Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1637,25 +1637,32 @@ async fn vendored_takeover(
None
};
// The takeover refusal (if any) for one candidate: bun gates every
// npm purl, berry and vlt only their own vendored entries. A refused
// purl is never dispatched (see the loop), so its wiring is not a
// write target here.
// npm purl, berry and vlt only their own vendored entries. Berry also
// runs the rewriter's per-dep grant gate (a grant without the berry
// cache checksum is skipped by the rewriter, so reverting first would
// leave the package in neither mode). A refused purl is never
// dispatched (see the loop), so its wiring is not a write target here.
let takeover_refusal = |c: &Candidate,
entry: Option<&socket_patch_core::vendor::VendorEntry>|
-> Option<&socket_patch_core::patch::redirect::RewriteWarning> {
-> Option<socket_patch_core::patch::redirect::RewriteWarning> {
if !c.purl.starts_with("pkg:npm/") {
return None;
}
let berry = entry.is_some_and(berry_entry);
bun_takeover_refusal
.as_ref()
.clone()
.or_else(|| berry_takeover_refusal.clone().filter(|_| berry))
.or_else(|| {
berry_takeover_refusal
.as_ref()
.filter(|_| entry.is_some_and(berry_entry))
berry
.then(|| {
socket_patch_core::patch::redirect::preflight_yarn_berry_hosted_dep(&c.dep)
.err()
})
.flatten()
})
.or_else(|| {
vlt_takeover_refusal
.as_ref()
.clone()
.filter(|_| entry.is_some_and(vlt_entry))
})
};
Expand Down Expand Up @@ -1684,8 +1691,10 @@ async fn vendored_takeover(
if let Some(entry) = ledger_entry {
if let Some(warning) = takeover_refusal(candidate, Some(entry)) {
refused.push(purl.clone());
if !out.pre_warnings.iter().any(|w| w["code"] == warning.code) {
out.pre_warnings.push(serde_json::json!(warning));
// Project-level refusals repeat per purl; report each once.
let warning = serde_json::json!(warning);
if !out.pre_warnings.contains(&warning) {
out.pre_warnings.push(warning);
}
continue;
}
Expand Down Expand Up @@ -1827,8 +1836,8 @@ async fn vendored_takeover(
for purl in &refused {
if let Some((c, entry)) = takeover.iter().find(|(c, _)| &c.purl == purl) {
let reason = takeover_refusal(c, entry.as_ref())
.map_or("vendored_revert_failed", |w| w.code.as_str());
skipped.push(SkippedPatch::new(purl, &c.dep.patch_uuid, reason));
.map_or_else(|| "vendored_revert_failed".to_string(), |w| w.code);
skipped.push(SkippedPatch::new(purl, &c.dep.patch_uuid, &reason));
}
}
// Purls leaving the rewrite set: refused takeovers, plus the dry-run
Expand Down
17 changes: 14 additions & 3 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2352,12 +2352,23 @@ pub(crate) async fn vendor_records_reusing(
// code and detail, in the dry run and the wet run alike —
// so the hosted wiring stays untouched.
if candidate.starts_with("pkg:npm/") {
let refusal = berry_takeover_refusal
let project = berry_takeover_refusal
.get_or_init(|| {
socket_patch_core::vendor::yarn_berry_vendor_preflight(&common.cwd)
})
.await;
if let Some((code, detail)) = refusal {
.await
.clone();
let refusal = match project {
Some(refusal) => Some(refusal),
None => {
socket_patch_core::vendor::yarn_berry_vendor_target_preflight(
&common.cwd,
candidate,
)
.await
}
};
if let Some((code, detail)) = &refusal {
has_errors = true;
env.record(
PatchEvent::new(PatchAction::Failed, candidate.clone())
Expand Down
158 changes: 152 additions & 6 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,13 @@ async fn berry_mixed_line_endings_fail_closed_with_code() {
/// reference carrying the yarn-berry-zip checksum, the patch view) for the
/// berry takeover legs. Returns the hosted tarball URL.
async fn mount_berry_hosted_api(server: &wiremock::MockServer) -> String {
mount_berry_hosted_api_opts(server, true).await
}

/// [`mount_berry_hosted_api`], with the grant's `yarn-berry-zip` artifact
/// (the `yarnBerry10c0` cache checksum) present or not. Vendored mode only
/// uses the `tarball` artifact, so a vendorable grant can lack it.
async fn mount_berry_hosted_api_opts(server: &wiremock::MockServer, berry_zip: bool) -> String {
use wiremock::matchers::{method, path, path_regex};
use wiremock::{Mock, ResponseTemplate};
let org = "test-org";
Expand Down Expand Up @@ -1062,17 +1069,18 @@ async fn mount_berry_hosted_api(server: &wiremock::MockServer) -> String {
})))
.mount(server)
.await;
let mut artifacts = vec![json!({ "kind": "tarball", "url": hosted_url,
"integrity": { "sha512": "sha512-unused-by-berry==" } })];
if berry_zip {
artifacts.push(json!({ "kind": "yarn-berry-zip", "url": hosted_url,
"integrity": { "yarnBerry10c0": format!("10c0/{}", "7".repeat(128)) } }));
}
Mock::given(method("POST"))
.and(path(format!("/v0/orgs/{org}/patches/package")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"results": { UUID: {
"status": "granted", "url": hosted_url, "purl": PURL,
"artifacts": [
{ "kind": "tarball", "url": hosted_url,
"integrity": { "sha512": "sha512-unused-by-berry==" } },
{ "kind": "yarn-berry-zip", "url": hosted_url,
"integrity": { "yarnBerry10c0": format!("10c0/{}", "7".repeat(128)) } }
],
"artifacts": artifacts,
"registryOverride": null
}}
})))
Expand Down Expand Up @@ -1588,6 +1596,144 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() {
}
}

/// #468: a vendored→hosted takeover whose grant has no `yarnBerry10c0`
/// cache checksum (vendored mode never needs it) must keep the package
/// vendored. The berry rewriter skips such a dep with
/// `redirect_yarn_berry_missing_checksum`; reverting the vendored wiring
/// first left it patched in neither mode while the run exited 0 announcing
/// "now fully hosted".
#[tokio::test]
async fn berry_vendored_to_hosted_takeover_keeps_vendored_without_berry_checksum() {
let server = wiremock::MockServer::start().await;
mount_berry_hosted_api_opts(&server, false).await;
let code = "redirect_yarn_berry_missing_checksum";
for dry in [true, false] {
let ctx = format!("dry={dry}");
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
stage_berry_project(root, BERRY_WIN_PKG, &berry_win_lock());
let (exit, env) = vendor_cli(root, &[]);
assert_eq!(exit, 0, "{ctx}: vendor: {env:#}");
let before = berry_wiring_snapshot(root);
let extra: &[&str] = if dry { &["--dry-run"] } else { &[] };
let (_, env) = hosted_scan_cli_with(root, &server.uri(), extra);
let text = env.to_string();
assert!(text.contains(code), "{ctx}: refused with {code}: {env:#}");
for announced in [
"redirect_takeover_reverted_vendored",
"redirect_would_revert_vendored",
] {
assert!(
!text.contains(announced),
"{ctx}: no takeover ({announced}): {env:#}"
);
}
assert_eq!(env["redirect"]["redirected"], 0, "{ctx}: {env:#}");
let skipped = env["redirect"]["skipped"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
skipped
.iter()
.any(|s| s["purl"] == PURL && s["reason"] == code),
"{ctx}: the purl is skipped with the gate's code: {env:#}"
);
assert_eq!(
berry_wiring_snapshot(root),
before,
"{ctx}: the vendored wiring, ledger and artifact stay byte-identical"
);
}
}

/// #369: a hosted→vendored takeover must run the berry backend's
/// per-package gates (another locked version of the name, a user-authored
/// `resolutions` override) BEFORE restoring the upstream registry entry.
/// Restoring first left the package patched in neither mode: the hosted
/// redirect was gone and vendoring then refused with
/// `vendor_override_conflict`.
#[tokio::test]
async fn berry_hosted_to_vendored_takeover_runs_package_gates_first() {
let server = wiremock::MockServer::start().await;
mount_berry_hosted_api(&server).await;
type Break = fn(&Path);
// A workspace member's lock entry for another version of the name: a
// name-keyed `resolutions` entry would move it too.
let other_version: Break = |root| {
let lock = std::fs::read_to_string(root.join("yarn.lock")).unwrap();
let extra = format!(
"\n\"left-pad@npm:1.1.3\":\n version: 1.1.3\n \
resolution: \"left-pad@npm:1.1.3\"\n checksum: 10c0/{}\n \
languageName: node\n linkType: hard\n",
"5".repeat(128)
);
std::fs::write(root.join("yarn.lock"), lock + &extra).unwrap();
};
// A user-authored range override for the name.
let user_resolution: Break = |root| {
let pkg = std::fs::read_to_string(root.join("package.json")).unwrap();
let pkg = pkg.replacen(
"\"private\": true,",
"\"private\": true,\n \"resolutions\": {\n \"left-pad\": \"^1.0.0\"\n },",
1,
);
std::fs::write(root.join("package.json"), pkg).unwrap();
};
for (label, breakage) in [
("other locked version", other_version),
("user resolutions", user_resolution),
] {
for dry in [true, false] {
let ctx = format!("{label} dry={dry}");
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
stage_berry_project(root, BERRY_WIN_PKG, &berry_win_lock());
let (exit, env) = hosted_scan_cli_with(root, &server.uri(), &[]);
assert_eq!(exit, 0, "{ctx}: hosted scan: {env:#}");
assert_eq!(env["redirect"]["redirected"], 1, "{ctx}: {env:#}");
breakage(root);
let before = berry_wiring_snapshot(root);
// The hosted pin's origin must count as the patch server, or
// the vendor run never sees it as a takeover.
let uri = server.uri();
let mut args = vec![
"vendor",
"--json",
"--cwd",
root.to_str().unwrap(),
"--patch-server-url",
&uri,
];
if dry {
args.push("--dry-run");
}
let (exit, stdout, stderr) = run_cli(root, &args, &[]);
let text = format!("{stdout}\n{stderr}");
eprintln!("DBG {ctx} exit={exit} {text}");
assert_ne!(exit, 0, "{ctx}: the refusal fails the run: {text}");
assert!(
text.contains("vendor_override_conflict"),
"{ctx}: refused with the gate's code: {text}"
);
for announced in [
"vendor_takeover_reverted_redirect",
"vendor_would_revert_redirect",
] {
assert!(
!text.contains(announced),
"{ctx}: no takeover ({announced}): {text}"
);
}
assert_eq!(
berry_wiring_snapshot(root),
before,
"{ctx}: the hosted lock edits stay byte-identical"
);
}
}
}

// ─────────────────────────────────────────────────────────────────────
// 9. offline with no local source
// ─────────────────────────────────────────────────────────────────────
Expand Down
35 changes: 28 additions & 7 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3233,6 +3233,30 @@ fn berry_cache_key(content: &str) -> Option<String> {
/// compares the file with its own majority-normalized re-render and fails
/// (YN0028), while a plain install rewrites every minority line — so it is
/// refused untouched, `yarn install` normalizes it first.
/// The per-dep refusal of the yarn berry hosted rewriter that depends only
/// on the grant, not on the lock: a dep whose grant carries no
/// `yarnBerry10c0` cache checksum cannot be redirected (berry verifies the
/// converted cache zip, and only the service can compute that checksum).
///
/// Exposed for the vendored→hosted mode takeover, like
/// [`preflight_yarn_berry_hosted`]: vendored mode only uses the `tarball`
/// artifact, so a vendorable patch can lack the berry checksum, and the
/// takeover must keep such a package vendored instead of reverting it and
/// then skipping the redirect.
pub fn preflight_yarn_berry_hosted_dep(dep: &DepOverride) -> Result<(), RewriteWarning> {
if dep.integrity.yarn_berry10c0.is_some() {
return Ok(());
}
Err(RewriteWarning {
code: "redirect_yarn_berry_missing_checksum".into(),
detail: format!(
"{}@{} has no yarnBerry10c0 cache checksum",
full_name(dep),
dep.version
),
})
}

pub fn preflight_yarn_berry_hosted(lock: &str, yarnrc: Option<&str>) -> Result<(), RewriteWarning> {
if !is_berry_lock(lock) {
return Ok(());
Expand Down Expand Up @@ -3325,19 +3349,16 @@ fn rewrite_yarn_berry(
let fname = full_name(dep);
// The API hands the prefixed `10c0/<hex>`; a yarn 4.0.x lock spells
// its checksums bare, and `--immutable` rejects a respelled one.
if let Err(warning) = preflight_yarn_berry_hosted_dep(dep) {
result.warnings.push(warning);
continue;
}
let Some(checksum) = dep
.integrity
.yarn_berry10c0
.as_deref()
.map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c))
else {
result.warnings.push(RewriteWarning {
code: "redirect_yarn_berry_missing_checksum".into(),
detail: format!(
"{fname}@{} has no yarnBerry10c0 cache checksum",
dep.version
),
});
continue;
};
// Berry versions are UNQUOTED (` version: 1.3.0`).
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/vendor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub use verify::{
};
// The hosted→vendored takeover refuses a berry project the backend would
// refuse BEFORE it reverts the hosted redirect.
pub use yarn_berry_lock::yarn_berry_vendor_preflight;
pub use yarn_berry_lock::{yarn_berry_vendor_preflight, yarn_berry_vendor_target_preflight};

use std::collections::{HashMap, HashSet};
use std::path::Path;
Expand Down
35 changes: 35 additions & 0 deletions crates/socket-patch-core/src/vendor/yarn_berry_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,41 @@ pub async fn yarn_berry_vendor_preflight(project_root: &Path) -> Option<(&'stati
.and_then(into_pair)
}

/// The per-package twin of [`yarn_berry_vendor_preflight`] for the
/// hosted→vendored takeover: the backend's `resolutions` conflict gate and
/// its lock-entry gates for `purl` (another version of the name, a
/// non-npm protocol, a mixed-descriptor or duplicate entry). Evaluated on
/// the still-hosted files, which give the same verdict as the restored
/// ones: the hosted redirect only rewrites the target entry's
/// `resolution:` / `checksum:` lines, never its descriptor key, any other
/// entry, or `package.json`. Returns `(code, detail)`, exactly the refusal
/// the backend would raise after the restore; `None` when it would not
/// refuse (or the files are unreadable, which the backend reports itself).
pub async fn yarn_berry_vendor_target_preflight(
project_root: &Path,
purl: &str,
) -> Option<(&'static str, String)> {
use super::npm_flavor::{detect_npm_lock_flavor, NpmLockFlavor};
if !matches!(
detect_npm_lock_flavor(project_root).await,
Ok((NpmLockFlavor::YarnBerry, _))
) {
return None;
}
let (name, version) = super::npm_common::parse_npm_purl(purl)?;
let pkg_bytes = read_regular_to_bytes(&project_root.join(PACKAGE_JSON))
.await
.ok()?;
let pkg = parse_json_manifest(&pkg_bytes).ok()?;
if let Err(outcome) = resolutions_gate(pkg.as_object()?, &name, &version) {
if let VendorOutcome::Refused { code, detail } = *outcome {
return Some((code, detail));
}
}
let lock_text = read_yarn_lock(project_root).await.ok()?;
scan_berry_target(&scan_blocks(&lock_text), &name, &version).err()
}

/// Commit the pair in contract order — package.json first, yarn.lock second
/// — unwinding package.json to its original bytes when the lock write fails
/// (a resolutions entry without its lock counterpart would let a plain
Expand Down
Loading