diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d302aa48..c1e56b41 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -756,6 +756,77 @@ async fn scan_redirect_rewrites_yarn_berry_lock() { ); } +/// Classic (v1) yarn.lock with CRLF line endings (Windows `core.autocrlf` +/// checkout): the full hosted chain must repoint the TARGET entry — not +/// whichever entry sorts first — and keep every untouched line CRLF +/// byte-identical. Regression: `split("\n\n")` never split a CRLF lock, so +/// the whole file was one block and the leftmost `resolved`/`integrity` (the +/// decoy's) were rewritten, then confirmed and ledgered as the target's. +#[tokio::test] +#[serial] +async fn scan_redirect_rewrites_correct_entry_in_crlf_classic_lock() { + let server = MockServer::start().await; + mock_discovery(&server).await; + mock_reference(&server).await; + + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join("package.json"), + format!( + r#"{{ "name": "consumer", "version": "0.0.0", "dependencies": {{ "{NAME}": "{VERSION}" }} }}"# + ), + ) + .unwrap(); + let pkg = tmp.path().join("node_modules").join(NAME); + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::write( + pkg.join("package.json"), + format!(r#"{{ "name": "{NAME}", "version": "{VERSION}" }}"#), + ) + .unwrap(); + let decoy_resolved = "https://registry.yarnpkg.com/aaa-decoy/-/aaa-decoy-1.0.0.tgz#aaaa"; + let lock_lf = format!( + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + aaa-decoy@^1.0.0:\n version \"1.0.0\"\n resolved \"{decoy_resolved}\"\n \ + integrity sha512-DECOYdecoy==\n\n\ + {NAME}@{VERSION}:\n version \"{VERSION}\"\n \ + resolved \"https://registry.yarnpkg.com/{NAME}/-/{NAME}-{VERSION}.tgz#bbbb\"\n \ + integrity sha512-UPSTREAMupstream==\n" + ); + std::fs::write(tmp.path().join("yarn.lock"), lock_lf.replace('\n', "\r\n")).unwrap(); + + let code = run(redirect_args(tmp.path(), server.uri())).await; + assert_eq!(code, 0, "scan --redirect (classic CRLF) should succeed"); + + let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(); + assert!( + lock.contains(&format!("resolved \"{decoy_resolved}\"\r\n")) + && lock.contains("integrity sha512-DECOYdecoy==\r\n"), + "the decoy entry must stay byte-identical: {lock}" + ); + assert!( + lock.contains(&format!("resolved \"{HOSTED_URL}\"\r\n")) + && lock.contains(&format!("integrity {PATCHED_SHA512}\r\n")), + "the target entry must pin the hosted patch: {lock}" + ); + assert!( + !lock.contains("integrity sha512-UPSTREAMupstream=="), + "the target's upstream integrity must be gone: {lock}" + ); + assert_eq!( + lock.matches('\n').count(), + lock.matches("\r\n").count(), + "every line must keep its CRLF ending: {lock}" + ); + assert!( + tmp.path() + .join(".socket/vendor/redirect-state.json") + .is_file(), + "a redirect ledger should be written" + ); +} + /// Write a project whose only lockfile is a text `bun.lock` (registry 4-tuple). fn write_bun_project(root: &Path) { std::fs::write( diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 0ff40eb5..b3fdea56 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -755,14 +755,40 @@ fn rewrite_yarn_classic( overrides: &[DepOverride], result: &mut RewriteResult, ) { + use crate::vendor::yarn_classic_lock::{pattern_real_name, split_key_patterns, split_pattern}; + let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); if npm.is_empty() || !files.contains_key("yarn.lock") { return; } - let content = &files["yarn.lock"]; - if Regex::new(r"(?m)^__metadata:").unwrap().is_match(content) { + let raw = &files["yarn.lock"]; + if Regex::new(r"(?m)^__metadata:").unwrap().is_match(raw) { return; // yarn-berry — not classic } + // 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 + // splits a CRLF file: the whole lock becomes ONE block and the + // leftmost-match replaces below would rewrite the FIRST entry in the + // file, not the target's. Bare `\r`s outside a CRLF pair make the + // round-trip lossy, so such a lock is refused untouched. + let crlf = raw.contains('\r'); + let normalized: String; + let content: &str = if crlf { + normalized = raw.replace("\r\n", "\n"); + if normalized.contains('\r') { + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_unsupported_line_endings".into(), + detail: "yarn.lock contains bare carriage returns (mixed line endings); \ + leaving it untouched" + .into(), + }); + return; + } + &normalized + } else { + raw + }; let mut blocks: Vec = content.split("\n\n").map(String::from).collect(); let resolved_re = Regex::new(r#"\n {2}resolved "[^"]*""#).unwrap(); let integrity_re = Regex::new(r"\n {2}integrity [^\n]*").unwrap(); @@ -776,15 +802,59 @@ fn rewrite_yarn_classic( }); continue; }; - let header_re = - Regex::new(&(String::from(r#"(?m)^ *"?"#) + ®ex::escape(&fname) + "@")).unwrap(); let version_re = Regex::new(&(String::from(r#"\n {2}version ""#) + ®ex::escape(&dep.version) + "\"")) .unwrap(); + let mut matched_any = false; + let mut alias_skipped = false; for block in blocks.iter_mut() { - if !header_re.is_match(block) || !version_re.is_match(block) { + // The block's key line names its consumers; resolve every + // comma-joined pattern to the REAL package it stands for + // (`alias@npm:target@range` → target). A key like + // `@npm:@…` — yarn v1's fork-substitution + // idiom — resolves to , so it is NOT ours to touch: + // matching on the alias name alone would hijack the fork. + let Some(key_line) = block + .lines() + .find(|l| !l.is_empty() && !l.starts_with([' ', '\t', '#'])) + else { + continue; + }; + let Some(key) = key_line.strip_suffix(':') else { + continue; + }; + let patterns = split_key_patterns(key); + if patterns.is_empty() + || !patterns + .iter() + .all(|p| pattern_real_name(p) == Some(fname.as_str())) + { + continue; + } + if !version_re.is_match(block) { + 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 + // unpatched artifact. + if !patterns + .iter() + .any(|p| split_pattern(p).is_some_and(|(n, _)| n == fname)) + { + alias_skipped = true; + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_alias_skipped".into(), + detail: format!( + "lock entry `{key}` consumes {fname}@{} only through npm: alias \ + descriptors; the hosted redirect does not rewrite alias entries, \ + so this copy stays unpatched", + dep.version + ), + }); continue; } + matched_any = true; let frag = dep .integrity .sha1 @@ -815,21 +885,38 @@ fn rewrite_yarn_classic( .to_string(); } if rewritten != *block { + // Ledger originals record the on-disk byte form, so a future + // revert of a CRLF lock can match what the file really held. + let (edit_original, edit_new) = if crlf { + (block.replace('\n', "\r\n"), rewritten.replace('\n', "\r\n")) + } else { + (block.clone(), rewritten.clone()) + }; result.edits.push(FileEdit { path: "yarn.lock".into(), kind: "redirect_yarn_classic_entry".into(), action: "rewritten".into(), key: Some(format!("{fname}@{}", dep.version)), - original: Some(Value::String(block.clone())), - new: Some(Value::String(rewritten.clone())), + original: Some(Value::String(edit_original)), + new: Some(Value::String(edit_new)), }); *block = rewritten; changed = true; } } + if !matched_any && !alias_skipped { + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_entry_not_found".into(), + detail: format!("no yarn.lock entry resolving {fname}@{}", dep.version), + }); + } } if changed { - result.files.insert("yarn.lock".into(), blocks.join("\n\n")); + let mut out = blocks.join("\n\n"); + if crlf { + out = out.replace('\n', "\r\n"); + } + result.files.insert("yarn.lock".into(), out); } } @@ -960,6 +1047,7 @@ fn rewrite_yarn_berry( Regex::new(&(String::from(r"\n {2}version: ") + ®ex::escape(&dep.version) + "\n")) .unwrap(); let mut matched_any = false; + let mut alias_skipped = false; for block in blocks.iter_mut() { // A block's key is its first line up to a trailing colon; skip // header comment blocks and the leading `__metadata` block. @@ -983,6 +1071,31 @@ fn rewrite_yarn_berry( let names: std::collections::BTreeSet<&str> = parsed.iter().map(|p| p.unwrap().0).collect(); if !names.contains(fname.as_str()) { + // An `alias@npm:@range` descriptor resolves the + // patched package under a different ident. The redirect + // never rewrites those, but that must not be silent — this + // copy keeps installing the unpatched artifact, and the + // generic not-found warning would point at the wrong cause. + if version_re.is_match(block) + && parsed.iter().any(|p| { + p.unwrap() + .1 + .strip_prefix("npm:") + .and_then(split_berry_descriptor) + .is_some_and(|(real, _)| real == fname) + }) + { + alias_skipped = true; + result.warnings.push(RewriteWarning { + code: "redirect_yarn_berry_alias_skipped".into(), + detail: format!( + "lock entry `{raw_key}` consumes {fname}@{} only through an \ + npm: alias descriptor; the hosted redirect does not rewrite \ + alias entries, so this copy stays unpatched", + dep.version + ), + }); + } continue; } if names.len() > 1 { @@ -1054,7 +1167,7 @@ fn rewrite_yarn_berry( changed = true; } } - if !matched_any { + if !matched_any && !alias_skipped { result.warnings.push(RewriteWarning { code: "redirect_yarn_berry_entry_not_found".into(), detail: format!("no npm: lock entry resolving {fname}@{}", dep.version), @@ -3273,6 +3386,269 @@ mod tests { .any(|w| w.code == "redirect_yarn_berry_ambiguous_entry")); } + /// Two-entry classic lock: a decoy entry FIRST, the target second — the + /// shape that exposed the CRLF wrong-entry rewrite. + fn classic_lock_two_entries() -> String { + "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.\n\ + # yarn lockfile v1\n\n\n\ + abbrev@^1.0.0:\n version \"1.1.1\"\n \ + resolved \"https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#aaaa\"\n \ + integrity sha512-DECOYdecoy==\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#bbbb\"\n \ + integrity sha512-UPSTREAMupstream==\n" + .to_string() + } + + /// A CRLF classic lock (Windows `core.autocrlf` checkout) must rewrite + /// the TARGET entry, not whichever entry happens to come first, and every + /// untouched line must keep its CRLF ending byte-exactly. Regression: + /// `split("\n\n")` never matched in a CRLF file, so the whole lock was + /// one block and the leftmost `resolved`/`integrity` — the decoy's — + /// were rewritten (then confirmed and attested downstream). + #[test] + fn yarn_classic_crlf_lock_rewrites_only_the_target_entry() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + classic_lock_two_entries().replace('\n', "\r\n"), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "clean rewrite: {:?}", r.warnings); + let out = r + .files + .get("yarn.lock") + .expect("yarn.lock must be rewritten"); + assert!( + out.contains( + "abbrev@^1.0.0:\r\n version \"1.1.1\"\r\n \ + resolved \"https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#aaaa\"\r\n \ + integrity sha512-DECOYdecoy==\r\n" + ), + "the decoy entry must stay byte-identical: {out}" + ); + assert!( + out.contains( + "left-pad@^1.3.0:\r\n version \"1.3.0\"\r\n \ + resolved \"http://p.test/lp.tgz\"\r\n integrity sha512-PATCHED==\r\n" + ), + "the target entry must pin the hosted artifact: {out}" + ); + assert_eq!( + out.matches('\n').count(), + out.matches("\r\n").count(), + "every line must keep its CRLF ending: {out}" + ); + + // The CRLF output is exactly the LF rewrite re-expanded. + let mut lf_files = BTreeMap::new(); + lf_files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + let mut lf_r = RewriteResult::default(); + rewrite_yarn_classic(&lf_files, std::slice::from_ref(&ovr), &mut lf_r); + assert_eq!( + out, + &lf_r.files["yarn.lock"].replace('\n', "\r\n"), + "CRLF rewrite must equal the LF rewrite modulo line endings" + ); + + // Ledger originals carry the on-disk (CRLF) byte form for revert. + assert_eq!(r.edits.len(), 1); + let original = r.edits[0].original.as_ref().unwrap().as_str().unwrap(); + assert!( + original.contains("\r\n") && original.contains("left-pad@^1.3.0:"), + "edit original must record the CRLF bytes: {original:?}" + ); + } + + /// Bare carriage returns outside a CRLF pair make the normalize/expand + /// round-trip lossy — the lock is refused untouched with a warning. + #[test] + fn yarn_classic_mixed_line_endings_are_refused() { + let mixed = + classic_lock_two_entries() + .replace('\n', "\r\n") + .replacen("UPSTREAM", "UP\rSTREAM", 1); + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), mixed); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + 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(), + "mixed-EOL lock must stay untouched: {:?}", + r.files + ); + assert_eq!( + r.warnings[0].code, + "redirect_yarn_classic_unsupported_line_endings" + ); + } + + /// `"@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; + /// the dep surfaces as not-found instead. + #[test] + fn yarn_classic_fork_alias_block_is_not_hijacked() { + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + "# yarn lockfile v1\n\n\n\ + \"left-pad@npm:totally-other@^1.3.0\":\n version \"1.3.0\"\n \ + resolved \"https://registry.yarnpkg.com/totally-other/-/totally-other-1.3.0.tgz#cccc\"\n \ + integrity sha512-FORKfork==\n" + .to_string(), + ); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + 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(), + "the fork block must stay byte-identical: {:?}", + r.files + ); + assert_eq!(r.warnings[0].code, "redirect_yarn_classic_entry_not_found"); + } + + /// 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). + #[test] + fn yarn_classic_alias_only_consumer_warns_specifically() { + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + "# yarn lockfile v1\n\n\n\ + \"safe-pad@npm: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" + .to_string(), + ); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + 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()); + assert_eq!(r.warnings[0].code, "redirect_yarn_classic_alias_skipped"); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_yarn_classic_entry_not_found"), + "the alias warning replaces the generic not-found: {:?}", + r.warnings + ); + } + + /// A merged key serving BOTH a direct and an alias descriptor of the + /// patched package (yarn v1 merges patterns resolving identically) is + /// still rewritten — every pattern resolves to the patched package. + #[test] + fn yarn_classic_merged_direct_and_alias_key_is_rewritten() { + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + "# yarn lockfile v1\n\n\n\ + left-pad@^1.3.0, \"safe-pad@npm: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" + .to_string(), + ); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "no warnings: {:?}", r.warnings); + let out = r.files.get("yarn.lock").expect("must rewrite"); + assert!( + out.contains("resolved \"http://p.test/lp.tgz\"") + && out.contains("left-pad@^1.3.0, \"safe-pad@npm:left-pad@^1.3.0\":"), + "merged key preserved, resolution repointed: {out}" + ); + } + + /// A granted dep with no matching lock entry (version drift, not + /// installed) must warn instead of vanishing silently — every sibling + /// npm-family rewriter already surfaces this. + #[test] + fn yarn_classic_entry_not_found_warns() { + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + "# yarn lockfile v1\n\n\n\ + left-pad@^1.2.0:\n version \"1.2.0\"\n \ + resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.0.tgz#dddd\"\n \ + integrity sha512-OLDold==\n" + .to_string(), + ); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + 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()); + assert_eq!(r.warnings[0].code, "redirect_yarn_classic_entry_not_found"); + } + + /// Berry flavor of the alias hole: the lock key's descriptor ident is the + /// alias, but the entry plainly resolves the patched package — the skip + /// must name the alias cause, not claim the entry is missing. + #[test] + fn yarn_berry_alias_only_consumer_warns_specifically() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let ovr = berry_override("left-pad", "1.3.0", "http://p.test/lp.tgz", &checksum); + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + format!( + "# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"safe-pad@npm:left-pad@^1.3.0\":\n version: 1.3.0\n \ + resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n \ + languageName: node\n linkType: hard\n", + "3".repeat(128) + ), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty() && r.edits.is_empty()); + assert_eq!(r.warnings[0].code, "redirect_yarn_berry_alias_skipped"); + assert!( + !r.warnings + .iter() + .any(|w| w.code == "redirect_yarn_berry_entry_not_found"), + "the alias warning replaces the generic not-found: {:?}", + r.warnings + ); + } + fn bun_lock_file(entry: &str, version: u64) -> String { format!( "{{\n \"lockfileVersion\": {version},\n \"packages\": {{\n {entry}\n }}\n}}\n" diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 74b382b8..f6076c1e 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -76,7 +76,7 @@ pub(crate) mod service_fetch; mod toml_surgery; pub(crate) mod verify; pub(crate) mod yarn_berry_lock; -mod yarn_classic_lock; +pub(crate) mod yarn_classic_lock; #[cfg(test)] mod yarn_layering_tests; 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 bad8d269..08dac67f 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -679,7 +679,7 @@ pub(super) fn classic_field<'a>(lines: &'a [String], field: &str) -> Option<&'a /// Split a comma-joined key into its patterns, honoring quoting; the /// surrounding quotes are dropped from each pattern. -pub(super) fn split_key_patterns(key: &str) -> Vec { +pub(crate) fn split_key_patterns(key: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); let mut in_quotes = false; @@ -704,7 +704,7 @@ pub(super) fn split_key_patterns(key: &str) -> Vec { } /// Split `name@range` at the first `@` past a leading `@scope/` marker. -pub(super) fn split_pattern(pattern: &str) -> Option<(&str, &str)> { +pub(crate) fn split_pattern(pattern: &str) -> Option<(&str, &str)> { let from = usize::from(pattern.starts_with('@')); let at = pattern[from..].find('@')? + from; let (name, range) = (&pattern[..at], &pattern[at + 1..]); @@ -716,7 +716,7 @@ pub(super) fn split_pattern(pattern: &str) -> Option<(&str, &str)> { /// The real package a key pattern stands for: its name, unless the range is /// an `npm:` alias — then the aliased target's name. -pub(super) fn pattern_real_name(pattern: &str) -> Option<&str> { +pub(crate) fn pattern_real_name(pattern: &str) -> Option<&str> { let (name, range) = split_pattern(pattern)?; if let Some(aliased) = range.strip_prefix("npm:") { return match split_pattern(aliased) { diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/expected-edits.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/expected-edits.json @@ -0,0 +1 @@ +[] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/input/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/input/yarn.lock new file mode 100644 index 00000000..0e7cf891 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/input/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"left-pad@npm:totally-other@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/totally-other/-/totally-other-1.3.0.tgz#aaaabbbbccccddddeeeeffff0000111122223333" + integrity sha512-FORKforkFORKfork== + +"safe-pad@npm:left-pad@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e" + integrity sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXXupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345== diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/overrides.json new file mode 100644 index 00000000..5bb66cec --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/alias-guard/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "npm", + "name": "left-pad", + "version": "1.3.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "22222222-2222-2222-2222-222222222222", + "artifactUrl": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": { + "sha512": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==", + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected-edits.json new file mode 100644 index 00000000..17f48034 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "yarn.lock", + "kind": "redirect_yarn_classic_entry", + "action": "rewritten", + "key": "left-pad@1.3.0", + "original": "left-pad@1.3.0:\r\n version \"1.3.0\"\r\n resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e\"\r\n integrity sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXXupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==\r\n", + "new": "left-pad@1.3.0:\r\n version \"1.3.0\"\r\n resolved \"https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz#abcdef0123456789abcdef0123456789abcdef01\"\r\n integrity sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==\r\n" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected/yarn.lock new file mode 100644 index 00000000..8f30f956 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/expected/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +abbrev@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +left-pad@1.3.0: + version "1.3.0" + resolved "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz#abcdef0123456789abcdef0123456789abcdef01" + integrity sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB== diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/input/yarn.lock b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/input/yarn.lock new file mode 100644 index 00000000..5b27bed5 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/input/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +abbrev@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +left-pad@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e" + integrity sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXXupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345== diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/overrides.json new file mode 100644 index 00000000..5bb66cec --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/yarn-classic/crlf/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "npm", + "name": "left-pad", + "version": "1.3.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "22222222-2222-2222-2222-222222222222", + "artifactUrl": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": { + "sha512": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==", + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1313c4eb..b4c3d4d1 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -39,6 +39,13 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. yarn 4), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker is e2e-covered; PnP is untested for hosted — the lock rewrite fires, but PnP's `.yarn/cache` resolution isn't exercised. +- **yarn `npm:` aliases (classic & berry)** — a lock entry that consumes the patched + package only through an alias descriptor (`"safe-pad@npm:left-pad@^1.3.0"`) is left + untouched, with a `redirect_yarn_classic_alias_skipped` / + `redirect_yarn_berry_alias_skipped` warning naming the entry — that copy keeps the + 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. - **bun** — text `bun.lock` v1 only. A binary `bun.lockb` with no text lock beside it is auto-migrated first: the CLI runs your installed `bun` (`bun install --save-text-lockfile --frozen-lockfile --lockfile-only`) before reading