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 511788da5..2cf427052 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 @@ -763,3 +763,216 @@ async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { ); } } + +/// #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 +/// tarball made every later install fail with `git ls-remote` on a `.tgz`), +/// say so with `redirect_yarn_classic_git_skipped`, and attest nothing in +/// its in-run VEX. The fresh-checkout `yarn install --frozen-lockfile` still +/// succeeds. The git repo is local, so no registry is needed. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_git_sourced_dependency_is_left_unrewired() { + if cfg!(windows) { + // `git+file:` urls over a drive-letter path are not a shape yarn 1 + // parses reliably; the rewriter logic is OS-independent and unit + // tested. + println!("SKIP classic_git_sourced_dependency_is_left_unrewired: not on Windows"); + return; + } + if !require_yarn_classic("e2e_redirect_yarn_classic_build (git)", |c| { + cache_env::isolate(c); + }) { + return; + } + let tmp = tempfile::tempdir().unwrap(); + // A local git source of left-pad@1.3.0, tagged v1.3.0. + let repo = tmp.path().join("lpgit"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write( + repo.join("package.json"), + format!(r#"{{"name":"{DEP}","version":"{DEP_VERSION}","main":"index.js"}}"#), + ) + .unwrap(); + let orig: &[u8] = b"module.exports = function leftPad(s) { return s; };\n"; + std::fs::write(repo.join("index.js"), orig).unwrap(); + for args in [ + &["init", "-q"][..], + &["add", "-A"], + &[ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "v", + ], + &["tag", "v1.3.0"], + ] { + let st = Command::new("git") + .args(args) + .current_dir(&repo) + .status() + .expect("git"); + assert!(st.success(), "git {args:?}"); + } + + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + format!( + r#"{{"name":"git-classic","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"git+file://{}#v1.3.0"}}}}"#, + repo.display() + ), + ) + .unwrap(); + let cache = tmp.path().join("yarn-cache"); + let install = corepack( + &proj, + &yarn_classic(), + &["install", "--no-progress"], + &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + ); + if !install.status.success() { + skip!( + "(git): fixture `yarn install` of the git source failed:\n{}", + String::from_utf8_lossy(&install.stderr) + ); + return; + } + let lock_pristine = std::fs::read_to_string(proj.join("yarn.lock")).unwrap(); + assert!( + lock_pristine.contains("resolved \"git+file://"), + "fixture must lock a git block:\n{lock_pristine}" + ); + + // A granted hosted patch for the same name@version. + let installed_dir = proj.join("node_modules").join(DEP); + let patched: Vec = [MARKER.as_bytes(), orig].concat(); + let tgz_path = tmp.path().join("patched.tgz"); + build_patched_tgz(&installed_dir, &patched, &tgz_path); + let tgz = std::fs::read(&tgz_path).unwrap(); + let server = MockServer::start().await; + let hosted_url = format!( + "{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz", + server.uri() + ); + let summary = serde_json::json!({ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", "title": "git classic fixture" + }); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ "purl": PURL, "patches": [summary] }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { UUID: { + "status": "granted", "url": hosted_url, "purl": PURL, + "artifacts": [{ "kind": "tarball", "url": hosted_url, + "integrity": { "sha512": sha512_sri(&tgz), "sha1": sha1_hex(&tgz) } }], + "registryOverride": null + } } + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z", + "files": { "package/index.js": { + "beforeHash": compute_git_sha256_from_bytes(orig), + "afterHash": compute_git_sha256_from_bytes(&patched), + } }, + "vulnerabilities": { GHSA: { + "cves": [CVE], "summary": "s", "severity": "high", "description": "d" + } }, + "description": "x", "license": "MIT", "tier": "free" + }))) + .mount(&server) + .await; + + let api_url = server.uri(); + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &api_url, + "--org", + ORG, + "--api-token", + "fake", + "--vex", + "out.vex.json", + "--vex-product", + PRODUCT, + ], + ); + println!("scan exit {code}\nstdout:\n{stdout}\nstderr:\n{stderr}"); + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("scan --json output is not JSON: {e}\n{stdout}\n{stderr}")); + assert_eq!( + std::fs::read_to_string(proj.join("yarn.lock")).unwrap(), + lock_pristine, + "the git block must stay byte-identical: {env}" + ); + assert!( + env.to_string() + .contains("redirect_yarn_classic_git_skipped"), + "the skip must be named: {env}" + ); + let vex = std::fs::read_to_string(proj.join("out.vex.json")).unwrap_or_default(); + assert!( + !vex.contains("not_affected"), + "nothing may be attested for the git copy:\n{vex}\n{env}" + ); + + // A fresh checkout still installs (from git, unpatched). + let fresh = tmp.path().join("fresh"); + std::fs::create_dir_all(&fresh).unwrap(); + std::fs::copy(proj.join("package.json"), fresh.join("package.json")).unwrap(); + std::fs::copy(proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap(); + let fresh_cache = tmp.path().join("fresh-yarn-cache"); + let ci = corepack( + &fresh, + &yarn_classic(), + &["install", "--frozen-lockfile", "--no-progress"], + &[("YARN_CACHE_FOLDER", fresh_cache.to_str().unwrap())], + ); + assert!( + ci.status.success(), + "fresh `yarn install --frozen-lockfile` must still succeed.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); +} diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index b093b4929..d1b5b1607 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1107,6 +1107,57 @@ async fn a_refused_pin_fails_closed_beside_a_restored_one() { ); } +/// #363: an older release rewired a git-pattern yarn-classic block to the +/// hosted tarball. yarn 1 fetches that pattern with git, from `resolved`, so +/// restoring a registry tarball there still fails every install. The pin is +/// refused with the `git checkout` remedy and the lock left untouched, +/// instead of a "success" that installs nothing; a registry pin beside it +/// still restores. +#[tokio::test] +#[serial] +async fn a_git_pattern_hosted_pin_is_refused_not_restored_to_the_registry() { + let server = MockServer::start().await; + mock_yarn_registry(&server, "left-pad", "1.2.3").await; + mock_yarn_registry(&server, "is-odd", "3.0.1").await; + let tmp = tempfile::tempdir().unwrap(); + let git_wired = format!( + "\"left-pad@git+https://github.com/stevemao/left-pad.git#v1.2.3\":\n \ + version \"1.2.3\"\n resolved \"{LP_HOSTED_URL}\"\n integrity sha512-PATCHEDpatched==" + ); + std::fs::write( + tmp.path().join("yarn.lock"), + yarn_lock_content(&format!("{git_wired}\n\n{}", io_redirected_block())), + ) + .unwrap(); + + let (code, envelope) = run_rollback_subprocess_online(tmp.path(), &server, &[]); + assert_eq!(code, 1, "{envelope}"); + assert_eq!(envelope["status"], "partial_failure", "{envelope}"); + assert_eq!(envelope["hosted"]["reverted"], serde_json::json!([IO_PURL])); + // Discovery already refuses to attribute the git-wired entry, so the + // pin fails closed as contested wiring before any restore is planned. + assert_eq!( + envelope["hosted"]["failed"].as_array().map(Vec::len), + Some(1), + "{envelope}" + ); + assert!( + envelope["hosted"]["failed"][0]["error"] + .as_str() + .is_some_and(|e| e.contains("installs from git") + && e.contains("`git checkout -- yarn.lock`")), + "{envelope}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&format!( + "{git_wired}\n\n{}", + yarn_upstream_block("is-odd", "3.0.1") + )), + "the git block is left as it was; the registry pin is restored" + ); +} + // --------------------------------------------------------------------------- // 5. manifest-less hosted-only project vs. the truly-empty project // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c5b565053..a2c6097f4 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -276,8 +276,9 @@ pub struct RewriteResult { /// a scoped registry or jsr, which hosted mode leaves unpatched. pub vlt_foreign_uuids: std::collections::BTreeSet, /// Patch uuids with a same-`name@version` bundled instance the rewriter - /// skipped (Bun's `bundled` entries/records, #469): that copy is - /// unpacked from its parent's tarball and stays unpatched, so a + /// skipped (Bun's `bundled` entries/records, #469), or a yarn classic + /// git-fetched entry (#363): that copy is unpacked from its parent's + /// tarball or checked out from git and stays unpatched, so a /// confirmation of the uuid must never stand in for the installed tree /// (in-run VEX verifies it instead). Left out of the golden digests /// while empty, so the blessed oracle outputs predating it still hold. @@ -3121,6 +3122,7 @@ fn rewrite_yarn_classic( .expect("version regex from the escaped version is valid"); let mut matched_any = false; let mut alias_skipped = false; + let mut git_skipped = false; for (i, block) in blocks.iter_mut().enumerate() { // The block's key line names its consumers; resolve every // comma-joined pattern to the REAL package it stands for @@ -3138,6 +3140,29 @@ fn rewrite_yarn_classic( continue; } let patterns = split_key_patterns(key); + // yarn 1 fetches a git pattern with git, handing it `resolved` + // as the remote (#363): a tarball there fails every install, so + // the block stays byte-identical and that copy keeps the git + // bytes — never assumed patched by the in-run VEX. Checked + // before the alias gate: an alias of a git range is git too. + let resolved = block + .lines() + .find_map(|l| l.strip_prefix(" resolved ")) + .map(|v| v.trim().trim_matches('"')); + if crate::vendor::yarn_classic_lock::classic_block_is_git(&patterns, resolved) { + git_skipped = true; + result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_git_skipped".into(), + detail: format!( + "lock entry `{key}` installs {fname}@{} from git, which yarn fetches \ + from the git source rather than a tarball; the hosted redirect leaves \ + it untouched, so this copy stays unpatched", + dep.version + ), + }); + continue; + } // A block reached only through `alias@npm:@range` // descriptors is left byte-identical (mirroring the berry // rewriter), but never silently: that copy keeps installing the @@ -3209,7 +3234,7 @@ fn rewrite_yarn_classic( changed = true; } } - if !matched_any && !alias_skipped { + if !matched_any && !alias_skipped && !git_skipped { result.warnings.push(RewriteWarning { code: "redirect_yarn_classic_entry_not_found".into(), detail: format!("no yarn.lock entry resolving {fname}@{}", dep.version), @@ -9068,6 +9093,93 @@ mod tests { assert_eq!(r.warnings[0].code, "redirect_yarn_classic_entry_not_found"); } + /// #363: yarn 1 fetches a git-pattern block with its git fetcher from + /// the block's `resolved`, so a hosted tarball there makes every later + /// install fail. The block stays byte-identical with a named warning, + /// and since that copy installs the git bytes, the uuid is never + /// assumed applied by the in-run VEX. + #[test] + fn yarn_classic_git_pattern_block_is_skipped() { + let git_block = "\"left-pad@git+https://github.com/stevemao/left-pad.git#v1.3.0\":\n \ + version \"1.3.0\"\n \ + resolved \"git+https://github.com/stevemao/left-pad.git#ff8e7ba5b0b3a5ad2f1bb06a4e6aef1c6b2c3d4e\"\n"; + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + + // Git block only: nothing to rewire, a specific warning (not the + // generic not-found). + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + format!("# yarn lockfile v1\n\n\n{git_block}"), + ); + 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(), "{:?}", r.files); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, ["redirect_yarn_classic_git_skipped"]); + assert!(r.warnings[0].detail.contains("git"), "{:?}", r.warnings); + + // Git block beside a registry block: the registry block is wired, + // the git block left alone, and the uuid flagged so in-run VEX + // verifies instead of assuming. + let registry_block = "left-pad@^1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#bbbb\"\n \ + integrity sha512-UPSTREAMupstream==\n"; + files.insert( + "yarn.lock".to_string(), + format!("# yarn lockfile v1\n\n\n{registry_block}\n{git_block}"), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + let out = &r.files["yarn.lock"]; + assert!(out.contains("resolved \"http://p.test/lp.tgz\""), "{out}"); + assert!(out.contains(git_block), "git block byte-identical:\n{out}"); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_yarn_classic_git_skipped")); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + + // An `npm:` alias of a git range is fetched with git too: beside a + // rewired registry block it still keeps the uuid out of the in-run + // VEX assumption. + files.insert( + "yarn.lock".to_string(), + format!( + "# yarn lockfile v1\n\n\n{registry_block}\n\ + \"safe-pad@npm:left-pad@git+https://github.com/stevemao/left-pad.git#v1.3.0\":\n \ + version \"1.3.0\"\n \ + resolved \"git+https://github.com/stevemao/left-pad.git#ff8e7ba\"\n" + ), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert!(r + .warnings + .iter() + .any(|w| w.code == "redirect_yarn_classic_git_skipped")); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + + // The codeload shorthand is a tarball to yarn: still rewired. + files.insert( + "yarn.lock".to_string(), + "# yarn lockfile v1\n\n\nleft-pad@stevemao/left-pad#v1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba\"\n" + .to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + } + /// The opposite alias direction — `"alias@npm:@…"` consuming the /// patched package under another name — is skipped with a SPECIFIC /// warning (not silence, not a misleading not-found). diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index fe1938991..0fdbec124 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -282,6 +282,8 @@ async fn restore_classic( ctx: &Ctx<'_>, result: &mut FormatResult, ) { + use crate::vendor::yarn_classic_lock::{classic_block_is_git, split_key_patterns}; + let eol = LineEndings::of(raw); if eol == LineEndings::Mixed { refuse_all_in(pins, rel, result, format!("{rel} mixes line endings")); @@ -308,7 +310,25 @@ async fn restore_classic( if !pins.contains_key(uuid.as_str()) { continue; } - let name = super::super::yarn_classic_block_head(block).and_then(|(_, n)| n); + let head = super::super::yarn_classic_block_head(block); + // yarn 1 fetches a git pattern with git, from `resolved` (#363): a + // registry tarball there fails every install just as the hosted one + // does, and the block's own git source was never recorded. + let patterns = head + .as_ref() + .map(|(key, _)| split_key_patterns(key)) + .unwrap_or_default(); + if classic_block_is_git(&patterns, None) { + result.refuse( + &uuid, + format!( + "the {rel} entry wiring it installs from git; a registry tarball there \ + would still be fetched with git" + ), + ); + continue; + } + let name = head.and_then(|(_, n)| n); let version = version_re.captures(block).map(|c| c[1].to_string()); match (name, version) { (Some(name), Some(version)) => hits.push((i, uuid, name, version)), diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 70602238b..758f55ac1 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -99,6 +99,12 @@ pub async fn vendor_yarn_classic<'a>( BlockClass::LinkSkip(detail) => { warnings.push(VendorWarning::new("vendor_link_entry_skipped", detail)); } + BlockClass::GitSkip(detail) => { + warnings.push(VendorWarning::new( + "vendor_yarn_classic_git_entry_skipped", + detail, + )); + } BlockClass::NoMatch => {} } } @@ -656,6 +662,9 @@ enum BlockClass { Candidate, /// Matches the target but cannot be rewired; carries the warning detail. LinkSkip(String), + /// Matches the target but yarn fetches it with git (#363); carries the + /// warning detail. + GitSkip(String), NoMatch, } @@ -693,7 +702,17 @@ fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> Block } } } - if classic_field(&block.lines, "resolved").is_none() { + let resolved = classic_field(&block.lines, "resolved"); + // yarn fetches a git pattern with git, from `resolved` (#363): a vendored + // tarball there makes every install fail, and the copy is the git bytes. + if classic_block_is_git(&patterns, resolved) { + return BlockClass::GitSkip(format!( + "lock block `{}` installs from git, which yarn fetches from the git \ + source rather than a tarball; skipped, so that copy stays unpatched", + block.key + )); + } + if resolved.is_none() { return BlockClass::LinkSkip(format!( "lock block `{}` has no resolved tarball; skipped", block.key @@ -1039,6 +1058,72 @@ pub(crate) fn pattern_real_name(pattern: &str) -> Option<&str> { Some(name) } +/// Whether yarn 1 fetches a lock block with its GIT fetcher (#363): when any +/// key pattern's range (an `npm:` alias's target range included) is one +/// yarn's `GitResolver.isVersion` accepts, or the block's `resolved` is +/// itself a git remote. Yarn picks the fetcher from the PATTERN and hands it +/// the `resolved` value as a git remote, so rewriting that `resolved` to a +/// tarball breaks every later install (`git ls-remote` on a `.tgz`). The +/// hosted-git shorthands (`owner/repo`, `github:owner/repo`) are not git +/// here: yarn locks them to a codeload tarball and fetches that as one. +pub(crate) fn classic_block_is_git(patterns: &[String], resolved: Option<&str>) -> bool { + patterns.iter().any(|p| { + split_pattern(p).is_some_and(|(_, range)| { + let range = match range.strip_prefix("npm:") { + Some(aliased) => split_pattern(aliased).map_or("", |(_, r)| r), + None => range, + }; + yarn_classic_range_is_git(range) + }) + }) || resolved.is_some_and(yarn_classic_range_is_git) +} + +/// yarn 1's `GitResolver.isVersion` over node's legacy `url.parse`: a url +/// with a scheme whose path ends in `.git`, a `git+:` / `git:` / `ssh:` +/// scheme, or a `github.com` / `gitlab.com` / `bitbucket.{com,org}` url +/// naming exactly `/` (not a file inside the repo, such as an +/// `/archive/v1.tar.gz`). +pub(crate) fn yarn_classic_range_is_git(range: &str) -> bool { + let range = range.trim(); + let scheme_len = range + .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-'))) + .unwrap_or(range.len()); + if scheme_len == 0 || !range[scheme_len..].starts_with(':') { + return false; + } + let scheme = range[..scheme_len].to_ascii_lowercase(); + let rest = &range[scheme_len + 1..]; + let rest = rest.split('#').next().unwrap_or(rest); + let (host, path) = match rest.strip_prefix("//") { + Some(after) => { + let end = after.find(['/', '?']).unwrap_or(after.len()); + let authority = &after[..end]; + let host = authority.rsplit('@').next().unwrap_or(authority); + let host = host.split(':').next().unwrap_or(host).to_ascii_lowercase(); + (Some(host), &after[end..]) + } + None => (None, rest), + }; + let pathname = path.split('?').next().unwrap_or(path); + if pathname.ends_with(".git") { + return true; + } + if (scheme.starts_with("git+") && scheme.len() > 4) || scheme == "git" || scheme == "ssh" { + return true; + } + match host { + Some(host) + if matches!( + host.as_str(), + "github.com" | "gitlab.com" | "bitbucket.com" | "bitbucket.org" + ) => + { + path.split('/').filter(|s| !s.is_empty()).count() == 2 + } + _ => false, + } +} + /// Which blocks yarn actually keeps, by block index: a block survives while /// at least one of its key patterns is not re-keyed by a LATER block (yarn /// parses the lock into an object, so duplicate keys are last-wins). A @@ -3012,4 +3097,128 @@ left-pad@^1.3.0: assert_eq!(looped, Err("vendor_lockfile_missing")); assert_eq!(planned, looped); } + + /// yarn 1's `GitResolver.isVersion`, case by case (#363). + #[test] + fn yarn_classic_git_ranges_are_recognized() { + for range in [ + "git+https://github.com/stevemao/left-pad.git#v1.3.0", + "git+ssh://git@github.com/stevemao/left-pad.git#ff8e7ba", + "git+file:///tmp/lpgit#v1.3.0", + "git://github.com/stevemao/left-pad.git", + "ssh://git@example.com/left-pad", + "https://example.com/left-pad.git", + "https://example.com/left-pad.git#v1.3.0", + "https://github.com/stevemao/left-pad", + "https://github.com/stevemao/left-pad#v1.3.0", + "https://gitlab.com/stevemao/left-pad/", + "http://bitbucket.org/stevemao/left-pad", + "GIT+HTTPS://github.com/stevemao/left-pad.git", + ] { + assert!(yarn_classic_range_is_git(range), "{range:?} is a git range"); + } + for range in [ + "^1.3.0", + "1.3.0", + "latest", + "stevemao/left-pad#v1.3.0", + "github:stevemao/left-pad#v1.3.0", + "https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba", + "https://github.com/stevemao/left-pad/archive/v1.3.0.tar.gz", + "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7", + "file:./old/left-pad-1.3.0.tgz", + "file:./.socket/vendor/npm/x/left-pad-1.3.0.tgz", + "link:../left-pad", + "", + ] { + assert!( + !yarn_classic_range_is_git(range), + "{range:?} is not a git range" + ); + } + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + assert!(classic_block_is_git( + &pats(&["left-pad@git+https://h/x.git#v1"]), + Some("https://p.test/lp.tgz") + )); + assert!(classic_block_is_git( + &pats(&["pad@npm:left-pad@git+https://h/x.git"]), + None + )); + assert!( + classic_block_is_git(&pats(&["left-pad@^1.3.0"]), Some("git+ssh://h/x.git#abc")), + "a git `resolved` alone decides it too" + ); + assert!(!classic_block_is_git( + &pats(&["left-pad@stevemao/left-pad#v1.3.0"]), + Some("https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba") + )); + } + + /// #363: a git-pattern block is fetched by yarn 1's git fetcher from its + /// `resolved`, so vendoring must never rewrite it — the registry block + /// beside it is still wired, and the skip is named, not silent. + #[tokio::test] + async fn git_pattern_block_is_skipped_with_warning() { + let extra = r#" +"left-pad@git+https://github.com/stevemao/left-pad.git#v1.3.0": + version "1.3.0" + resolved "git+https://github.com/stevemao/left-pad.git#ff8e7ba5b0b3a5ad2f1bb06a4e6aef1c6b2c3d4e" +"#; + let lock = format!("{Y2_BEFORE}{extra}"); + let fx = fixture_with_lock(&lock).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!( + entry.unwrap().wiring.len(), + 1, + "only the registry block rewritten" + ); + assert_eq!( + warnings + .iter() + .filter(|w| w.code == "vendor_yarn_classic_git_entry_skipped") + .count(), + 1, + "{warnings:?}" + ); + let text = fx.lock_text().await; + assert!( + text.contains(extra.trim_start()), + "git block byte-untouched:\n{text}" + ); + } + + /// #363: a lock whose ONLY copy is git-sourced has nothing vendoring can + /// wire — refused before any write, the lock untouched. + #[tokio::test] + async fn git_only_lock_is_refused_untouched() { + let lock = r#"# yarn lockfile v1 + + +"left-pad@git+file:///tmp/lpgit#v1.3.0": + version "1.3.0" + resolved "git+file:///tmp/lpgit#a380ff32159b9beb078ec6ce294cf6fbdad19c55" +"#; + let fx = fixture_with_lock(lock).await; + expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_found"); + assert_eq!(fx.lock_text().await, lock); + } + + /// #363 scope note: the hosted-git SHORTHAND locks to a codeload tarball + /// that yarn fetches as a tarball, so it stays rewritable. + #[tokio::test] + async fn codeload_shorthand_block_is_still_rewritten() { + let lock = r#"# yarn lockfile v1 + + +left-pad@stevemao/left-pad#v1.3.0: + version "1.3.0" + resolved "https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba5b0b3a5ad2f1bb06a4e6aef1c6b2c3d4e" +"#; + let fx = fixture_with_lock(lock).await; + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert_eq!(entry.unwrap().wiring.len(), 1, "{warnings:?}"); + } } diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index ea8fcef83..b6763b88f 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -87,7 +87,7 @@ use serde_json::Value; use super::{ npm_purl, npm_vendored_tarball_names, parse_json, root_anchored_spelling, vendor_ref_decorated, DiscoverCtx, Discovery, LocateOpts, PatchedRef, VendorRef, Wired, DIAG_LOCKFILE_UNPARSEABLE, - DIAG_REF_INVALID, + DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::patch::redirect::is_berry_lock; use crate::utils::digest::is_sri_pin; @@ -97,7 +97,7 @@ use crate::vendor::lock_inventory::yarn::{ use crate::vendor::lock_inventory::LockIntegrity; use crate::vendor::yarn_berry_lock::{berry_field, resolution_selector_target, BerryLocator}; use crate::vendor::yarn_classic_lock::{ - classic_field, pattern_real_name, split_pattern, split_resolved_sha1, + classic_block_is_git, classic_field, pattern_real_name, split_pattern, split_resolved_sha1, }; const YARN_LOCK: &str = "yarn.lock"; @@ -138,18 +138,82 @@ fn stray_top_level_line(text: &str) -> Option<&str> { // ── classic ────────────────────────────────────────────────────────────── fn extract_classic(ctx: &DiscoverCtx<'_>, entries: Vec, out: &mut Discovery) { + // (purl, key) of every live git-fetched block: that copy installs the + // git bytes, so no wiring of the same package in this lock is attested. + let mut git_copies: Vec<(String, String)> = Vec::new(); for entry in entries { if entry.live && !entry.patterns.is_empty() { - classic_block(ctx, &entry, out); + classic_block(ctx, &entry, &mut git_copies, out); + } + } + if git_copies.is_empty() { + return; + } + let refs = std::mem::take(&mut out.refs); + for r in refs { + let git_key = (r.source_file == std::path::Path::new(YARN_LOCK)) + .then(|| git_copies.iter().find(|(purl, _)| *purl == r.purl)) + .flatten(); + match git_key { + Some((_, key)) => out.diag( + DIAG_REF_UNATTRIBUTABLE, + YARN_LOCK, + format!( + "{YARN_LOCK}: {} is wired to a Socket patch but lock entry `{key}` \ + installs from git, which yarn fetches from the git source rather than \ + a tarball; that copy stays UNPATCHED and nothing is attested", + r.purl + ), + ), + None => out.refs.push(r), } } } -fn classic_block(ctx: &DiscoverCtx<'_>, entry: &YarnEntry, out: &mut Discovery) { +fn classic_block( + ctx: &DiscoverCtx<'_>, + entry: &YarnEntry, + git_copies: &mut Vec<(String, String)>, + out: &mut Discovery, +) { let YarnEntry { block, patterns, .. } = entry; - let Some(resolved) = classic_field(&block.lines, "resolved") else { + let resolved = classic_field(&block.lines, "resolved"); + // yarn 1 fetches a git pattern with git, from `resolved` (#363): the + // copy is the git bytes, whatever `resolved` names. + if classic_block_is_git(patterns, resolved) { + let purl = match ( + patterns.first().and_then(|p| pattern_real_name(p)), + classic_field(&block.lines, "version"), + ) { + (Some(name), Some(version)) + if patterns.iter().all(|p| pattern_real_name(p) == Some(name)) => + { + npm_purl(name, version) + } + _ => None, + }; + // A Socket wiring here (an older release rewired it) is inert. + if resolved.is_some_and(|r| classify(ctx, r, YARN_LOCK, &block.key, out).is_some()) { + out.diag( + DIAG_REF_UNATTRIBUTABLE, + YARN_LOCK, + format!( + "{YARN_LOCK}: Socket-wired entry `{}` installs from git, which yarn \ + fetches from the git source rather than the wired tarball; it is not \ + attested", + block.key + ), + ); + } + if let Some(purl) = purl { + out.resolved_elsewhere(YARN_LOCK, Some(purl.clone())); + git_copies.push((purl, block.key.clone())); + } + return; + } + let Some(resolved) = resolved else { return; }; // `link:` ranges install from the working tree; `resolved` is inert. @@ -928,6 +992,45 @@ mod tests { assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); } + /// #363: yarn 1 fetches a git-pattern block with git from its + /// `resolved`, so that copy is the git bytes whatever `resolved` says. + /// A Socket wiring of such a block (left by an older release) is never + /// attested, and a git copy beside a wired registry block leaves the + /// package unpatched there too: no ref, a named diagnostic, and the copy + /// counts as resolved elsewhere for other locks. + #[tokio::test] + async fn classic_git_pattern_copies_are_never_attested() { + let lp = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let git_key = "\"left-pad@git+https://github.com/stevemao/left-pad.git#v1.3.0\""; + let git_resolved = + "git+https://github.com/stevemao/left-pad.git#ff8e7ba5b0b3a5ad2f1bb06a4e6aef1c6b2c3d4e"; + for (case, blocks) in [ + ( + "git block wired", + vec![classic_block(git_key, "1.3.0", &lp, Some(SRI))], + ), + ( + "registry wired beside a git copy", + vec![ + classic_block("left-pad@^1.3.0", "1.3.0", &lp, Some(SRI)), + classic_block(git_key, "1.3.0", git_resolved, None), + ], + ), + ] { + let p = Project::new(); + p.write("yarn.lock", classic(&blocks)); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{case}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE && d.detail.contains("git")), + "{case}: {:?}", + out.diagnostics + ); + } + } + /// Socket-shaped blocks that fail validation are DIAGNOSED, never refs: /// no version, mixed-package key, unsafe name, a leaf naming another /// package, a traversal leaf, a non-canonical vendored uuid. diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 231d221ba..714b33336 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -2012,3 +2012,37 @@ async fn nuget_non_invertible_goldens_restore_or_refuse_as_documented() { } } } + +/// #363: yarn 1 fetches a git-pattern block with git from its `resolved`, +/// so restoring a registry tarball there would still fail every install. +/// Handed such a pin directly (discovery already refuses to attribute it), +/// the restorer refuses it and leaves the lock byte-identical, with no +/// registry lookup. +#[tokio::test] +#[serial] +async fn yarn_classic_git_pattern_pin_is_refused() { + let uuid = "55555555-5555-4555-8555-555555555555"; + let lock = format!( + "# yarn lockfile v1\n\n\n\"left-pad@git+https://github.com/stevemao/left-pad.git#v1.3.0\":\n \ + version \"1.3.0\"\n \ + resolved \"https://patch.socket.dev/patch/npm/left-pad/1.3.0/66666666-6666-4666-8666-666666666666/{uuid}/left-pad-1.3.0.tgz\"\n \ + integrity sha512-PATCHEDpatched==\n" + ); + let tmp = tempfile::tempdir().unwrap(); + fs::write(tmp.path().join("yarn.lock"), &lock).unwrap(); + let pin = HostedPin { + purl: "pkg:npm/left-pad@1.3.0".into(), + uuid: uuid.into(), + files: vec!["yarn.lock".into()], + }; + let outcome = restore_upstream(tmp.path(), &[pin], &RestoreOptions::default()).await; + assert!(outcome.flush_error.is_none(), "{:?}", outcome.flush_error); + match &outcome.pins[..] { + [p] => match &p.status { + PinStatus::Refused(why) => assert!(why.contains("installs from git"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + }, + other => panic!("one pin expected: {other:?}"), + } + assert_eq!(fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), lock); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1beb7b91d..f16711c91 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -95,6 +95,15 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. unpatched artifact. The reverse shape — an alias of the patched NAME pointing at a different package (`"left-pad@npm:some-fork@^1.3.0"`, the fork-substitution idiom) — is never rewritten: it resolves a different package. +- **yarn classic git dependencies** — yarn 1 fetches a git pattern (`git+https:`, + `git+ssh:`, `git:`, `ssh:`, a `….git` url, or a bare `https://github.com//`) + with git, using the lock entry's `resolved` as the remote, so a rewritten `resolved` + breaks every install. Hosted and vendored modes leave such an entry untouched + (`redirect_yarn_classic_git_skipped` / `vendor_yarn_classic_git_entry_skipped`) and + that copy stays unpatched; `vex` never attests the package from that lock while the + git copy is there, and rollback refuses a hosted pin an older release wrote on one. + The hosted-git shorthands (`owner/repo`, `github:owner/repo`) lock to a codeload + tarball and are rewired normally. - **bun** — text `bun.lock` lockfileVersion 0, 1 or 2: 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so registry entries rewrite identically. Any other or