diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e6e48a140..774ac2b93 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1762,6 +1762,42 @@ async fn vendored_takeover( } else { None }; + // Yarn classic twin: an offline mirror refuses the hosted rewrite + // (vendored mode works with one), so a vendored yarn classic entry + // must stay vendored rather than be reverted into neither mode. + let classic_entry = |entry: &socket_patch_core::vendor::VendorEntry| { + entry.ecosystem == "npm" && entry.flavor.as_deref() == Some("yarn-classic") + }; + let classic_takeover_refusal = if takeover + .iter() + .any(|(_, entry)| entry.as_ref().is_some_and(classic_entry)) + { + match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("yarn.lock")) + .await + { + Ok(lock) => { + let read_rc = |rel: &str| { + let path = common.cwd.join(rel); + async move { + socket_patch_core::utils::fs::read_regular_to_string(&path) + .await + .ok() + } + }; + let yarnrc = read_rc(socket_patch_core::patch::redirect::YARNRC_REL).await; + let npmrc = read_rc(socket_patch_core::patch::redirect::npmrc::NPMRC_REL).await; + socket_patch_core::patch::redirect::preflight_yarn_classic_hosted( + &lock, + yarnrc.as_deref(), + npmrc.as_deref(), + ) + .err() + } + Err(_) => None, + } + } else { + None + }; // vlt twin: the hosted rewriter's lock-level refusal must be known // before a vendored vlt entry is reverted, or the revert strips the // live vendored patch and the rewrite then refuses the lock. @@ -1856,6 +1892,11 @@ async fn vendored_takeover( }) .flatten() }) + .or_else(|| { + classic_takeover_refusal + .clone() + .filter(|_| entry.is_some_and(classic_entry)) + }) .or_else(|| { vlt_takeover_refusal .clone() diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index 2cf427052..a99fe241f 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -65,7 +65,8 @@ mod vex_e2e_common; mod yarn_classic_vex; use yarn_classic_vex::{ - require_yarn_classic, via_apply, yarn_classic, Embedded, ManifestlessVex, Wiring, + installs_file_tarballs, require_yarn_classic, via_apply, yarn_classic, yarn_classic_version, + Embedded, ManifestlessVex, Wiring, }; const ORG: &str = "test-org"; @@ -234,10 +235,14 @@ enum HostedDriver { /// (per `driver`: `scan --mode hosted --vex` or `get --mode hosted`), /// and the envelope/lockfile/ledger assertions. /// `tamper_served_tarball` serves DIFFERENT bytes at the hosted URL than the -/// sha1/integrity pins. `None` = skip (message printed). +/// sha1/integrity pins. `offline_mirror` configures `yarn-offline-mirror` +/// in `.yarnrc` before the fixture install, so the mirror holds the upstream +/// tarball, and asserts the hosted rewrite REFUSES (#364) instead of +/// pinning. `None` = skip (message printed). async fn classic_hosted_project( tag: &str, tamper_served_tarball: bool, + offline_mirror: bool, driver: HostedDriver, ) -> Option { if !require_yarn_classic(&format!("e2e_redirect_yarn_classic_build ({tag})"), |c| { @@ -255,6 +260,9 @@ async fn classic_hosted_project( ), ) .unwrap(); + if offline_mirror { + std::fs::write(proj.join(".yarnrc"), "yarn-offline-mirror \"./mirror\"\n").unwrap(); + } // 1. REAL fixture: yarn classic install (network here, private cache). let cache = tmp.path().join("yarn-cache"); @@ -428,13 +436,51 @@ async fn classic_hosted_project( ], }; let (code, stdout, stderr) = run_socket(&proj, &argv); + let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!("{driver:?} --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") + }); + if offline_mirror { + // With nothing redirected, `--vex` has nothing to attest: the scan + // fails closed on that rather than reporting a success. + if driver == HostedDriver::Scan { + assert_ne!(code, 0, "nothing attested must not exit 0: {env}"); + assert_eq!(env["error"]["code"], "manifest_not_found", "{env}"); + } + // #364: the mirror would serve the upstream tarball under the hosted + // URL's basename, so nothing is pinned, counted or attested. + assert_eq!( + env["redirect"]["redirected"], 0, + "a mirrored project must not count a redirect: {env}" + ); + assert!( + env.to_string().contains("redirect_yarn_classic_offline_mirror"), + "the refusal must be reported: {env}" + ); + assert_eq!( + std::fs::read_to_string(proj.join("yarn.lock")).unwrap(), + lock_pristine, + "the refused lock must stay byte-identical" + ); + if driver == HostedDriver::Scan { + let vex = std::fs::read_to_string(proj.join("out.vex.json")).unwrap_or_default(); + assert!( + !vex.contains("not_affected"), + "a refused redirect must not be attested: {vex}" + ); + } + return Some(ClassicRedirectFixture { + tmp, + proj, + orig, + patched, + lock_pristine: lock_pristine.into_bytes(), + server, + }); + } assert_eq!( code, 0, "{driver:?} --mode hosted failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); - let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { - panic!("{driver:?} --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") - }); assert_eq!(env["status"], "success", "envelope: {env}"); assert_eq!( env["redirect"]["redirected"], 1, @@ -661,7 +707,7 @@ fn hosted_dev_resave_vex(fx: &ClassicRedirectFixture) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_fresh_checkout_installs_patched_bytes() { - let Some(fx) = classic_hosted_project("main", false, HostedDriver::Scan).await else { + let Some(fx) = classic_hosted_project("main", false, false, HostedDriver::Scan).await else { return; }; @@ -700,7 +746,7 @@ async fn classic_redirect_fresh_checkout_installs_patched_bytes() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = classic_hosted_project("get-uuid", false, HostedDriver::GetUuid).await else { + let Some(fx) = classic_hosted_project("get-uuid", false, false, HostedDriver::GetUuid).await else { return; }; @@ -732,7 +778,7 @@ async fn classic_get_uuid_hosted_fresh_checkout_installs() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { - let Some(fx) = classic_hosted_project("tampered", true, HostedDriver::Scan).await else { + let Some(fx) = classic_hosted_project("tampered", true, false, HostedDriver::Scan).await else { return; }; @@ -764,6 +810,73 @@ async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { } } +/// #364: with `yarn-offline-mirror` set, yarn 1 looks the tarball up in the +/// mirror by the basename of `resolved`, which the hosted URL shares with +/// the upstream tarball already there, so a hosted pin would make every +/// install fail its integrity check. The hosted rewrite refuses instead +/// (the fixture asserts no redirect, no attestation, an untouched lock), +/// and the fresh checkout still installs offline from the mirror. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { + let Some(fx) = classic_hosted_project("offline-mirror", false, true, HostedDriver::Scan).await + else { + return; + }; + assert!( + fx.proj.join("mirror").join(format!("{DEP}-{DEP_VERSION}.tgz")).is_file(), + "the fixture install must populate the offline mirror" + ); + let fresh = fx.tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + for f in ["package.json", "yarn.lock", ".yarnrc"] { + std::fs::copy(fx.proj.join(f), fresh.join(f)).unwrap(); + } + copy_dir_recursive(&fx.proj.join("mirror"), &fresh.join("mirror")); + let fresh_cache = fx.tmp.path().join("fresh-yarn-cache"); + // yarn 1.0–1.6 install nothing from a mirror (a local tarball), with or + // without socket-patch: the control the issue measured. Pin that + // limitation there instead of the upstream bytes. + if !installs_file_tarballs(&yarn_classic_version()) { + let ci = corepack( + &fresh, + &yarn_classic(), + &["install", "--frozen-lockfile", "--no-progress"], + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ); + assert!( + ci.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&ci.stderr) + ); + assert!( + !fresh.join("node_modules").join(DEP).join("index.js").exists(), + "yarn < 1.7 is expected to install nothing from the mirror" + ); + return; + } + for extra in [&[][..], &["--offline"][..]] { + let mut args = vec!["install", "--frozen-lockfile", "--no-progress"]; + args.extend_from_slice(extra); + let ci = corepack( + &fresh, + &yarn_classic(), + &args, + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ); + assert!( + ci.status.success(), + "`yarn {args:?}` must still install from the mirror.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = + std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!(installed, fx.orig, "the untouched lock installs the upstream bytes"); + std::fs::remove_dir_all(fresh.join("node_modules")).unwrap(); + } +} + /// #363: a git-sourced dependency (`git+file://…#v1.3.0`) locks as a block /// yarn 1 fetches with GIT, from its `resolved`. `scan --mode hosted` must /// leave that block byte-identical (rewriting `resolved` to the hosted diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 3c51f2992..7de1d70de 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1657,6 +1657,68 @@ async fn berry_takeovers_refuse_before_reverting_the_old_mode() { } } +/// #364: an offline mirror (`yarn-offline-mirror` in `.yarnrc` or +/// `.npmrc`) refuses the yarn classic hosted rewrite, while vendored mode +/// works with one. A vendored→hosted takeover must therefore keep the +/// package vendored: reverting first would leave it patched in neither mode. +#[tokio::test] +async fn classic_vendored_to_hosted_takeover_refuses_with_offline_mirror() { + let server = wiremock::MockServer::start().await; + mount_berry_hosted_api(&server).await; + let code = "redirect_yarn_classic_offline_mirror"; + let pkg = r#"{"name":"classic-mirror","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}"#; + let lock = "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + left-pad@1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#aaaa\"\n \ + integrity sha512-UPSTREAMupstream==\n"; + for (rc, text) in [ + (".yarnrc", "yarn-offline-mirror \"./mirror\"\n"), + (".npmrc", "yarn-offline-mirror=./mirror\n"), + ] { + for dry in [true, false] { + let ctx = format!("{rc} dry={dry}"); + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + stage_berry_project(root, pkg, lock); + std::fs::remove_file(root.join(".yarnrc.yml")).unwrap(); + let (exit, env) = vendor_cli(root, &[]); + assert_eq!(exit, 0, "{ctx}: vendor: {env:#}"); + std::fs::write(root.join(rc), text).unwrap(); + let snapshot = || { + ["package.json", "yarn.lock", ".socket/vendor/state.json"] + .map(|rel| std::fs::read(root.join(rel)).ok()) + }; + let before = snapshot(); + 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:#}"); + assert_eq!( + snapshot(), + before, + "{ctx}: the vendored wiring and ledger stay byte-identical" + ); + assert!( + String::from_utf8(before[1].clone().unwrap()) + .unwrap() + .contains(".socket/vendor/npm/"), + "{ctx}: the lock was vendored" + ); + } + } +} + /// #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 diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f84f61b1a..36b5ccf82 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -507,6 +507,21 @@ pub async fn read_candidate_files( } } + // Beside a classic yarn.lock, the yarn configs decide whether an + // offline mirror serves the tarballs (the classic rewriter's refusal). + // Read strictly: a link or an unreadable in-memory entry could hide a + // mirror, so it is refused like a rewrite target. + if candidates.iter().any(|c| c.dep.ecosystem == "npm") + && out + .files + .get("yarn.lock") + .is_some_and(|lock| !crate::patch::redirect::is_berry_lock(lock)) + { + out.read(view, unreadable, crate::patch::redirect::YARNRC_REL) + .await; + out.read(view, unreadable, NPMRC_REL).await; + } + // Cargo workspace members (and in-root path dependencies) declare // dependencies of their own: a member's direct `cfg-if = "1"` must be // pinned alongside the root's, or the redirected lock entry is @@ -1614,6 +1629,11 @@ fn confirm( // `resolutions` routing to it; the URL in `yarn.lock` alone (the // routing removed, a refused re-pin) installs nothing, so the // berry rewriter's own report decides every dep its lock holds. + // A yarn classic lock beside an offline mirror installs the + // upstream mirror tarball whatever `resolved` says. + if rewrite.refused_yarn_classic_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } if rewrite.yarn_berry_uuids.contains(uuid) { return ProbeStep::Decided(rewrite.confirmed_yarn_berry_uuids.contains(uuid)); } @@ -1712,6 +1732,10 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { if let Some(eco) = crate::formats::registry::hosted_file_ecosystem(rel) { return Some(eco); } + // Read only beside a classic yarn.lock, for its offline-mirror gate. + if rel == crate::patch::redirect::YARNRC_REL || rel == NPMRC_REL { + return Some("npm"); + } let base = rel.rsplit('/').next().unwrap_or(rel); // A legacy Gradle lock (`gradle/dependency-locks/.lockfile`). if base.ends_with(".lockfile") { diff --git a/crates/socket-patch-core/src/hosted/memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs index f18efa81e..f3720a8b5 100644 --- a/crates/socket-patch-core/src/hosted/memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -50,9 +50,10 @@ pub(crate) const MANIFEST_REL: &str = ".socket/manifest.json"; pub(crate) const NPM_MANIFEST_REL: &str = "package.json"; /// Root-relative text files read beyond `REDIRECT_CANDIDATE_FILES`. -const EXTRA_TEXT_FILES: [&str; 5] = [ +const EXTRA_TEXT_FILES: [&str; 6] = [ PNPM_WORKSPACE_REL, NPMRC_REL, + crate::patch::redirect::YARNRC_REL, VENDOR_STATE_REL, MANIFEST_REL, NPM_MANIFEST_REL, diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 72c8b2967..16b94c0c9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -271,6 +271,14 @@ pub struct RewriteResult { serde(skip_serializing_if = "std::collections::BTreeSet::is_empty") )] pub confirmed_yarn_berry_uuids: std::collections::BTreeSet, + /// Patch uuids the yarn classic rewriter refused because the project + /// configures a `yarn-offline-mirror` (see + /// [`preflight_yarn_classic_hosted`]). Never confirmed. + #[cfg_attr( + test, + serde(skip_serializing_if = "std::collections::BTreeSet::is_empty") + )] + pub refused_yarn_classic_uuids: std::collections::BTreeSet, pub python_lock_uuids: std::collections::BTreeSet, pub confirmed_python_lock_uuids: std::collections::BTreeSet, pub refused_python_lock_uuids: std::collections::BTreeSet, @@ -662,6 +670,7 @@ fn merge_group_delta(result: &mut RewriteResult, delta: RewriteResult) { refused_pnpm_uuids, yarn_berry_uuids, confirmed_yarn_berry_uuids, + refused_yarn_classic_uuids, python_lock_uuids, confirmed_python_lock_uuids, refused_python_lock_uuids, @@ -695,6 +704,9 @@ fn merge_group_delta(result: &mut RewriteResult, delta: RewriteResult) { result .confirmed_yarn_berry_uuids .extend(confirmed_yarn_berry_uuids); + result + .refused_yarn_classic_uuids + .extend(refused_yarn_classic_uuids); result.python_lock_uuids.extend(python_lock_uuids); result .confirmed_python_lock_uuids @@ -3214,6 +3226,123 @@ fn plan_cargo_config( } // ── yarn.lock (classic) ────────────────────────────────────────────────────── +/// The project's `.yarnrc`, read beside a classic `yarn.lock` for its +/// `yarn-offline-mirror` setting. +pub const YARNRC_REL: &str = ".yarnrc"; + +/// The `yarn-offline-mirror` directory a project-level `.yarnrc` or +/// `.npmrc` configures, if any. Yarn 1 reads the key from its own +/// `.yarnrc` first and falls back to the npm config, so a `.yarnrc` entry +/// (even `false`) wins over `.npmrc`. An empty value or `false` means no +/// mirror. +pub fn yarn_classic_offline_mirror(yarnrc: Option<&str>, npmrc: Option<&str>) -> Option { + let value = yarnrc + .and_then(yarnrc_value_of_offline_mirror) + .or_else(|| npmrc.and_then(npmrc_value_of_offline_mirror))?; + (!value.is_empty() && value != "false").then_some(value) +} + +const YARN_OFFLINE_MIRROR_KEY: &str = "yarn-offline-mirror"; + +/// Strip one pair of matching quotes, as yarn's `.yarnrc` parser and npm's +/// ini parser both do. +fn unquote_rc_value(raw: &str) -> &str { + let raw = raw.trim(); + for q in ['"', '\''] { + if raw.len() >= 2 && raw.starts_with(q) && raw.ends_with(q) { + return &raw[1..raw.len() - 1]; + } + } + raw +} + +/// The last `yarn-offline-mirror` value in a `.yarnrc` (`key value` or +/// `key: value` lines, key optionally quoted, `#` comments); later lines +/// override earlier ones. +fn yarnrc_value_of_offline_mirror(text: &str) -> Option { + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, rest) = match line.strip_prefix('"') { + Some(quoted) => match quoted.split_once('"') { + Some((key, rest)) => (key, rest), + None => continue, + }, + // yarn's `.yarnrc` parser also ends an unquoted key at `:`, so + // `key: value` and `key:value` set the key like `key value`. + None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { + Some((key, rest)) => (key, rest), + None => (line, ""), + }, + }; + if key == YARN_OFFLINE_MIRROR_KEY { + let rest = rest.trim_start(); + let rest = rest.strip_prefix(':').unwrap_or(rest); + found = Some(unquote_rc_value(rest).to_string()); + } + } + found +} + +/// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini +/// `key = value` lines, `#`/`;` comments, `[section]` headers end the +/// top level). +fn npmrc_value_of_offline_mirror(text: &str) -> Option { + let mut found = None; + for line in text.lines() { + let line = line.trim(); + if line.starts_with('[') { + break; + } + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if unquote_rc_value(key) == YARN_OFFLINE_MIRROR_KEY { + found = Some(unquote_rc_value(value).to_string()); + } + } + found +} + +/// The project-level refusal of the yarn classic hosted rewriter: a +/// configured `yarn-offline-mirror`. Yarn 1 stores and looks up mirror +/// tarballs by the basename of `resolved`, and the hosted URL ends in the +/// same `-.tgz` as the upstream tarball already in the +/// mirror, so yarn installs the upstream bytes and fails the patched +/// integrity (or, `--offline`, never fetches the patched tarball at all). +/// `Ok` for a lock that is not classic (the berry rewriter owns those). +/// +/// Exposed so the vendored→hosted mode takeover can refuse BEFORE it +/// reverts a vendored yarn classic entry (vendored mode works with a +/// mirror), like [`preflight_yarn_berry_hosted`]. +pub fn preflight_yarn_classic_hosted( + lock: &str, + yarnrc: Option<&str>, + npmrc: Option<&str>, +) -> Result<(), RewriteWarning> { + if is_berry_lock(lock) { + return Ok(()); + } + match yarn_classic_offline_mirror(yarnrc, npmrc) { + Some(mirror) => Err(RewriteWarning { + code: "redirect_yarn_classic_offline_mirror".into(), + detail: format!( + "the project sets `yarn-offline-mirror` ({mirror}); yarn looks mirror \ + tarballs up by file name, and the hosted tarball has the same name as \ + the upstream one, so installs would get the unpatched bytes and fail \ + the integrity check; leaving yarn.lock untouched (use --mode vendored)" + ), + }), + None => Ok(()), + } +} + fn rewrite_yarn_classic( files: &BTreeMap, overrides: &[DepOverride], @@ -3229,6 +3358,16 @@ fn rewrite_yarn_classic( if is_berry_lock(raw) { return; // yarn-berry — not classic } + // An offline mirror refuses every dep this lock locks (see + // [`preflight_yarn_classic_hosted`]): nothing is rewritten, and the + // refused uuids are never confirmed, so an earlier run's URL left in + // the lock attests nothing either. + let mirror_refusal = preflight_yarn_classic_hosted( + raw, + files.get(YARNRC_REL).map(String::as_str), + files.get(npmrc::NPMRC_REL).map(String::as_str), + ) + .err(); // CRLF locks (core.autocrlf Windows checkouts — yarn v1 parses them fine) // are processed LF-normalized and re-expanded on output, so untouched // lines round-trip byte-identically. Without this, `split("\n\n")` never @@ -3340,6 +3479,12 @@ fn rewrite_yarn_classic( continue; } matched_any = true; + if mirror_refusal.is_some() { + result + .refused_yarn_classic_uuids + .insert(dep.patch_uuid.clone()); + continue; + } let frag = dep .integrity .sha1 @@ -3397,6 +3542,11 @@ fn rewrite_yarn_classic( }); } } + if let Some(warning) = mirror_refusal { + if !result.refused_yarn_classic_uuids.is_empty() { + result.warnings.push(warning); + } + } if changed { let mut out = blocks.join("\n\n"); if crlf { @@ -9721,6 +9871,122 @@ mod tests { ); } + /// #364: yarn 1 looks offline-mirror tarballs up by the basename of + /// `resolved`, which the hosted URL shares with the upstream tarball in + /// the mirror, so a project-level mirror (`.yarnrc` or `.npmrc`) + /// refuses the rewrite untouched, and the dep is never confirmed. + #[test] + fn yarn_classic_offline_mirror_refuses_rewrite() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/patch/npm/left-pad/1.3.0/tok/u/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + let cases = [ + (YARNRC_REL, "yarn-offline-mirror \"./mirror\"\n"), + (YARNRC_REL, "# offline\n\"yarn-offline-mirror\" ./mirror\n"), + (YARNRC_REL, "yarn-offline-mirror ./mirror\r\n"), + (YARNRC_REL, "yarn-offline-mirror: ./mirror\n"), + (YARNRC_REL, "yarn-offline-mirror:./mirror\n"), + (YARNRC_REL, "yarn-offline-mirror : \"./mirror\"\n"), + (YARNRC_REL, "\"yarn-offline-mirror\": \"./mirror\"\n"), + (npmrc::NPMRC_REL, "yarn-offline-mirror = ./mirror\n"), + (npmrc::NPMRC_REL, "yarn-offline-mirror=\"./mirror\"\n"), + ]; + for (rc, text) in cases { + for lock in [ + classic_lock_two_entries(), + classic_lock_two_entries().replace('\n', "\r\n"), + ] { + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock); + files.insert(rc.to_string(), text.to_string()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "{rc} {text:?}: lock must stay untouched: {:?}", + r.files + ); + assert_eq!( + r.warnings.len(), + 1, + "{rc} {text:?}: one refusal: {:?}", + r.warnings + ); + assert_eq!(r.warnings[0].code, "redirect_yarn_classic_offline_mirror"); + assert!( + r.warnings[0].detail.contains("./mirror") + && r.warnings[0].detail.contains("--mode vendored"), + "{}", + r.warnings[0].detail + ); + assert!(r.refused_yarn_classic_uuids.contains(&ovr.patch_uuid)); + } + } + } + + /// No mirror, a disabled one, a look-alike key, or an `.npmrc` mirror + /// that `.yarnrc` turns off leaves the classic rewrite as before. + #[test] + fn yarn_classic_without_effective_offline_mirror_rewrites() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let cases: [&[(&str, &str)]; 8] = [ + &[], + &[(YARNRC_REL, "yarn-offline-mirror false\n")], + &[(YARNRC_REL, "yarn-offline-mirror: false\n")], + &[(YARNRC_REL, "yarn-offline-mirror:\n")], + &[(YARNRC_REL, "yarn-offline-mirror \"\"\n")], + &[(YARNRC_REL, "yarn-offline-mirror-pruning true\n# yarn-offline-mirror ./m\n")], + &[(npmrc::NPMRC_REL, "[scope]\nyarn-offline-mirror=./m\n")], + &[ + (YARNRC_REL, "yarn-offline-mirror false\n"), + (npmrc::NPMRC_REL, "yarn-offline-mirror=./m\n"), + ], + ]; + for rcs in cases { + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + for (rc, text) in rcs { + files.insert(rc.to_string(), text.to_string()); + } + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{rcs:?}: {:?}", r.warnings); + assert!(r.files["yarn.lock"].contains("http://p.test/lp.tgz"), "{rcs:?}"); + assert!(r.refused_yarn_classic_uuids.is_empty(), "{rcs:?}"); + } + } + + /// The mirror only gates deps the classic lock locks, and never a berry + /// lock (berry has no offline mirror; its cache is checksummed). + #[test] + fn yarn_classic_offline_mirror_preflight_scope() { + let rc = Some("yarn-offline-mirror ./mirror\n"); + assert!(preflight_yarn_classic_hosted(&classic_lock_two_entries(), rc, None).is_err()); + assert!(preflight_yarn_classic_hosted(&classic_lock_two_entries(), None, None).is_ok()); + let berry = "__metadata:\n version: 8\n cacheKey: 10c0\n"; + assert!(preflight_yarn_classic_hosted(berry, rc, None).is_ok()); + + let other = npm_override("not-locked", "1.0.0", "http://p.test/x.tgz", "sha512-X=="); + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + files.insert(YARNRC_REL.to_string(), rc.unwrap().to_string()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&other), &mut r); + assert!(r.refused_yarn_classic_uuids.is_empty()); + assert_eq!( + r.warnings.iter().map(|w| w.code.as_str()).collect::>(), + ["redirect_yarn_classic_entry_not_found"] + ); + } + /// `"@npm:@…"` is yarn v1's fork-substitution idiom: /// the block resolves a DIFFERENT package that merely tracks the patched /// version. It must never be hijacked onto the upstream patched artifact; diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 14735578b..e540fc6ff 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -86,6 +86,13 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. upstream bytes. Use a clean install tree and an empty store; `--force` is not a reliable substitute. Run `socket-patch vex` after installation to verify the patched files. See the [compatibility matrix and workflow](testing/pnpm-compatibility.md). +- **yarn classic** — the `yarn.lock` entry's `resolved` / `integrity` are + rewritten to the hosted tarball. A project that sets `yarn-offline-mirror` + (in `.yarnrc` or `.npmrc`) is refused with + `redirect_yarn_classic_offline_mirror`. Yarn looks mirror tarballs up by + file name, and the hosted tarball has the same name as the upstream one + already in the mirror, so installs would get the unpatched bytes and fail + the integrity check. Use `--mode vendored` there; it works with a mirror. - **yarn berry** — the redirect pins the way yarn does for a root `resolutions` entry (cacheKey `10c0` / yarn 4): `package.json` routes the locked descriptor (`"left-pad@npm:^1.3.0"`) to the hosted tarball and only that `yarn.lock` entry