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 75b5baeea..e7347eb01 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1159,6 +1159,49 @@ async fn a_git_pattern_hosted_pin_is_refused_not_restored_to_the_registry() { ); } +/// B16: an older release pinned a URL-keyed yarn-classic block (the +/// project's own fork tarball) to the hosted artifact. The fork's own +/// `resolved` was never recorded, and the registry tarball is not what +/// that copy installed, so the pin is refused and the block left as it is; +/// a registry pin beside it still restores. The old restore wrote the +/// registry tarball under the fork's key. +#[tokio::test] +#[serial] +async fn a_non_registry_keyed_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 fork_wired = format!( + "\"left-pad@https://host.test/fork/left-pad-1.2.3.tgz\":\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!("{fork_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])); + assert!( + envelope["hosted"]["failed"][0]["error"] + .as_str() + .is_some_and(|e| e.contains("non-registry source")), + "{envelope}" + ); + assert_eq!( + std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(), + yarn_lock_content(&format!( + "{fork_wired}\n\n{}", + yarn_upstream_block("is-odd", "3.0.1") + )), + "the fork 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/formats/yarn/berry_gates.rs b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs index 0f5ad9189..4fad514c0 100644 --- a/crates/socket-patch-core/src/formats/yarn/berry_gates.rs +++ b/crates/socket-patch-core/src/formats/yarn/berry_gates.rs @@ -12,6 +12,7 @@ //! same decision on them; the detail text lives here so it reads the same //! in either mode. +use super::blocks::{berry_field, berry_metadata, scan_blocks, LockBlock}; use crate::utils::line_endings::LineEndings; /// The lock file the gates read. @@ -140,10 +141,11 @@ pub fn check_line_endings(file: &'static str, text: &str) -> Result<(), BerryGat /// compression; only [`SUPPORTED_CACHE_KEY`] is reproducible offline, and a /// guessed `checksum:` bricks installs (YN0018). pub fn check_cache_key(lock: &str) -> Result<(), BerryGate> { - let Some(mut fields) = metadata_fields(lock) else { + let blocks = scan_blocks(lock); + let Some(metadata) = berry_metadata(&blocks) else { return Err(BerryGate::NoMetadata); }; - let found = fields.find_map(|line| scalar_field(line, "cacheKey")); + let found = berry_field(&metadata.lines, "cacheKey"); if found == Some(SUPPORTED_CACHE_KEY) { return Ok(()); } @@ -169,10 +171,12 @@ pub fn check_yarnrc(yarnrc: Yarnrc<'_>) -> Result<(), BerryGate> { } } -/// The lock's `cacheKey` (berry writes it unquoted: ` cacheKey: 10c0`), -/// `None` without a `__metadata` block or a `cacheKey` line in it. -pub fn cache_key(lock: &str) -> Option<&str> { - metadata_fields(lock)?.find_map(|line| scalar_field(line, "cacheKey")) +/// The `cacheKey` of a scanned lock (berry writes it unquoted: +/// ` cacheKey: 10c0`), `None` without a `__metadata` block or a +/// `cacheKey` field in it. Read through the shared block grammar +/// ([`berry_metadata`] + [`berry_field`]), like every other berry field. +pub(crate) fn cache_key(blocks: &[LockBlock]) -> Option<&str> { + berry_field(&berry_metadata(blocks)?.lines, "cacheKey") } /// The `.yarnrc.yml` `compressionLevel` value, when set. A flat line scan is @@ -209,27 +213,6 @@ pub fn yarnrc_scalar<'a>(rc: &'a str, key: &str) -> Option<&'a str> { }) } -/// The body lines of the lock's column-0 `__metadata:` block (CRLF and a -/// leading BOM tolerated), up to the next blank or column-0 line; `None` -/// when there is no such block. -fn metadata_fields(lock: &str) -> Option> { - let lock = lock.strip_prefix('\u{feff}').unwrap_or(lock); - let mut lines = lock.lines(); - lines.find(|line| line.trim_end() == "__metadata:")?; - Some(lines.take_while(|line| line.starts_with(' '))) -} - -/// A 2-space body field ` : ` (value possibly quoted). -/// Deeper sub-map lines are not body fields. -fn scalar_field<'a>(line: &'a str, field: &str) -> Option<&'a str> { - let rest = line.strip_prefix(" ")?; - if rest.starts_with(' ') { - return None; - } - let value = rest.strip_prefix(field)?.strip_prefix(':')?; - Some(value.trim().trim_matches('"')) -} - #[cfg(test)] mod tests { use super::*; @@ -253,7 +236,7 @@ mod tests { check(&lf, None, Yarnrc::Text("compressionLevel: 0 # default\n")), Ok(()) ); - assert_eq!(cache_key(&crlf), Some("10c0")); + assert_eq!(cache_key(&scan_blocks(&crlf)), Some("10c0")); } #[test] @@ -326,7 +309,7 @@ mod tests { fn a_sub_map_or_later_block_never_supplies_the_cache_key() { let text = "__metadata:\n version: 8\n nested:\n cacheKey: 10c0\n\n\ \"x@npm:1\":\n cacheKey: 10c0\n"; - assert_eq!(cache_key(text), None); + assert_eq!(cache_key(&scan_blocks(text)), None); } /// A `.yarnrc.yml` saved with a BOM (and CRLF) still has its first-line diff --git a/crates/socket-patch-core/src/formats/yarn/blocks.rs b/crates/socket-patch-core/src/formats/yarn/blocks.rs new file mode 100644 index 000000000..9529edcf4 --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/blocks.rs @@ -0,0 +1,328 @@ +//! The yarn.lock block grammar, classic (v1) and berry (v2+) alike: a key +//! line at column 0 ending in `:`, an indented body, blocks separated by +//! blank lines. Every reader and writer of the file — both vendored +//! backends, both hosted rewriters and restorers, the lock inventory and +//! lockfile discovery — walks the lock with [`scan_blocks`] and reads +//! fields with [`classic_field`] / [`berry_field`], so they cannot drift +//! apart on what a block, a key or a field is. + +use super::patterns::{berry_npm_alias_target, split_berry_key_patterns, split_pattern}; +use crate::vendor::common::detect_eol; + +/// One key-line block of a yarn lockfile (classic or berry). +pub(crate) struct LockBlock { + /// Byte offset of the key line's first byte. + pub start: usize, + /// Byte offset one past the last body line (incl. its terminator). + pub end: usize, + /// Whether the final line carried a terminator (false only at EOF). + pub terminated: bool, + /// Key line text without the trailing `:` (quotes kept verbatim). + pub key: String, + /// Verbatim block lines (key line first), without line terminators. + pub lines: Vec, +} + +/// Scan a lockfile into blocks, CRLF-aware. Comments, blank lines, and +/// anything else outside blocks are left to the splicer untouched. A +/// leading UTF-8 BOM is encoding, not text (yarn's parsers drop it): it is +/// stripped from the first line and kept OUT of that line's span, so a +/// header-less lock still yields its first key and a splice keeps the BOM. +pub(crate) fn scan_blocks(text: &str) -> Vec { + // (start, end-incl-terminator, content-without-terminator, terminated) + let mut lines: Vec<(usize, usize, &str, bool)> = Vec::new(); + let mut pos = 0; + for seg in text.split_inclusive('\n') { + let mut start = pos; + pos += seg.len(); + let terminated = seg.ends_with('\n'); + let mut content = seg; + if terminated { + content = &content[..content.len() - 1]; + } + let mut content = content.strip_suffix('\r').unwrap_or(content); + if start == 0 { + if let Some(rest) = content.strip_prefix('\u{feff}') { + start = '\u{feff}'.len_utf8(); + content = rest; + } + } + lines.push((start, pos, content, terminated)); + } + let mut blocks = Vec::new(); + let mut i = 0; + while i < lines.len() { + let (start, _, content, _) = lines[i]; + if is_key_line(content) { + let mut j = i + 1; + while j < lines.len() && is_body_line(lines[j].2) { + j += 1; + } + blocks.push(LockBlock { + start, + end: lines[j - 1].1, + terminated: lines[j - 1].3, + key: content[..content.len() - 1].to_string(), + lines: lines[i..j].iter().map(|l| l.2.to_string()).collect(), + }); + i = j; + } else { + i += 1; + } + } + blocks +} + +fn is_key_line(s: &str) -> bool { + !s.is_empty() && !s.starts_with([' ', '\t', '#']) && s.ends_with(':') +} + +fn is_body_line(s: &str) -> bool { + s.starts_with(' ') || s.starts_with('\t') +} + +/// The line terminator `block` is written in: its first line's (`\r\n` or +/// `\n`), else — a block that is one unterminated last line — the file's +/// dominant one ([`detect_eol`]). For a uniformly-ended lock this is the +/// file's own terminator; in a lock whose endings were mixed after the +/// fact it keeps a restored block in the style of the block it replaces. +pub(crate) fn block_eol(text: &str, block: &LockBlock) -> &'static str { + let span = &text[block.start..block.end]; + match span.find('\n') { + Some(i) if span[..i].ends_with('\r') => "\r\n", + Some(_) => "\n", + None => detect_eol(text), + } +} + +/// Splice `new_lines` over `block`'s byte range, preserving every byte +/// outside it. +pub(crate) fn replace_block( + text: &str, + block: &LockBlock, + new_lines: &[String], + eol: &str, +) -> String { + let mut replacement = new_lines.join(eol); + if block.terminated { + replacement.push_str(eol); + } + format!( + "{}{}{}", + &text[..block.start], + replacement, + &text[block.end..] + ) +} + +/// A 2-space body field line (`version "1.3.0"` / `resolution: "..."`), +/// returned without the indent; deeper sub-map lines return `None`. +pub(crate) fn body_field_line(line: &str) -> Option<&str> { + let rest = line.strip_prefix(" ")?; + if rest.starts_with(' ') { + return None; + } + Some(rest) +} + +/// Whether `line` is the 2-space body field `field`, in either grammar +/// (classic `field "value"` / `field value`, berry `field: value`). +pub(crate) fn is_body_field(line: &str, field: &str) -> bool { + body_field_line(line) + .and_then(|rest| rest.strip_prefix(field)) + .is_some_and(|rest| rest.starts_with([' ', ':'])) +} + +/// `lines` with the first body field `field` replaced by `new_line` +/// (`None` when the block has no such field). Plain line surgery: no +/// regex, so nothing in `new_line` is ever read as a replacement +/// template. +pub(crate) fn with_body_field( + lines: &[String], + field: &str, + new_line: &str, +) -> Option> { + let at = lines.iter().skip(1).position(|l| is_body_field(l, field))? + 1; + let mut out = lines.to_vec(); + out[at] = new_line.to_string(); + Some(out) +} + +/// A classic block's lines pinned to a tarball: `resolved` set to +/// `resolved` and `integrity` to `integrity` — the `integrity` line +/// replaced, or added right after `resolved` when absent (yarn's field +/// order: version, resolved, integrity, dependencies; once the line is +/// there yarn enforces both hashes). Every other line is kept verbatim. A +/// block with no `resolved` line is returned unchanged: there is no tarball +/// to repoint. +/// +/// The ONE classic pin splice: the vendored backend, the hosted rewriter +/// and the hosted restore all write a block through it. +pub(crate) fn repin_classic_block( + lines: &[String], + resolved: &str, + integrity: &str, +) -> Vec { + if !lines.iter().skip(1).any(|l| is_body_field(l, "resolved")) { + return lines.to_vec(); + } + let has_integrity = lines.iter().skip(1).any(|l| is_body_field(l, "integrity")); + let mut out = Vec::with_capacity(lines.len() + 1); + for (i, line) in lines.iter().enumerate() { + if i > 0 && is_body_field(line, "resolved") { + out.push(format!(" resolved \"{resolved}\"")); + if !has_integrity { + out.push(format!(" integrity {integrity}")); + } + } else if i > 0 && is_body_field(line, "integrity") { + out.push(format!(" integrity {integrity}")); + } else { + out.push(line.clone()); + } + } + out +} + +/// Read a classic scalar field (` ""`, integrity unquoted). +pub(crate) fn classic_field<'a, S: AsRef>(lines: &'a [S], field: &str) -> Option<&'a str> { + for line in lines.iter().skip(1) { + let Some(rest) = body_field_line(line.as_ref()) else { + continue; + }; + let Some(value) = rest.strip_prefix(field) else { + continue; + }; + let Some(value) = value.strip_prefix(' ') else { + continue; + }; + return Some(value.trim().trim_matches('"')); + } + None +} + +/// 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 +/// block with no patterns is never live. +pub(crate) fn live_blocks(patterns: &[Vec]) -> Vec { + let mut last: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); + for (i, pats) in patterns.iter().enumerate() { + for p in pats { + last.insert(p.as_str(), i); + } + } + patterns + .iter() + .enumerate() + .map(|(i, pats)| pats.iter().any(|p| last.get(p.as_str()) == Some(&i))) + .collect() +} + +/// Read a berry scalar field (`: `, value possibly quoted). +pub(crate) fn berry_field<'a, S: AsRef>(lines: &'a [S], field: &str) -> Option<&'a str> { + berry_field_of(lines.iter().map(AsRef::as_ref), field) +} + +/// [`berry_field`] over a block's text (key line first), without +/// collecting its lines: for a per-dep scan of every stanza of a lock. +pub(crate) fn berry_stanza_field<'a>(stanza: &'a str, field: &str) -> Option<&'a str> { + berry_field_of(stanza.lines(), field) +} + +fn berry_field_of<'a>(mut lines: impl Iterator, field: &str) -> Option<&'a str> { + lines.next()?; + lines.find_map(|line| { + let value = body_field_line(line)? + .strip_prefix(field)? + .strip_prefix(':')?; + Some(value.trim().trim_matches('"')) + }) +} + +/// The lock's exact `__metadata` block (its `version` / `cacheKey` header). +pub(crate) fn berry_metadata(blocks: &[LockBlock]) -> Option<&LockBlock> { + blocks.iter().find(|b| b.key == "__metadata") +} + +/// Whether the hosted classic writers (rewrite and restore) can splice +/// `lock`: every `\r` is part of a `\r\n` line break. CRLF, LF and a mix of +/// the two all splice byte-exactly; a bare `\r` does not. +pub(crate) fn classic_line_endings_supported(lock: &str) -> bool { + let bytes = lock.as_bytes(); + bytes + .iter() + .enumerate() + .all(|(i, &b)| b != b'\r' || bytes.get(i + 1) == Some(&b'\n')) +} + +/// Whether a berry lock holds an entry for `name` at `version`, under any +/// descriptor (npm, tarball, `patch:`, …). +pub(crate) fn berry_lock_locks(lock: &str, name: &str, version: &str) -> bool { + scan_blocks(lock).iter().any(|block| { + block.key != "__metadata" + && berry_field(&block.lines, "version") == Some(version) + && split_berry_key_patterns(&block.key).iter().any(|p| { + split_pattern(p).is_some_and(|(n, range)| { + n == name && berry_npm_alias_target(range).is_none_or(|real| real == name) + }) + }) + }) +} + +/// The entries of the berry lock `lock` that carry a `bin:` map: the only +/// ones whose pin needs the served tarball's own package.json (#718). +/// Scanned once per lock, so the per-dep check in +/// [`berry_pin_needs_manifest`] only walks these (usually none). +pub(crate) fn berry_bin_entries(lock: &str) -> Vec { + scan_blocks(lock) + .into_iter() + .filter(|block| block.lines.iter().skip(1).any(|l| is_body_field(l, "bin"))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines(text: &str) -> Vec { + text.lines().map(str::to_string).collect() + } + + #[test] + fn repin_replaces_or_adds_integrity_and_keeps_every_other_line() { + let with = lines( + "a@^1:\n version \"1.0.0\"\n resolved \"https://r/a.tgz#00\"\n integrity sha512-old\n dependencies:\n b \"^1\"", + ); + assert_eq!( + repin_classic_block(&with, "https://p/$1.tgz#ff", "sha512-new"), + lines( + "a@^1:\n version \"1.0.0\"\n resolved \"https://p/$1.tgz#ff\"\n integrity sha512-new\n dependencies:\n b \"^1\"", + ), + "a `$` in the URL is literal text, never a replacement group" + ); + let without = lines("a@^1:\n version \"1.0.0\"\n resolved \"https://r/a.tgz\""); + assert_eq!( + repin_classic_block(&without, "https://p/a.tgz", "sha512-new"), + lines( + "a@^1:\n version \"1.0.0\"\n resolved \"https://p/a.tgz\"\n integrity sha512-new", + ) + ); + let unresolved = lines("a@^1:\n version \"1.0.0\"\n integrity sha512-old"); + assert_eq!(repin_classic_block(&unresolved, "x", "y"), unresolved); + } + + #[test] + fn with_body_field_matches_both_grammars_and_skips_sub_maps() { + let berry = lines( + "\"a@npm:^1\":\n dependencies:\n resolution: x\n resolution: \"a@npm:1.0.0\"", + ); + assert_eq!( + with_body_field(&berry, "resolution", " resolution: \"$0\"").unwrap()[3], + " resolution: \"$0\"" + ); + assert_eq!(with_body_field(&berry, "checksum", "x"), None); + assert!(is_body_field(" resolved \"x\"", "resolved")); + assert!(!is_body_field(" resolvedX \"x\"", "resolved")); + assert!(!is_body_field(" resolved \"x\"", "resolved")); + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index e6fc97b2d..5e6dba3fd 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -1,15 +1,25 @@ -//! `yarn.lock`, classic (v1) and berry (v2+): the grammar split every -//! reader of the file routes on. +//! `yarn.lock`, classic (v1) and berry (v2+): the one grammar every +//! reader and writer of the file shares. //! -//! The entry grammars themselves (`vendor::yarn_classic_lock`'s block walk, -//! `lock_inventory::yarn`'s entry models) and the hosted splices are still -//! read through their current homes; this module owns the one decision -//! they all start from — which grammar a lock is — so the vendor flavor -//! probe, the lock-inventory view, repair's reference flavor, both hosted -//! rewriters and lockfile discovery cannot disagree on it. +//! * which grammar a lock is ([`sniff_grammar`], [`is_berry_lock`]); +//! * the block walk and field reads ([`blocks`]); +//! * key, descriptor and locator patterns ([`patterns`]); +//! * where yarn 1 installs a block's copy from ([`source`]); +//! * the stanza view the hosted berry writers re-key and re-order +//! entries in ([`stanzas`]); +//! * the berry pinned-entry renderer ([`berry_entry`]). +//! +//! The vendored backends (`vendor::yarn_classic_lock`, +//! `vendor::yarn_berry_lock`), the hosted rewriters and restorers +//! (`patch::redirect`), the lock inventory and lockfile discovery all read +//! the lock through these, so they cannot disagree on it. pub(crate) mod berry_entry; pub mod berry_gates; +pub(crate) mod blocks; +pub(crate) mod patterns; +pub(crate) mod source; +pub(crate) mod stanzas; use super::text::strip_bom; @@ -22,39 +32,42 @@ pub enum YarnLockGrammar { Classic, } -/// How many head lines [`sniff_grammar`] reads. -const SNIFF_HEAD_LINES: usize = 30; - /// Why [`sniff_grammar`] found neither grammar, for the refusal detail. pub const UNIDENTIFIED_DETAIL: &str = "yarn.lock carries neither the `# yarn lockfile v1` \ header nor a berry `__metadata:` key; cannot identify the lockfile version"; -/// The head sniff: berry when one of the first lines is a column-0 -/// `__metadata:` key, else classic when one is the `# yarn lockfile v1` -/// header, else `None`. Berry wins the check — a berry lock must never be -/// mistaken for classic. CRLF lines split like LF ones; a leading BOM is -/// not key text. +/// The ONE grammar decision: berry when any line is a column-0 +/// `__metadata:` key, else classic when any line is the `# yarn lockfile v1` +/// header, else `None` (a header-less lock). Berry wins — a berry lock must +/// never be read as classic. CRLF lines split like LF ones; a leading BOM +/// is not key text (yarn's parsers drop it). +/// +/// What a header-less lock is depends on what the caller does with it: +/// the vendored router, which must know the grammar it writes, refuses +/// `None` (`vendor_lockfile_version_unsupported`); every reader takes +/// [`grammar`]'s answer, classic — what yarn 1 parses it as, and what yarn +/// berry migrates it from. pub fn sniff_grammar(text: &str) -> Option { - let head: Vec<&str> = strip_bom(text).lines().take(SNIFF_HEAD_LINES).collect(); - if head.iter().any(|l| l.starts_with("__metadata:")) { - Some(YarnLockGrammar::Berry) - } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - Some(YarnLockGrammar::Classic) - } else { - None + let mut classic = false; + for line in strip_bom(text).lines() { + if line.starts_with("__metadata:") { + return Some(YarnLockGrammar::Berry); + } + classic |= line.trim() == "# yarn lockfile v1"; } + classic.then_some(YarnLockGrammar::Classic) +} + +/// [`sniff_grammar`] with a header-less lock read as classic. +pub fn grammar(text: &str) -> YarnLockGrammar { + sniff_grammar(text).unwrap_or(YarnLockGrammar::Classic) } -/// A yarn.lock is berry (v2+) when ANY line carries the `__metadata:` -/// header key; anything else is a classic v1 lock. The whole-file check -/// both hosted rewriters, lockfile discovery and the classic vendored -/// backend's refusal gate share. A leading BOM is encoding, not key text -/// (yarn's YAML parser drops it), so a header-less lock opening with -/// `\u{feff}__metadata:` is berry too. +/// Whether [`grammar`] says berry: the check both hosted rewriters, the +/// hosted restore, lockfile discovery and the classic vendored backend's +/// refusal gate share. pub fn is_berry_lock(content: &str) -> bool { - strip_bom(content) - .lines() - .any(|line| line.starts_with("__metadata:")) + grammar(content) == YarnLockGrammar::Berry } #[cfg(test)] @@ -76,8 +89,23 @@ mod tests { Some(YarnLockGrammar::Berry) ); assert_eq!(sniff_grammar("a@1:\n version \"1\"\n"), None); - let deep = format!("{}# yarn lockfile v1\n", "\n".repeat(SNIFF_HEAD_LINES)); - assert_eq!(sniff_grammar(&deep), None); + assert_eq!(grammar("a@1:\n version \"1\"\n"), YarnLockGrammar::Classic); + } + + /// B60: the vendored router (`sniff_grammar`) and every reader + /// (`is_berry_lock`) take one decision, wherever in the file the marker + /// sits. They used to disagree: the router read only the first 30 lines, + /// so a classic header above a hand-merged `__metadata:` key was classic + /// to vendor and berry to hosted, restore and VEX. + #[test] + fn the_router_and_the_readers_agree_on_a_deep_marker() { + let padding = "\n".repeat(40); + let merged = format!("# yarn lockfile v1\n{padding}__metadata:\n version: 8\n"); + assert_eq!(sniff_grammar(&merged), Some(YarnLockGrammar::Berry)); + assert!(is_berry_lock(&merged)); + let deep_header = format!("{padding}# yarn lockfile v1\n"); + assert_eq!(sniff_grammar(&deep_header), Some(YarnLockGrammar::Classic)); + assert!(!is_berry_lock(&deep_header)); } #[test] diff --git a/crates/socket-patch-core/src/formats/yarn/patterns.rs b/crates/socket-patch-core/src/formats/yarn/patterns.rs new file mode 100644 index 000000000..35f052c79 --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/patterns.rs @@ -0,0 +1,188 @@ +//! yarn.lock key and locator patterns: how a block's key splits into its +//! descriptors (classic and berry quote them differently), how a +//! descriptor splits into name and range, and the berry `resolution:` +//! locator and `resolutions` selector forms. Shared by every yarn reader +//! and writer (see [`super::blocks`]). + +/// Split a comma-joined key into its patterns, honoring quoting; the +/// surrounding quotes are dropped from each pattern. +pub(crate) fn split_key_patterns(key: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_quotes = false; + for ch in key.chars() { + match ch { + '"' => in_quotes = !in_quotes, + ',' if !in_quotes => { + let p = cur.trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + cur.clear(); + } + _ => cur.push(ch), + } + } + let p = cur.trim(); + if !p.is_empty() { + out.push(p.to_string()); + } + out +} + +/// Split a berry lock key into its comma-joined descriptor patterns. yarn +/// wraps a multi-descriptor key in ONE outer quote pair (`"a@npm:^1, +/// a@npm:^2"`), so strip a single wrapping pair first, THEN split on `, ` — +/// that surfaces every descriptor (letting a genuinely mixed-name key be +/// detected as ambiguous) while a single quoted descriptor stays intact. +/// Twin of the TS `splitKeyPatterns`. The ONE berry key splitter: the +/// vendored and hosted berry backends and the lock inventory's +/// `berry_entries` (lockfile discovery's entry model) all read berry keys +/// with it — [`split_key_patterns`] is the classic grammar's, and treats +/// the outer pair as one quoted pattern. +pub(crate) fn split_berry_key_patterns(key: &str) -> Vec { + let trimmed = key.trim(); + let inner = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + inner + .split(", ") + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect() +} + +/// Split `name@range` at the first `@` past a leading `@scope/` marker. +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..]); + if name.is_empty() || range.is_empty() { + return None; + } + Some((name, range)) +} + +/// The real package a key pattern stands for: its name, unless the range is +/// an `npm:` alias — then the aliased target's name. +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) { + Some((real, _)) => Some(real), + None => Some(aliased), // `npm:left-pad` with no range + }; + } + Some(name) +} + +/// The one real package EVERY pattern of a classic key stands for +/// ([`pattern_real_name`]): `None` when there is no pattern, one does not +/// parse, or they name different packages. +pub(crate) fn classic_key_real_name(patterns: &[String]) -> Option<&str> { + let mut names = patterns.iter().map(|p| pattern_real_name(p)); + let first = names.next()??; + names.all(|n| n == Some(first)).then_some(first) +} + +/// A classic `resolved` value split at its first `#`: the url before it, +/// and the fragment as a lowercase sha1 when it is 40 hex digits (either +/// case) — the legacy tarball verifier yarn v1 enforces when no +/// `integrity` line is present. +pub(crate) fn split_resolved_sha1(raw: &str) -> (&str, Option) { + match raw.split_once('#') { + Some((url, frag)) => (url, crate::utils::digest::sha1_hex(frag)), + None => (raw, None), + } +} + +/// A berry `resolution:` locator `name@`, split at the first `@` +/// past a leading `@scope/` marker ([`split_pattern`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BerryLocator<'a> { + pub(crate) name: &'a str, + pub(crate) reference: &'a str, +} + +impl<'a> BerryLocator<'a> { + /// `(version, bindings)` of a registry locator `npm:[::]` + /// (`bindings` is `""` without a `::`); `None` for any other protocol. + pub(crate) fn npm(&self) -> Option<(&'a str, &'a str)> { + let npm = self.reference.strip_prefix("npm:")?; + Some(npm.split_once("::").unwrap_or((npm, ""))) + } + + /// The `__archiveUrl=` binding of a registry locator (bindings are + /// `&`-joined), still percent-encoded — what hosted redirects up to 5.0 + /// wrote (and what yarn itself writes for a custom registry). + pub(crate) fn archive_url(&self) -> Option<&'a str> { + self.npm()? + .1 + .split('&') + .find_map(|b| b.strip_prefix("__archiveUrl=")) + } +} + +/// Parse a berry `resolution:` value into its locator. +pub(crate) fn parse_berry_locator(resolution: &str) -> Option> { + split_pattern(resolution).map(|(name, reference)| BerryLocator { name, reference }) +} + +/// The package a berry `resolutions` selector overrides: its LAST +/// descriptor's ident (`name`, `name@range`, `**/name`, `parent/name`, +/// `@scope/name`, `parent/@scope/name@range`), or `None` when it has none. +pub(crate) fn resolution_selector_target(selector: &str) -> Option<&str> { + let s = selector.trim(); + // The last descriptor starts after the last `/` that is not a scope's + // own separator (the segment before it starts with `@`). + let mut start = 0; + let bytes = s.as_bytes(); + let mut seg_start = 0; + for (i, &b) in bytes.iter().enumerate() { + if b == b'/' { + if !s[seg_start..i].starts_with('@') { + start = i + 1; + } + seg_start = i + 1; + } + } + let last = &s[start..]; + let name = split_pattern(last).map(|(n, _)| n).unwrap_or(last); + (!name.is_empty() && name != "**").then_some(name) +} + +/// The package a berry `npm:@` alias range installs: `None` +/// for a plain `npm:` (no alias, the descriptor's own package) or +/// any other protocol. Unlike [`pattern_real_name`], which answers for a +/// whole classic key pattern and reads a bare `npm:` with no range as +/// an alias of ``, this reads only the range, so a plain berry +/// `npm:^1` registry range is never mistaken for an alias target. +pub(crate) fn berry_npm_alias_target(range: &str) -> Option<&str> { + let body = range.strip_prefix("npm:")?; + split_pattern(body).map(|(real, _)| real) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolution_selector_targets() { + for (sel, want) in [ + ("left-pad", Some("left-pad")), + ("left-pad@npm:1.3.0", Some("left-pad")), + ("**/left-pad", Some("left-pad")), + ("parent/left-pad", Some("left-pad")), + ("@scope/pkg", Some("@scope/pkg")), + ("@p/parent/@scope/pkg@^2", Some("@scope/pkg")), + ("@scope/parent/left-pad", Some("left-pad")), + ("**", None), + ("", None), + ] { + assert_eq!(resolution_selector_target(sel), want, "{sel}"); + } + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs new file mode 100644 index 000000000..859827ead --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -0,0 +1,270 @@ +//! Where yarn 1 installs a classic lock block's copy from, decided once for +//! every mode that reads or rewrites the block. + +use super::patterns::split_pattern; +use crate::vendor::npm_origin::npm_spec_is_registry; + +/// Where yarn 1 installs a lock block's copy from: the ONE classifier every +/// mode routes a classic block of the patched `name@version` through +/// (#857, #921, B16), so a copy one of them cannot rewire is never silently +/// counted as wired by another. What each mode then does with it: +/// +/// | source | hosted rewrite | hosted restore | vendored | inventory verifiers | +/// |-----------------|----------------|----------------|----------|---------------------| +/// | `Registry` | pinned | restored | wired | kept | +/// | `RemoteTarball` | skipped, named | refused | skipped | dropped | +/// | `Directory` | skipped, named | n/a | skipped | n/a | +/// | `Git` | skipped, named | refused | skipped | dropped | +/// | `Link` | not ours | n/a | skipped | n/a | +/// | `Unresolved` | left untouched | n/a | skipped | n/a | +/// +/// Both writing modes pin a copy to Socket's build of the REGISTRY package +/// (the hosted artifact, the service-built vendored tarball), so a remote +/// tarball — a fork, a local build — would be replaced by registry bytes it +/// never was. `vex` reads every tarball copy by what its `resolved` names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CopySource { + /// A registry tarball: every key pattern is a registry range (a + /// version, semver range, dist-tag, or an `npm:` alias of one) and the + /// block has a `resolved`. + Registry, + /// A tarball yarn fetches from somewhere other than the registry: a + /// `file:` tarball, a URL range, or a hosted-git shorthand + /// (`owner/repo`, `github:owner/repo`) that yarn locks to a GitHub + /// codeload tarball. The user's own artifact, not the registry package. + RemoteTarball, + /// A `link:` range: a symlink into the working tree. + Link, + /// A `file:` directory range: yarn COPIES the directory into + /// `node_modules`, so that copy keeps its own bytes. + Directory, + /// Fetched by yarn's git fetcher ([`classic_block_is_git`]). + Git, + /// Any other range with no `resolved`: not something yarn writes for a + /// locked package, so the lock is stale and `yarn install` re-locks it. + Unresolved, +} + +/// [`CopySource`] of a classic block from its key patterns and `resolved`. +pub(crate) fn classic_copy_source(patterns: &[String], resolved: Option<&str>) -> CopySource { + for pattern in patterns { + let range = split_pattern(pattern).map(|(_, r)| r).unwrap_or(""); + if range.starts_with("link:") { + return CopySource::Link; + } + if let Some(path) = range.strip_prefix("file:") { + if !is_tarball_path(path) { + return CopySource::Directory; + } + } + } + if classic_block_is_git(patterns, resolved) { + return CopySource::Git; + } + let Some(resolved) = resolved else { + return CopySource::Unresolved; + }; + let registry_ranges = patterns + .iter() + .all(|p| split_pattern(p).is_some_and(|(_, range)| npm_spec_is_registry(range))); + if registry_ranges && !is_codeload_tarball(resolved) { + CopySource::Registry + } else { + CopySource::RemoteTarball + } +} + +/// A GitHub codeload tarball: what yarn 1 locks a hosted-git shorthand to. +fn is_codeload_tarball(resolved: &str) -> bool { + resolved + .split_once("://") + .is_some_and(|(_, rest)| rest.starts_with("codeload.github.com/")) +} + +/// 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, + } +} + +/// `file:` path → tarball or directory? Directories cannot be rewired. +fn is_tarball_path(path: &str) -> bool { + let path = path.split('#').next().unwrap_or(path).trim_end_matches('/'); + path.ends_with(".tgz") || path.ends_with(".tar.gz") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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") + )); + } + + /// B16: which classic copies are the registry package. A hosted pin + /// replaces the copy with Socket's patched registry artifact, so only + /// a registry copy may be pinned. + #[test] + fn copy_sources_split_registry_from_remote_tarballs() { + let pats = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + let registry = Some("https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#aa"); + for key in [ + &["left-pad@^1.3.0"][..], + &["left-pad@^1.3.0", "left-pad@~1.3.0"], + &["left-pad@latest"], + &["pad@npm:left-pad@^1.3.0"], + ] { + assert_eq!( + classic_copy_source(&pats(key), registry), + CopySource::Registry, + "{key:?}" + ); + } + for (key, resolved) in [ + ( + "left-pad@file:./old/left-pad-1.3.0.tgz", + "file:./old/left-pad-1.3.0.tgz#aa", + ), + ("left-pad@https://host/fork.tgz", "https://host/fork.tgz"), + ( + "left-pad@stevemao/left-pad#v1.3.0", + "https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba", + ), + ( + "left-pad@github:stevemao/left-pad", + "https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba", + ), + ( + "pad@npm:left-pad@https://host/fork.tgz", + "https://host/fork.tgz", + ), + ] { + assert_eq!( + classic_copy_source(&pats(&[key]), Some(resolved)), + CopySource::RemoteTarball, + "{key}" + ); + } + assert_eq!( + classic_copy_source(&pats(&["left-pad@^1.3.0"]), None), + CopySource::Unresolved + ); + assert_eq!( + classic_copy_source(&pats(&["left-pad@file:../left-pad"]), None), + CopySource::Directory + ); + } +} diff --git a/crates/socket-patch-core/src/formats/yarn/stanzas.rs b/crates/socket-patch-core/src/formats/yarn/stanzas.rs new file mode 100644 index 000000000..bbedb8bd4 --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/stanzas.rs @@ -0,0 +1,215 @@ +//! The stanza view of a berry `yarn.lock`, for the writers that re-key an +//! entry and move it to where yarn sorts it — an edit the byte splice of +//! [`super::blocks`] cannot express. The hosted berry rewriter and the +//! hosted berry restore both read and write the lock through it. +//! +//! A stanza is one blank-line separated run of the LF-normalized lock: a +//! header comment run, the `__metadata` block or one entry. The leading +//! BOM, the line endings and the trailing newlines ride outside the +//! stanzas, and [`BerryStanzas::render`] puts them back, so every +//! untouched byte round-trips. Mixed line endings have no single style to +//! restore: callers refuse such a lock first (yarn itself rejects it +//! under `--immutable`, YN0028). + +use std::borrow::Cow; + +use crate::utils::line_endings::{to_lf, LineEndings}; + +/// A berry lock split into its stanzas (see the module docs). +pub(crate) struct BerryStanzas { + bom: &'static str, + eol: LineEndings, + /// The LF-normalized lock without its BOM. + pub(crate) lf: String, + /// The stanzas of [`Self::lf`], without the trailing newlines. + pub(crate) stanzas: Vec, + trailing: String, + was_sorted: bool, +} + +impl BerryStanzas { + /// Split `raw`. A [`LineEndings::Mixed`] lock must be refused by the + /// caller first; one would be rendered back in a single style. + pub(crate) fn parse(raw: &str) -> Self { + let (bom, body) = match raw.strip_prefix('\u{feff}') { + Some(rest) => ("\u{feff}", rest), + None => ("", raw), + }; + let eol = LineEndings::of(body); + let lf = to_lf(body).into_owned(); + let trimmed = lf.trim_end_matches('\n'); + let trailing = lf[trimmed.len()..].to_string(); + let stanzas: Vec = trimmed.split("\n\n").map(String::from).collect(); + let was_sorted = entries_sorted(&stanzas); + Self { + bom, + eol, + lf, + stanzas, + trailing, + was_sorted, + } + } + + /// `lf` (a stanza, or any LF text) spelled in the lock's own line + /// ending: what an edit records as the file's on-disk bytes. + pub(crate) fn on_disk<'a>(&self, lf: &'a str) -> Cow<'a, str> { + self.eol.restore(lf) + } + + /// The lock text: every stanza keyed `moved` placed where yarn sorts it + /// (see [`reposition`]), then the trailing newlines, line endings and + /// BOM restored. + pub(crate) fn render(mut self, moved: &[String]) -> String { + reposition(&mut self.stanzas, moved, self.was_sorted); + let out = format!("{}{}", self.stanzas.join("\n\n"), self.trailing); + format!("{}{}", self.bom, self.eol.restore(&out)) + } +} + +/// A stanza's key: its first line minus the trailing `:` when that line is +/// a block key line (column 0, not a comment); `None` for a header comment +/// run or anything else. `__metadata` is a key too. +pub(crate) fn stanza_key(stanza: &str) -> Option<&str> { + let first = stanza.lines().next()?; + if first.starts_with([' ', '\t', '#']) { + return None; + } + first.strip_suffix(':') +} + +/// A stanza's lines, in the form the [`super::blocks`] field readers take +/// (key line first). +pub(crate) fn stanza_lines(stanza: &str) -> Vec { + stanza.lines().map(str::to_string).collect() +} + +/// A stanza's sort key: its unquoted key, or `None` for header comment +/// runs and `__metadata`. +fn sort_key(stanza: &str) -> Option<&str> { + let key = stanza_key(stanza)?.trim_matches('"'); + (key != "__metadata").then_some(key) +} + +/// Whether the entries are in yarn's key order (see [`reposition`]). +fn entries_sorted(stanzas: &[String]) -> bool { + let keys: Vec<&str> = stanzas.iter().filter_map(|s| sort_key(s)).collect(); + keys.windows(2).all(|w| w[0] <= w[1]) +} + +/// Move each entry keyed `moved` to where yarn sorts it. Yarn writes lock +/// entries sorted by their (unquoted) key — `__metadata` first — so an entry +/// re-keyed from `name@npm:…` to `name@` can move past a sibling (e.g. +/// `name@npm:7.0.0` now sorts after `name@https://…`); a lock in any other +/// order is rewritten by yarn and fails `--immutable`. Header comment +/// stanzas and `__metadata` keep their place; the moved entry is inserted +/// before the first entry whose key sorts after it. A lock that was not in +/// yarn's order before the edit (`was_sorted`; a hand-edited lock) keeps +/// the entry in place, so a pin and its rollback still round-trip +/// byte-exactly. +fn reposition(stanzas: &mut Vec, moved: &[String], was_sorted: bool) { + if !was_sorted { + return; + } + for key in moved { + let line = format!("{key}:"); + let Some(from) = stanzas + .iter() + .position(|s| s.lines().next() == Some(line.as_str())) + else { + continue; + }; + let stanza = stanzas.remove(from); + let Some(own) = sort_key(&stanza).map(str::to_string) else { + stanzas.insert(from, stanza); + continue; + }; + let to = stanzas + .iter() + .position(|s| sort_key(s).is_some_and(|k| k > own.as_str())) + .unwrap_or(stanzas.len()); + stanzas.insert(to, stanza); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_round_trips_bom_crlf_and_trailing_newlines() { + for raw in [ + "__metadata:\n version: 8\n\n\"a@npm:^1\":\n version: 1.0.0\n", + "\u{feff}__metadata:\r\n version: 8\r\n\r\n\"a@npm:^1\":\r\n version: 1.0.0\r\n\r\n", + "# header\n\n__metadata:\n version: 8", + ] { + assert_eq!(BerryStanzas::parse(raw).render(&[]), raw, "{raw:?}"); + } + } + + #[test] + fn a_moved_entry_goes_to_its_sorted_place_only_in_a_sorted_lock() { + let raw = "__metadata:\n version: 8\n\n\"a@npm:^1\":\n v: 1\n\n\"b@npm:^1\":\n v: 1\n"; + let mut doc = BerryStanzas::parse(raw); + doc.stanzas[1] = "\"c@https://x/a.tgz\":\n v: 1".into(); + assert_eq!( + doc.render(&["\"c@https://x/a.tgz\"".into()]), + "__metadata:\n version: 8\n\n\"b@npm:^1\":\n v: 1\n\n\"c@https://x/a.tgz\":\n v: 1\n" + ); + let unsorted = "\"b@npm:^1\":\n v: 1\n\n\"a@npm:^1\":\n v: 1\n"; + let mut doc = BerryStanzas::parse(unsorted); + doc.stanzas[0] = "\"c@x\":\n v: 1".into(); + assert_eq!( + doc.render(&["\"c@x\"".into()]), + "\"c@x\":\n v: 1\n\n\"a@npm:^1\":\n v: 1\n", + "an unsorted lock keeps the entry in place" + ); + } + + #[test] + fn stanza_key_skips_comments_and_body_lines() { + assert_eq!(stanza_key("\"a@npm:^1\":\n v: 1"), Some("\"a@npm:^1\"")); + assert_eq!(stanza_key("__metadata:\n version: 8"), Some("__metadata")); + assert_eq!(stanza_key("# yarn lockfile"), None); + assert_eq!(stanza_key(" version: 8"), None); + } + + /// The stanza view and [`super::super::blocks::scan_blocks`] are two + /// reads of one berry lock (the hosted writers use the first, the + /// vendored backend and the field readers the second): on every shape + /// yarn writes — LF, CRLF, a BOM, a header comment, sub-maps, a final + /// entry with or without a trailing newline — they must name the same + /// blocks in the same order. (A blank line inside a block, which yarn + /// never writes, is where they would part.) + #[test] + fn stanza_keys_match_the_block_scan_on_every_line_ending() { + use super::super::blocks::scan_blocks; + let lf = "# This file is generated by running \"yarn install\" inside your project.\n\ + # Manual changes might be lost - proceed with caution!\n\n\ + __metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"@scope/a@npm:^1.0.0, @scope/a@npm:^1.2.0\":\n version: 1.2.0\n \ + resolution: \"@scope/a@npm:1.2.0\"\n dependencies:\n b: \"npm:^2\"\n \ + checksum: 10c0/aa\n languageName: node\n linkType: hard\n\n\ + \"app@workspace:.\":\n version: 0.0.0-use.local\n resolution: \"app@workspace:.\"\n\n\ + \"b@npm:^2\":\n version: 2.0.0\n resolution: \"b@npm:2.0.0\"\n bin:\n b: cli.js\n"; + let crlf = lf.replace('\n', "\r\n"); + for text in [ + lf.to_string(), + lf.trim_end().to_string(), + crlf.clone(), + format!("\u{feff}{crlf}"), + format!("\u{feff}{lf}\n\n"), + ] { + let stanzas = BerryStanzas::parse(&text); + let from_stanzas: Vec<&str> = stanzas + .stanzas + .iter() + .filter_map(|s| stanza_key(s)) + .collect(); + let blocks = scan_blocks(&text); + let from_blocks: Vec<&str> = blocks.iter().map(|b| b.key.as_str()).collect(); + assert_eq!(from_stanzas, from_blocks, "{text:?}"); + assert_eq!(from_blocks.len(), 4, "{text:?}"); + } + } +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 7b4d180fe..f4feb2424 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -465,7 +465,7 @@ pub async fn read_candidate_files( && out .files .get("yarn.lock") - .is_some_and(|lock| crate::patch::redirect::is_berry_lock(lock)) + .is_some_and(|lock| crate::formats::yarn::is_berry_lock(lock)) { out.read(view, unreadable, "package.json").await; // Otherwise the root manifest's `overrides` decide which git / url / @@ -535,7 +535,7 @@ pub async fn read_candidate_files( && out .files .get("yarn.lock") - .is_some_and(|lock| !crate::patch::redirect::is_berry_lock(lock)) + .is_some_and(|lock| !crate::formats::yarn::is_berry_lock(lock)) { out.read(view, unreadable, crate::patch::redirect::YARNRC_REL) .await; @@ -848,12 +848,11 @@ pub fn yarn_berry_manifest_targets<'a>( ) -> Vec<&'a DepOverride> { let Some(lock) = files .get("yarn.lock") - .filter(|lock| crate::patch::redirect::is_berry_lock(lock)) + .filter(|lock| crate::formats::yarn::is_berry_lock(lock)) else { return Vec::new(); }; - let lock = crate::utils::line_endings::to_lf(lock); - let bin_entries = crate::patch::redirect::berry_bin_entries(&lock); + let bin_entries = crate::formats::yarn::blocks::berry_bin_entries(lock); if bin_entries.is_empty() { return Vec::new(); } diff --git a/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs index a573ac813..2e61248f0 100644 --- a/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/lock_index_equivalence_tests.rs @@ -267,6 +267,7 @@ fn indexed_yarn_classic_rewrite_matches_golden() { "redirect_yarn_classic_missing_sha512", "redirect_yarn_classic_alias_skipped", "redirect_yarn_classic_entry_not_found", + "redirect_yarn_classic_unresolved_entry_skipped", "redirect_yarn_classic_unsupported_line_endings", ] { assert!(codes.contains(code), "missing {code}: {codes:?}"); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c839011cd..05d27b75d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,7 +25,8 @@ use serde_json::{json, Value}; use crate::formats::yarn::berry_gates::{self, Yarnrc}; use crate::utils::digest::is_hex64_lower; -use crate::utils::line_endings::{to_lf, LineEndings}; +#[cfg(test)] +use crate::utils::line_endings::LineEndings; use crate::vendor::common::{parse_json_text, JsonLayout}; use crate::vendor::lock_inventory::npm_legacy_identity; use crate::vendor::npm_origin::{legacy_packages_key, npm_non_registry_entries, NpmOverrides}; @@ -64,6 +65,18 @@ use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; pub(crate) use crate::formats::yarn::is_berry_lock; pub mod gradle; #[cfg(test)] +use crate::formats::yarn::blocks::berry_bin_entries; +use crate::formats::yarn::blocks::{ + berry_field, berry_lock_locks, berry_stanza_field, block_eol, classic_field, + classic_line_endings_supported, repin_classic_block, scan_blocks, LockBlock, +}; +use crate::formats::yarn::patterns::{ + berry_npm_alias_target, classic_key_real_name, split_berry_key_patterns, split_key_patterns, + split_pattern, +}; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; +use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; +#[cfg(test)] mod pnpm_equivalence_tests; #[cfg(test)] mod platform_wheel_tests; @@ -3416,6 +3429,78 @@ pub fn preflight_yarn_classic_hosted( } } +/// Where each classic block sits among the lock's blank-line separated +/// segments, for the `original` / `new` strings of a +/// `redirect_yarn_classic_entry` edit. +/// +/// The record is a cross-language contract: depscan's TS rewriter and the +/// shared `tests/fixtures/redirect/npm/yarn-classic/*/expected-edits.json` +/// fixtures record the segment (`split("\n\n")` over the LF-normalized +/// lock) that holds the block, so a block that follows two blank lines +/// carries a leading `\n` and the last block carries the file's final +/// newline. Both sides of the record keep that surrounding text and differ +/// only in the block's own lines. Built once per lock, from the lock as +/// read (a re-pin never moves a separator). +struct ClassicSegments { + /// The LF-normalized lock. + lf: String, + /// Start offsets (in `lf`) of the non-overlapping `\n\n` separators, + /// scanned left to right as `str::split` does. + seps: Vec, + /// Each block's `(start, end)` in `lf`: its lines joined by `\n`. + spans: Vec<(usize, usize)>, +} + +impl ClassicSegments { + fn new(text: &str, blocks: &[LockBlock]) -> Self { + let lf = text.replace("\r\n", "\n"); + let seps = lf.match_indices("\n\n").map(|(i, _)| i).collect(); + let mut spans = Vec::with_capacity(blocks.len()); + let (mut at, mut crs) = (0, 0); + for block in blocks { + crs += text[at..block.start].matches("\r\n").count(); + at = block.start; + let start = block.start - crs; + let len = block.lines.iter().map(String::len).sum::() + + block.lines.len().saturating_sub(1); + spans.push((start, start + len)); + } + Self { lf, seps, spans } + } + + /// The edit record of block `i` going from `current` to `pinned`, both + /// in the block's line ending `eol`. + fn edit_record( + &self, + i: usize, + current: &[String], + pinned: &[String], + eol: &str, + ) -> (String, String) { + let (start, end) = self.spans[i]; + let first_after = self.seps.partition_point(|&p| p + 2 <= start); + let seg_start = first_after.checked_sub(1).map_or(0, |k| self.seps[k] + 2); + let seg_end = self.seps[self.seps.partition_point(|&p| p < end)..] + .first() + .copied() + .unwrap_or(self.lf.len()); + let (prefix, suffix) = if seg_start <= start && end <= seg_end { + (&self.lf[seg_start..start], &self.lf[end..seg_end]) + } else { + ("", "") + }; + let render = |lines: &[String]| { + let seg = format!("{prefix}{}{suffix}", lines.join("\n")); + if eol == "\r\n" { + seg.replace('\n', "\r\n") + } else { + seg + } + }; + (render(current), render(pinned)) + } +} + /// [`rewrite_yarn_classic_with`] seeing only the project's rc files. #[cfg(test)] fn rewrite_yarn_classic( @@ -3437,8 +3522,6 @@ fn rewrite_yarn_classic_with( yarn_outer: &yarnrc::OuterYarnMirror, result: &mut RewriteResult, ) { - use crate::vendor::yarn_classic_lock::{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; @@ -3458,40 +3541,34 @@ fn rewrite_yarn_classic_with( yarn_outer, ) .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 - // 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 "[^"]*""#).expect("static resolved-line regex is valid"); - let integrity_re = - Regex::new(r"\n {2}integrity [^\n]*").expect("static integrity-line regex is valid"); - // Each block's key and the one real package all its patterns stand for - // (see `yarn_classic_block_head`), computed once per block and redone - // only for a block this run rewrites — not re-split per block per dep. - let mut heads: Vec)>> = - blocks.iter().map(|b| yarn_classic_block_head(b)).collect(); + // Line endings: the rewrite splices each pinned block over its own byte + // span ([`splice_blocks`]), in the line ending that block is written in, + // so every byte outside it — CRLF lines (`core.autocrlf` Windows + // checkouts; yarn v1 parses them fine), a mixed lock's LF lines, a BOM + // — round-trips verbatim. A bare `\r` is not a line break yarn 1's + // lexer accepts, so such a lock is refused untouched. + if !classic_line_endings_supported(raw) { + 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; + } + let text = raw.as_str(); + let mut blocks = scan_blocks(text); + // Each block's key patterns and the one real package they all stand + // for, computed once per block — not re-split per block per dep. A + // re-pin never changes a block's key line. + let heads: Vec<(Vec, Option)> = + blocks.iter().map(|b| classic_block_head(&b.key)).collect(); + // A pinned block's new lines are kept in `blocks[i].lines` (so a later + // dep reads the pinned fields) and spliced over the block's original + // byte span once, at the end: re-scanning and re-copying the whole lock + // per pinned block is quadratic in a large lock. + let mut pinned_blocks: Vec = vec![false; blocks.len()]; + let mut segments: Option = None; let mut changed = false; let mut any_pinned = false; for dep in &npm { @@ -3503,46 +3580,33 @@ fn rewrite_yarn_classic_with( }); continue; }; - let version_re = - Regex::new(&(String::from(r#"\n {2}version ""#) + ®ex::escape(&dep.version) + "\"")) - .expect("version regex from the escaped version is valid"); let mut matched_any = false; let mut pinned_any = false; let mut alias_skipped = false; let mut copy_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 + for i in 0..blocks.len() { + // The block's key line names its consumers; every comma-joined + // pattern resolves 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, real_name)) = &heads[i] else { - continue; - }; + let (patterns, real_name) = &heads[i]; if real_name.as_deref() != Some(fname.as_str()) { continue; } - if !version_re.is_match(block) { + let block = &blocks[i]; + if classic_field(&block.lines, "version") != Some(dep.version.as_str()) { 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('"')); - use crate::vendor::yarn_classic_lock::{classic_block_source, ClassicBlockSource}; - let source = classic_block_source(&patterns, resolved); + let key = &block.key; + let resolved = classic_field(&block.lines, "resolved"); + let source = classic_copy_source(patterns, resolved); // yarn 1 COPIES a `file:` directory (or a block with no // `resolved`) into node_modules (#921): there is no tarball to // repoint, so that copy keeps the directory's unpatched bytes — // named, and never assumed applied by the in-run VEX. - if source == ClassicBlockSource::Directory { + if source == CopySource::Directory { copy_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { @@ -3556,7 +3620,12 @@ fn rewrite_yarn_classic_with( }); continue; } - if source == ClassicBlockSource::Git { + // 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. + if source == CopySource::Git { copy_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { @@ -3570,6 +3639,69 @@ fn rewrite_yarn_classic_with( }); continue; } + // A `file:` tarball, URL or hosted-git shorthand copy (B16) is + // the user's own artifact — a fork, a local build — not the + // registry package: pinning it to Socket's patched REGISTRY + // artifact would silently swap the user's code for registry + // bytes, and nothing records the original `resolved` for a + // rollback. Left untouched and named, like the git copy. + // A copy an older release already pinned to this run's artifact + // installs Socket's patched registry build, not the user's own + // artifact: it is neither unpatched nor re-pinned, but the pin + // is the B16 mistake, so it is named with the way back (rollback + // refuses it, since the copy's own `resolved` was never + // recorded). + if source == CopySource::RemoteTarball + && resolved.is_some_and(|r| { + r.split_once('#').map_or(r, |(base, _)| base) == dep.artifact_url + }) + { + matched_any = true; + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_non_registry_legacy_pin".into(), + detail: format!( + "lock entry `{key}` is a file: tarball, URL or hosted-git dependency \ + that an older release pinned to Socket's patched registry artifact \ + for {fname}@{}, so it installs the registry build rather than your \ + own; its original `resolved` was not recorded — restore yarn.lock \ + from version control to return it to its own source", + dep.version + ), + }); + continue; + } + if source == CopySource::RemoteTarball { + copy_skipped = true; + result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_non_registry_entry_skipped".into(), + detail: format!( + "lock entry `{key}` installs {fname}@{} from a tarball that is not the \ + registry's (a file: tarball, URL or hosted-git dependency); the hosted \ + redirect only repoints registry copies, so it leaves this one \ + untouched and this copy stays unpatched", + dep.version + ), + }); + continue; + } + // A block with no `resolved` is a stale lock (yarn writes one for + // every locked package): there is no tarball to repoint, and + // `yarn install` re-locks it from the registry, unpatched. + if source == CopySource::Unresolved { + copy_skipped = true; + result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_yarn_classic_unresolved_entry_skipped".into(), + detail: format!( + "lock entry `{key}` locks {fname}@{} with no `resolved` tarball (a \ + stale lock); the hosted redirect has nothing to repoint, so this copy \ + stays unpatched — run `yarn install` to re-lock it, then re-run", + 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 @@ -3599,11 +3731,7 @@ fn rewrite_yarn_classic_with( // hosted pin only if an earlier run already wrote one — this // run's URL, or one on the same patch server from an earlier // grant token or patch uuid. - pinned_any |= resolved_re.find(block).is_some_and(|m| { - let url = m - .as_str() - .trim_start_matches("\n resolved \"") - .trim_end_matches('"'); + pinned_any |= classic_field(&block.lines, "resolved").is_some_and(|url| { let url = url.split_once('#').map_or(url, |(u, _)| u); berry_hosted_pin_is_ours(url, &fname, Some(&dep.version), &dep.artifact_url) }); @@ -3616,47 +3744,27 @@ fn rewrite_yarn_classic_with( .as_ref() .map(|s| format!("#{s}")) .unwrap_or_default(); - let mut rewritten = resolved_re - .replace( - block, - format!("\n resolved \"{}{frag}\"", dep.artifact_url).as_str(), - ) - .to_string(); - if integrity_re.is_match(&rewritten) { - rewritten = integrity_re - .replace(&rewritten, format!("\n integrity {sha512}").as_str()) - .to_string(); - } else { - rewritten = resolved_re - .replace( - &rewritten, - // $0 re-inserts the matched resolved line, then add integrity. - format!( - "\n resolved \"{}{frag}\"\n integrity {sha512}", - dep.artifact_url - ) - .as_str(), - ) - .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()) - }; + let pinned = repin_classic_block( + &block.lines, + &format!("{}{frag}", dep.artifact_url), + &sha512, + ); + if pinned != block.lines { + // Edits record the block's on-disk bytes (CRLF lines for a + // CRLF block), so they match what the file really held. + let eol = block_eol(text, block); + let segments = segments.get_or_insert_with(|| ClassicSegments::new(text, &blocks)); + let (original, new) = segments.edit_record(i, &blocks[i].lines, &pinned, eol); 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(edit_original)), - new: Some(Value::String(edit_new)), + original: Some(Value::String(original)), + new: Some(Value::String(new)), }); - *block = rewritten; - heads[i] = yarn_classic_block_head(block); + blocks[i].lines = pinned; + pinned_blocks[i] = true; changed = true; } } @@ -3693,32 +3801,38 @@ fn rewrite_yarn_classic_with( } } if changed { - let mut out = blocks.join("\n\n"); - if crlf { - out = out.replace('\n', "\r\n"); - } - result.files.insert("yarn.lock".into(), out); + result.files.insert( + "yarn.lock".into(), + splice_blocks(text, &blocks, &pinned_blocks), + ); } } -/// A classic yarn.lock block's key (its first non-indented, non-comment -/// line, minus the trailing `:`) and the real package EVERY comma-joined -/// pattern of that key resolves to — `None` when the key has no pattern, -/// one does not parse, or they name different packages. `None` overall -/// when the block has no key line. -fn yarn_classic_block_head(block: &str) -> Option<(String, Option)> { - use crate::vendor::yarn_classic_lock::{pattern_real_name, split_key_patterns}; - let key_line = block - .lines() - .find(|l| !l.is_empty() && !l.starts_with([' ', '\t', '#']))?; - let key = key_line.strip_suffix(':')?; +/// `text` with every block flagged in `pinned` replaced by its (new) +/// `lines`, in the line ending that block is written in; every other byte +/// is kept verbatim. One pass over the lock, whatever the number of pins. +fn splice_blocks(text: &str, blocks: &[LockBlock], pinned: &[bool]) -> String { + let mut out = String::with_capacity(text.len() + 256 * pinned.len()); + let mut at = 0; + for (block, _) in blocks.iter().zip(pinned).filter(|(_, p)| **p) { + let eol = block_eol(text, block); + out.push_str(&text[at..block.start]); + out.push_str(&block.lines.join(eol)); + if block.terminated { + out.push_str(eol); + } + at = block.end; + } + out.push_str(&text[at..]); + out +} + +/// A classic block key's patterns and the one real package they all stand +/// for ([`classic_key_real_name`]). +fn classic_block_head(key: &str) -> (Vec, Option) { let patterns = split_key_patterns(key); - let mut names = patterns.iter().map(|p| pattern_real_name(p)); - let real_name = match names.next() { - Some(Some(first)) => names.all(|n| n == Some(first)).then(|| first.to_string()), - _ => None, - }; - Some((key.to_string(), real_name)) + let real_name = classic_key_real_name(&patterns).map(str::to_string); + (patterns, real_name) } // ── yarn.lock (berry / v2+) ────────────────────────────────────────────────── @@ -3848,8 +3962,6 @@ fn rewrite_yarn_berry_with_manifests( manifests: &BTreeMap, result: &mut RewriteResult, ) { - // Descriptors split with the classic grammar's `name@range` rule. - use crate::vendor::yarn_classic_lock::{split_berry_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; @@ -3860,18 +3972,9 @@ fn rewrite_yarn_berry_with_manifests( return; } - // Line endings (see [`preflight_yarn_berry_hosted`] for when yarn writes - // CRLF): a CRLF lock is rewritten LF-normalized (the `\n\n` block - // grammar never splits a `\r\n\r\n` file) and re-expanded, so every - // untouched byte round-trips and the ledger records the lock's on-disk - // CRLF fragments. A leading BOM rides outside the blocks; a mixed lock - // is refused by the preflight. - let (bom, body) = match raw.strip_prefix('\u{feff}') { - Some(rest) => ("\u{feff}", rest), - None => ("", raw.as_str()), - }; // Project-level gates (lock and manifest line endings, cacheKey, - // compressionLevel), shared with the vendored→hosted takeover preflight so a takeover never + // compressionLevel), shared with the vendored→hosted takeover preflight + // so a takeover never // reverts vendored wiring this rewriter then refuses. if let Err(warning) = preflight_yarn_berry_hosted( raw, @@ -3882,25 +3985,20 @@ fn rewrite_yarn_berry_with_manifests( // Nothing is verified, so nothing is confirmed — but a dep this lock // locks is still this rewriter's to decide: an earlier run's URL in // the lock must not confirm it through the text probe. - let lf = to_lf(body); for dep in &npm { - if berry_lock_locks(&lf, &full_name(dep), &dep.version) { + if berry_lock_locks(raw, &full_name(dep), &dep.version) { result.yarn_berry_uuids.insert(dep.patch_uuid.clone()); } } return; } - let eol = LineEndings::of(body); - let normalized = to_lf(body); - let content: &str = &normalized; - - // The trailing newline(s) ride outside the blocks, so an entry moved to - // its sorted position (see [`berry_reposition_blocks`]) never carries - // the file's final newline into the middle of the lock. - let trimmed = content.trim_end_matches('\n'); - let trailing_newlines = &content[trimmed.len()..]; - let mut blocks: Vec = trimmed.split("\n\n").map(String::from).collect(); - let was_sorted = berry_entries_sorted(&blocks); + // Line endings (see [`preflight_yarn_berry_hosted`] for when yarn writes + // CRLF), the BOM and the trailing newlines ride outside the stanzas, so + // every untouched byte round-trips and the edits record the lock's + // on-disk (CRLF) fragments; a mixed lock was refused above. + let mut doc = BerryStanzas::parse(raw); + let mut blocks = std::mem::take(&mut doc.stanzas); + let content: &str = &doc.lf; // The root manifest whose `resolutions` route each pinned descriptor to // the hosted tarball (see the section header). Parsed once; written back // in its own layout when a pin changes it. @@ -3927,10 +4025,8 @@ fn rewrite_yarn_berry_with_manifests( .yarn_berry10c0 .as_deref() .map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c)); - // Berry versions are UNQUOTED (` version: 1.3.0`). - let version_re = - Regex::new(&(String::from(r"\n {2}version: ") + ®ex::escape(&dep.version) + "\n")) - .expect("version regex from the escaped version is valid"); + let locks_version = + |stanza: &str| berry_stanza_field(stanza, "version") == Some(dep.version.as_str()); let mut matched_any = false; let mut alias_skipped = false; // Every entry this dep's pin would re-key: `(index, npm ranges)` — @@ -3942,15 +4038,10 @@ fn rewrite_yarn_berry_with_manifests( // shares the npm one, so re-keying the npm entry would break it. let mut shared_descriptor = false; for (block_idx, block) in blocks.iter().enumerate() { - // A block's key is its first line up to a trailing colon; skip - // header comment blocks and the leading `__metadata` block. - let Some(first_line) = block.lines().next() else { + // Skip header comment stanzas and the leading `__metadata` block. + let Some(raw_key) = stanza_key(block) else { continue; }; - if first_line.starts_with([' ', '\t', '#']) || !first_line.ends_with(':') { - continue; - } - let raw_key = &first_line[..first_line.len() - 1]; if raw_key == "__metadata" { continue; } @@ -3974,14 +4065,13 @@ fn rewrite_yarn_berry_with_manifests( // 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.expect("every pattern parsed — None-bearing keys are skipped above") - .1 - .strip_prefix("npm:") - .and_then(split_pattern) - .is_some_and(|(real, _)| real == fname) - }) + if parsed.iter().any(|p| { + p.expect("every pattern parsed — None-bearing keys are skipped above") + .1 + .strip_prefix("npm:") + .and_then(split_pattern) + .is_some_and(|(real, _)| real == fname) + }) && locks_version(block) { alias_skipped = true; result.warnings.push(RewriteWarning { @@ -4005,7 +4095,7 @@ fn rewrite_yarn_berry_with_manifests( }); continue; } - if !version_re.is_match(block) { + if !locks_version(block) { continue; } // Descriptor ranges carry a protocol; only an `npm:` range names @@ -4207,10 +4297,7 @@ fn rewrite_yarn_berry_with_manifests( // An entry keyed by its tarball URL (an earlier hosted run) recovers // its ranges from the manifest selectors routed to that URL. let current_url = if key_ranges.is_empty() { - block - .lines() - .next() - .and_then(|l| l.strip_suffix(':')) + stanza_key(&block) .map(|k| k.trim_matches('"')) .and_then(split_pattern) .map(|(_, r)| r.to_string()) @@ -4282,8 +4369,8 @@ fn rewrite_yarn_berry_with_manifests( kind: "redirect_yarn_berry_entry".into(), action: "rewritten".into(), key: Some(format!("{fname}@{}", dep.version)), - original: Some(Value::String(eol.restore(&block).into_owned())), - new: Some(Value::String(eol.restore(&rewritten).into_owned())), + original: Some(Value::String(doc.on_disk(&block).into_owned())), + new: Some(Value::String(doc.on_disk(&rewritten).into_owned())), }); blocks[target_idx] = rewritten; moved_keys.push(new_key); @@ -4294,11 +4381,10 @@ fn rewrite_yarn_berry_with_manifests( .insert(dep.patch_uuid.clone()); } if changed { - berry_reposition_blocks(&mut blocks, &moved_keys, was_sorted); - let out = format!("{}{trailing_newlines}", blocks.join("\n\n")); + doc.stanzas = blocks; result .files - .insert("yarn.lock".into(), format!("{bom}{}", eol.restore(&out))); + .insert("yarn.lock".into(), doc.render(&moved_keys)); } if manifest_changed { if let (Some(text), Some(value)) = (manifest_text, manifest.as_ref()) { @@ -4332,85 +4418,20 @@ fn rewrite_yarn_berry_with_manifests( } } -/// A berry lock block's sort key: its unquoted key, or `None` for header -/// comment blocks and `__metadata`. -fn berry_sort_key(block: &str) -> Option<&str> { - let first = block.lines().next()?; - if first.starts_with([' ', '\t', '#']) || !first.ends_with(':') { - return None; - } - let key = first[..first.len() - 1].trim_matches('"'); - (key != "__metadata").then_some(key) -} - -/// Whether a berry lock's entries are in yarn's key order (see -/// [`berry_reposition_blocks`]). -pub(crate) fn berry_entries_sorted(blocks: &[String]) -> bool { - let keys: Vec<&str> = blocks.iter().filter_map(|b| berry_sort_key(b)).collect(); - keys.windows(2).all(|w| w[0] <= w[1]) -} - -/// Whether an LF-normalized berry lock holds an entry for `name` at -/// `version`, under any descriptor (npm, tarball, `patch:`, …). -fn berry_lock_locks(content: &str, name: &str, version: &str) -> bool { - use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern}; - let version_line = format!("\n version: {version}\n"); - content.split("\n\n").any(|block| { - let Some(key) = block.lines().next().and_then(|l| l.strip_suffix(':')) else { - return false; - }; - if key.starts_with([' ', '\t', '#']) || key == "__metadata" { - return false; - } - format!("{block}\n").contains(&version_line) - && split_berry_key_patterns(key).iter().any(|p| { - split_pattern(p).is_some_and(|(n, range)| { - n == name && berry_npm_alias_target(range).is_none_or(|real| real == name) - }) - }) - }) -} - -/// The entries of the LF-normalized berry lock `content` that carry a -/// `bin:` map: the only ones whose pin needs the served tarball's own -/// package.json (#718). Split once per lock, so the per-dep check in -/// [`berry_pin_needs_manifest`] only walks these (usually none). -pub(crate) fn berry_bin_entries(content: &str) -> Vec<&str> { - content - .split("\n\n") - .filter(|block| block.contains("\n bin:")) - .collect() -} - /// Whether the berry hosted pin of `dep` would re-key one of `bin_entries` /// (see [`berry_bin_entries`]): an entry of the package version whose /// descriptors all name the package through a plain (non-fork) `npm:` /// range, or one an earlier hosted run keyed by its tarball URL. A fork /// alias or another protocol is never re-keyed, so never needs a fetch. -pub(crate) fn berry_pin_needs_manifest(bin_entries: &[&str], dep: &DepOverride) -> bool { - use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern}; - if bin_entries.is_empty() { - return false; - } +pub(crate) fn berry_pin_needs_manifest(bin_entries: &[LockBlock], dep: &DepOverride) -> bool { let name = full_name(dep); - let version_line = format!("\n version: {}", dep.version); bin_entries.iter().any(|block| { - let Some(key) = block.lines().next().and_then(|l| l.strip_suffix(':')) else { - return false; - }; - if key.starts_with([' ', '\t', '#']) || key == "__metadata" { - return false; - } - let has_version = block.match_indices(&version_line).any(|(at, _)| { - matches!( - block.as_bytes().get(at + version_line.len()), - None | Some(b'\n') - ) - }); - if !has_version { + if block.key == "__metadata" + || berry_field(&block.lines, "version") != Some(dep.version.as_str()) + { return false; } - let patterns = split_berry_key_patterns(key); + let patterns = split_berry_key_patterns(&block.key); !patterns.is_empty() && patterns.iter().all(|p| { split_pattern(p).is_some_and(|(n, range)| { @@ -4428,13 +4449,6 @@ pub(crate) fn berry_pin_needs_manifest(bin_entries: &[&str], dep: &DepOverride) }) } -/// The package an `npm:@` alias range installs (`None` for a -/// plain `npm:` or any other protocol). -fn berry_npm_alias_target(range: &str) -> Option<&str> { - let body = range.strip_prefix("npm:")?; - crate::vendor::yarn_classic_lock::split_pattern(body).map(|(real, _)| real) -} - /// The root manifest the yarn berry hosted pin edits. const BERRY_MANIFEST: &str = "package.json"; @@ -4556,7 +4570,7 @@ fn berry_resolutions_pin( current_url: Option<&str>, artifact_url: &str, ) -> Result { - use crate::vendor::yarn_berry_lock::resolution_selector_target; + use crate::formats::yarn::patterns::resolution_selector_target; let empty = serde_json::Map::new(); let table = match manifest.get("resolutions") { None => &empty, @@ -4611,7 +4625,7 @@ fn berry_resolutions_pin( } let ranges: Vec<&str> = selectors .iter() - .filter_map(|selector| crate::vendor::yarn_classic_lock::split_pattern(selector)) + .filter_map(|selector| crate::formats::yarn::patterns::split_pattern(selector)) .filter(|(n, range)| *n == name && range.starts_with("npm:")) .map(|(_, range)| range) .collect(); @@ -4684,45 +4698,6 @@ fn berry_catalog_selectors(yarnrc: Option<&str>, name: &str, ranges: &[&str]) -> selectors } -/// Move each entry keyed `moved` to where yarn sorts it. Yarn writes lock -/// entries sorted by their (unquoted) key — `__metadata` first — so an entry -/// re-keyed from `name@npm:…` to `name@` can move past a sibling (e.g. -/// `name@npm:7.0.0` now sorts after `name@https://…`); a lock in any other -/// order is rewritten by yarn and fails `--immutable`. Header comment blocks -/// and `__metadata` keep their place; the moved entry is inserted before the -/// first entry whose key sorts after it. A lock that was not in yarn's -/// order before the edit (`was_sorted`, from [`berry_entries_sorted`]; a -/// hand-edited lock) keeps the entry in place, so a pin and its rollback -/// still round-trip byte-exactly. -pub(crate) fn berry_reposition_blocks( - blocks: &mut Vec, - moved: &[String], - was_sorted: bool, -) { - if !was_sorted { - return; - } - for key in moved { - let line = format!("{key}:"); - let Some(from) = blocks - .iter() - .position(|b| b.lines().next() == Some(line.as_str())) - else { - continue; - }; - let block = blocks.remove(from); - let Some(own) = berry_sort_key(&block).map(str::to_string) else { - blocks.insert(from, block); - continue; - }; - let to = blocks - .iter() - .position(|b| berry_sort_key(b).is_some_and(|k| k > own.as_str())) - .unwrap_or(blocks.len()); - blocks.insert(to, block); - } -} - // ── bun.lock (text lockfile) ───────────────────────────────────────────────── // A registry 4-tuple `["name@version", "", {deps}, "sha512-…"]` is // rewritten to a URL 3-tuple `["name@", {deps verbatim}, @@ -10553,6 +10528,117 @@ mod tests { ); } + /// E08: the classic rewrite splices the pinned block over its own bytes, + /// so a lock mixing CRLF and LF lines keeps every untouched line in its + /// own ending. The old normalize/re-expand round trip turned every LF + /// line of such a lock into CRLF. + #[test] + fn yarn_classic_mixed_crlf_lf_lock_keeps_untouched_lines() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + // The header and the decoy entry CRLF, the target entry LF. + let lock = classic_lock_two_entries().replacen('\n', "\r\n", 9); + assert!(lock.contains("integrity sha512-DECOYdecoy==\r\n\r\nleft-pad@^1.3.0:\n")); + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock.clone()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let want = lock.replace( + "resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#bbbb\"\n \ + integrity sha512-UPSTREAMupstream==", + "resolved \"http://p.test/lp.tgz\"\n integrity sha512-PATCHED==", + ); + assert_eq!(r.files["yarn.lock"], want); + } + + /// E08: the pinned `resolved` is written as plain text. The old regex + /// replacement read a `$` in the artifact URL as a capture group, so a + /// patch-server URL holding one corrupted the line. + #[test] + fn yarn_classic_artifact_url_dollar_is_literal() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/$1/$name/lp.tgz", + "sha512-PATCHED==", + ); + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!( + r.files["yarn.lock"].contains(" resolved \"http://p.test/$1/$name/lp.tgz\"\n"), + "{}", + r.files["yarn.lock"] + ); + } + + /// A block with no `resolved` line has no tarball to repoint: it stays + /// byte-identical. The old rewriter still swapped its `integrity` for + /// the patched sha512, which a registry re-resolve then fails. + #[test] + fn yarn_classic_unresolved_block_is_left_untouched() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let lock = "# yarn lockfile v1\n\n\nleft-pad@^1.3.0:\n version \"1.3.0\"\n \ + integrity sha512-UPSTREAM==\n"; + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock.to_string()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty(), "{:?}", r.files); + assert!(r.edits.is_empty(), "{:?}", r.edits); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, ["redirect_yarn_classic_unresolved_entry_skipped"]); + assert!( + r.warnings[0].detail.contains("yarn install"), + "{:?}", + r.warnings + ); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + } + + /// B16 upgrade state: an older release pinned a URL-keyed fork block to + /// this artifact. That copy installs Socket's build, so it is not + /// called unpatched nor kept out of the in-run VEX; it is named as a + /// legacy pin with the way back, and left byte-identical. + #[test] + fn yarn_classic_legacy_pin_on_a_non_registry_copy_is_named_not_unpatched() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + let lock = + "# yarn lockfile v1\n\n\n\"left-pad@https://host.test/fork/left-pad-1.3.0.tgz\":\n \ + version \"1.3.0\"\n resolved \"http://p.test/lp.tgz#ab\"\n \ + integrity sha512-PATCHED==\n"; + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), lock.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(), "{:?}", r.files); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, ["redirect_yarn_classic_non_registry_legacy_pin"]); + assert!( + !r.warnings[0].detail.contains("unpatched") + && r.warnings[0].detail.contains("version control"), + "{:?}", + r.warnings + ); + assert!(!r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + } + /// Bare carriage returns outside a CRLF pair make the normalize/expand /// round-trip lossy — the lock is refused untouched with a warning. #[test] @@ -10838,6 +10924,72 @@ mod tests { assert_eq!(r.warnings[0].code, "redirect_yarn_classic_entry_not_found"); } + /// B16: a `file:` tarball, URL or hosted-git (codeload) copy is the + /// user's own artifact, not the registry package: the hosted pin would + /// swap it for Socket's patched registry bytes. It stays byte-identical, + /// named, and out of the in-run VEX, while the registry block beside it + /// is pinned. The old rewriter repointed all three. + #[test] + fn yarn_classic_non_registry_tarball_copies_are_skipped() { + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/lp.tgz", + "sha512-PATCHED==", + ); + 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"; + let copies = [ + "\"left-pad@file:./old/left-pad-1.3.0.tgz\":\n version \"1.3.0\"\n \ + resolved \"file:./old/left-pad-1.3.0.tgz#cccc\"\n", + "\"left-pad@https://host.test/fork/left-pad-1.3.0.tgz\":\n version \"1.3.0\"\n \ + resolved \"https://host.test/fork/left-pad-1.3.0.tgz\"\n integrity sha512-FORK==\n", + "left-pad@stevemao/left-pad#v1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba5\"\n", + ]; + for copy in copies { + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + format!("# yarn lockfile v1\n\n\n{registry_block}\n{copy}"), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{copy}: {:?}", r.edits); + let out = &r.files["yarn.lock"]; + assert!(out.contains("resolved \"http://p.test/lp.tgz\""), "{out}"); + assert!(out.ends_with(copy), "{copy}: copy byte-identical:\n{out}"); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_yarn_classic_non_registry_entry_skipped"], + "{copy}" + ); + assert!(r.bundled_skipped_uuids.contains(&ovr.patch_uuid), "{copy}"); + + // As the only copy: nothing is written, and the scan says why. + let mut files = BTreeMap::new(); + files.insert( + "yarn.lock".to_string(), + format!("# yarn lockfile v1\n\n\n{copy}"), + ); + 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(), + "{copy}: {:?}", + r.files + ); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_yarn_classic_non_registry_entry_skipped"], + "{copy}" + ); + } + } + /// #921: yarn 1 COPIES a `file:` directory dependency into /// node_modules, so its block (no `resolved`) has nothing to repoint. /// Beside a registry block, the registry block is wired and the copy is @@ -10974,7 +11126,8 @@ mod tests { .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. + // The codeload shorthand is a tarball to yarn, but not the registry + // package (B16): named as a non-registry copy, not as git. 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 \ @@ -10983,8 +11136,9 @@ mod tests { ); 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); + assert!(r.edits.is_empty(), "{:?}", r.edits); + let codes: Vec<&str> = r.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, ["redirect_yarn_classic_non_registry_entry_skipped"]); } /// The opposite alias direction — `"alias@npm:@…"` consuming the 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 40ff3e971..4b95c7c07 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -10,12 +10,10 @@ use std::collections::{BTreeMap, BTreeSet}; -use regex::Regex; use serde_json::Value; use super::client::NpmDist; use super::{Ctx, FormatResult, HostedPin, View}; -use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::lock_inventory::npm_legacy_identity; /// The pins by uuid. @@ -352,7 +350,7 @@ pub(crate) async fn restore_yarn_locks( let Some(raw) = read_or_refuse(view, rel, &pins, &mut result).await else { continue; }; - if super::super::is_berry_lock(&raw) { + if crate::formats::yarn::is_berry_lock(&raw) { restore_berry(view, rel, &raw, &pins, ctx, &mut result).await; } else { restore_classic(view, rel, &raw, &pins, ctx, &mut result).await; @@ -369,56 +367,74 @@ async fn restore_classic( ctx: &Ctx<'_>, result: &mut FormatResult, ) { - use crate::vendor::yarn_classic_lock::{classic_block_is_git, split_key_patterns}; + use crate::formats::yarn::blocks::{ + block_eol, classic_field, classic_line_endings_supported, repin_classic_block, + replace_block, scan_blocks, + }; + use crate::formats::yarn::patterns::{classic_key_real_name, split_key_patterns}; + use crate::formats::yarn::source::{classic_copy_source, CopySource}; - let eol = LineEndings::of(raw); - if eol == LineEndings::Mixed { - refuse_all_in(pins, rel, result, format!("{rel} mixes line endings")); + // The same byte splice as the hosted rewriter (see + // [`classic_line_endings_supported`]). + if !classic_line_endings_supported(raw) { + refuse_all_in( + pins, + rel, + result, + format!("{rel} holds bare carriage returns"), + ); return; } - let content = to_lf(raw); - let mut blocks: Vec = content.split("\n\n").map(String::from).collect(); - let resolved_re = - Regex::new(r#"\n {2}resolved "([^"]*)""#).expect("static resolved-line regex is valid"); - let integrity_re = - Regex::new(r"\n {2}integrity [^\n]*").expect("static integrity-line regex is valid"); - let version_re = - Regex::new(r#"\n {2}version "([^"]*)""#).expect("static version-line regex is valid"); + let blocks = scan_blocks(raw); // (block index, uuid, name, version) per hosted block. let mut hits: Vec<(usize, String, String, String)> = Vec::new(); for (i, block) in blocks.iter().enumerate() { - let Some(uuid) = resolved_re - .captures(block) - .and_then(|c| ctx.hosted_uuid(&c[1])) + let Some((resolved, uuid)) = + classic_field(&block.lines, "resolved").and_then(|r| Some((r, ctx.hosted_uuid(r)?))) else { continue; }; if !pins.contains_key(uuid.as_str()) { continue; } - let head = super::super::yarn_classic_block_head(block); + let patterns = split_key_patterns(&block.key); // 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; + match classic_copy_source(&patterns, Some(resolved)) { + CopySource::Git => { + result.refuse( + &uuid, + format!( + "the {rel} entry wiring it installs from git; a registry tarball there \ + would still be fetched with git" + ), + ); + continue; + } + // A pin an older release wrote on a `file:` tarball, URL or + // hosted-git copy (B16): its own `resolved` was never recorded, + // and the registry tarball is not what that copy installed. + CopySource::RemoteTarball => { + result.refuse( + &uuid, + format!( + "the {rel} entry wiring it is keyed by a non-registry source (a file: \ + tarball, URL or hosted-git dependency) whose original `resolved` was \ + not recorded — restore {rel} from version control" + ), + ); + continue; + } + _ => {} } - let name = head.and_then(|(_, n)| n); - let version = version_re.captures(block).map(|c| c[1].to_string()); + let name = classic_key_real_name(&patterns); + let version = classic_field(&block.lines, "version"); match (name, version) { - (Some(name), Some(version)) => hits.push((i, uuid, name, version)), + (Some(name), Some(version)) => { + hits.push((i, uuid, name.to_string(), version.to_string())) + } _ => result.refuse( &uuid, format!("a {rel} entry wiring it names no single package and version"), @@ -430,7 +446,9 @@ async fn restore_classic( .map(|(_, u, n, v)| (u.clone(), n.clone(), v.clone())) .collect(); let dists = fetch_dists(&wanted, ctx, result).await; - let mut changed = false; + // (block index, restored lines), spliced last-to-first below so every + // earlier block's byte span stays valid. + let mut splices: Vec<(usize, Vec)> = Vec::new(); for (i, uuid, name, version) in hits { if result.refused.contains_key(&uuid) { continue; @@ -450,25 +468,22 @@ async fn restore_classic( .as_deref() .map(|s| format!("#{s}")) .unwrap_or_default(); - let resolved = format!( - "\n resolved \"{}{frag}\"", - yarn_classic_tarball(dist).replace('$', "$$") - ); - let mut block = resolved_re - .replace(&blocks[i], resolved.as_str()) - .into_owned(); - if integrity_re.is_match(&block) { - block = integrity_re - .replace(&block, format!("\n integrity {integrity}").as_str()) - .into_owned(); - } - blocks[i] = block; + let resolved = format!("{}{frag}", yarn_classic_tarball(dist)); + splices.push(( + i, + repin_classic_block(&blocks[i].lines, &resolved, integrity), + )); result.handled.insert(uuid); - changed = true; } - if changed { - view.write(rel, eol.restore(&blocks.join("\n\n")).into_owned()); + if splices.is_empty() { + return; + } + let mut text = raw.to_string(); + for (i, lines) in splices.iter().rev() { + let block = &blocks[*i]; + text = replace_block(&text, block, lines, block_eol(raw, block)); } + view.write(rel, text); } /// Whether a package manager derives `tarball` for `name@version` itself, @@ -551,13 +566,12 @@ async fn restore_berry( ctx: &Ctx<'_>, result: &mut FormatResult, ) { - use crate::vendor::yarn_berry_lock::resolution_selector_target; - use crate::vendor::yarn_classic_lock::{split_berry_key_patterns, split_pattern}; - - let (bom, body) = match raw.strip_prefix('\u{feff}') { - Some(rest) => ("\u{feff}", rest), - None => ("", raw), + use crate::formats::yarn::blocks::{berry_field, with_body_field}; + use crate::formats::yarn::patterns::{ + resolution_selector_target, split_berry_key_patterns, split_pattern, }; + use crate::formats::yarn::stanzas::{stanza_key, stanza_lines, BerryStanzas}; + let dir_prefix = match rel.rsplit_once('/') { Some((dir, _)) => format!("{dir}/"), None => String::new(), @@ -575,18 +589,9 @@ async fn restore_berry( refuse_all_in(pins, rel, result, w.detail); return; } - let eol = LineEndings::of(body); - let content = to_lf(body).into_owned(); - let trimmed = content.trim_end_matches('\n'); - let trailing_newlines = content[trimmed.len()..].to_string(); - let mut blocks: Vec = trimmed.split("\n\n").map(String::from).collect(); - let was_sorted = super::super::berry_entries_sorted(&blocks); - let resolution_re = Regex::new(r#"\n {2}resolution: "([^"]*)""#) - .expect("static resolution-line regex is valid"); - let checksum_re = - Regex::new(r"\n {2}checksum: [^\n]*").expect("static checksum-line regex is valid"); - let version_re = - Regex::new(r"\n {2}version: ([^\n]*)").expect("static version-line regex is valid"); + // The preflight refused a mixed lock, so the stanza view round-trips. + let mut doc = BerryStanzas::parse(raw); + let mut blocks = std::mem::take(&mut doc.stanzas); let mut pkg: Option = pkg_text .as_deref() @@ -606,7 +611,8 @@ async fn restore_berry( } let mut hits: Vec = Vec::new(); for (i, block) in blocks.iter().enumerate() { - let Some(resolution) = resolution_re.captures(block).map(|c| c[1].to_string()) else { + let lines = stanza_lines(block); + let Some(resolution) = berry_field(&lines, "resolution").map(str::to_string) else { continue; }; // The hosted pin is the tarball-URL locator `name@`; locks @@ -629,19 +635,13 @@ async fn restore_berry( if !pins.contains_key(uuid.as_str()) { continue; } - let key = block - .lines() - .next() - .and_then(|l| l.strip_suffix(':')) - .unwrap_or(""); + let key = stanza_key(block).unwrap_or(""); let patterns = split_berry_key_patterns(key); let names: BTreeSet = patterns .iter() .filter_map(|p| split_pattern(p).map(|(n, _)| n.to_string())) .collect(); - let version = version_re - .captures(block) - .map(|c| c[1].trim().trim_matches('"').to_string()); + let version = berry_field(&lines, "version").map(str::to_string); let (Some(name), Some(version), 1) = (names.iter().next(), version, names.len()) else { result.refuse( &uuid, @@ -743,7 +743,7 @@ async fn restore_berry( ) .await { - Ok(c) => crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(&content, &c), + Ok(c) => crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(&doc.lf, &c), Err(why) => { result.refuse(&uuid, format!("{name}@{version}: {why}")); continue; @@ -753,27 +753,23 @@ async fn restore_berry( continue; }; let resolution = format!( - "\n resolution: \"{}\"", + " resolution: \"{}\"", berry_registry_locator(project_registry.as_deref(), &name, &version, &dist.tarball) - ) - .replace('$', "$$"); - let mut block = resolution_re - .replace(&blocks[idx], resolution.as_str()) - .into_owned(); - if checksum_re.is_match(&block) { - block = checksum_re - .replace(&block, format!("\n checksum: {checksum}").as_str()) - .into_owned(); + ); + let mut lines = stanza_lines(&blocks[idx]); + if let Some(pinned) = with_body_field(&lines, "resolution", &resolution) { + lines = pinned; + } + if let Some(pinned) = + with_body_field(&lines, "checksum", &format!(" checksum: {checksum}")) + { + lines = pinned; } if let Some(key) = key { - let body_lines = block - .split_once('\n') - .map(|(_, r)| r.to_string()) - .unwrap_or_default(); - block = format!("{key}:\n{body_lines}"); + lines[0] = format!("{key}:"); moved.push(key); } - blocks[idx] = block; + blocks[idx] = lines.join("\n"); if !selectors.is_empty() { if let Some(table) = pkg .as_mut() @@ -818,9 +814,8 @@ async fn restore_berry( } } } - super::super::berry_reposition_blocks(&mut blocks, &moved, was_sorted); - let out = format!("{}{trailing_newlines}", blocks.join("\n\n")); - view.write(rel, format!("{bom}{}", eol.restore(&out))); + doc.stanzas = blocks; + view.write(rel, doc.render(&moved)); } // ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs index e0aeec0c6..2269ae52a 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs @@ -7,6 +7,7 @@ use std::path::Path; use crate::constants::npm_family::{BUN_LOCK, PNPM_SHRINKWRAP_LEGACY}; use crate::formats::governing_locks::{npm_governing_family, npm_lock_files, NpmLockFamily}; +use crate::formats::yarn::{grammar as yarn_grammar, YarnLockGrammar}; use crate::utils::purl::npm_purl; use crate::vendor::npm_flavor::NpmLockFlavor; @@ -198,21 +199,23 @@ pub(super) async fn inventory_live_sibling_lock_in( NpmLockFlavor::Bun, inventory_bun_binary_in(view).await.unwrap_or_default(), ), - // Classic vs berry is a content decision in the router. Rather than - // re-deriving that head sniff, try both readers: each yields entries - // only for its own grammar (classic's `version "…"` fields vs - // berry's `resolution:` lines), so a non-empty result is the sniff's - // answer. Berry PnP needs no carve-out: a PnP marker would have + // Classic vs berry is a content decision in the router. The router + // refused the lock because it declares neither grammar; the + // read-only fallback reads it the way yarn does, through the one + // grammar decision ([`yarn_grammar`]: a header-less lock is + // classic). Berry PnP needs no carve-out: a PnP marker would have // refused at the router's step 1 with a code this fallback ignores. NpmLockFamily::Yarn => { - let classic = inventory_yarn_classic_in(view).await.unwrap_or_default(); - if classic.is_empty() { - ( + let text = view.read_text("yarn.lock").await.unwrap_or_default(); + match yarn_grammar(&text) { + YarnLockGrammar::Berry => ( NpmLockFlavor::YarnBerry, inventory_yarn_berry_in(view).await.unwrap_or_default(), - ) - } else { - (NpmLockFlavor::YarnClassic, classic) + ), + YarnLockGrammar::Classic => ( + NpmLockFlavor::YarnClassic, + inventory_yarn_classic_in(view).await.unwrap_or_default(), + ), } } // `inventory_package_lock` itself prefers the shrinkwrap when both diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs index 83303ed86..55933228e 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -346,7 +346,7 @@ fn recover_npm_fragment( } if let Some(rest) = t.strip_prefix("resolved ") { let v = rest.trim().trim_matches('"'); - let (u, frag_sha1) = crate::vendor::yarn_classic_lock::split_resolved_sha1(v); + let (u, frag_sha1) = crate::formats::yarn::patterns::split_resolved_sha1(v); url = http_url(u); if frag_sha1.is_some() { sha1 = frag_sha1; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index cef50b43a..5912fffad 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -949,6 +949,66 @@ async fn yarn_classic_https_git_resolutions_carry_no_integrity() { ); } +/// B16: a `file:` tarball or URL fork copy is the user's own artifact, +/// not a registry tarball: its `resolved`, `#sha1` and `integrity` hash +/// something no registry serves, so the entry carries none of them (the +/// old view kept them as registry verifiers). The registry copy beside it +/// keeps both. +#[tokio::test] +async fn yarn_classic_file_tarball_and_url_forks_carry_no_registry_verifiers() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "yarn.lock", + "# yarn lockfile v1\n\n\ + \"url-fork@https://host.test/fork/url-fork-1.0.0.tgz\":\n\ + \x20 version \"1.0.0\"\n\ + \x20 resolved \"https://host.test/fork/url-fork-1.0.0.tgz#aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\ + \x20 integrity sha512-fork==\n\n\ + \"file-fork@file:./vendor/file-fork-2.0.0.tgz\":\n\ + \x20 version \"2.0.0\"\n\ + \x20 resolved \"file:./vendor/file-fork-2.0.0.tgz#bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\n\ + \x20 integrity sha512-local==\n\n\ + registry-pkg@^3.0.0:\n\ + \x20 version \"3.0.0\"\n\ + \x20 resolved \"https://registry.yarnpkg.com/registry-pkg/-/registry-pkg-3.0.0.tgz#dddddddddddddddddddddddddddddddddddddddd\"\n\ + \x20 integrity sha512-registry==\n", + ) + .await; + let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + for name in ["url-fork", "file-fork"] { + let e = entry(&entries, name); + assert_eq!(e.resolved, None, "{name}"); + assert_eq!(e.integrity, LockIntegrity::None, "{name}"); + } + assert_eq!( + entry(&entries, "registry-pkg").integrity, + LockIntegrity::Sri("sha512-registry==".into()) + ); +} + +/// A header-less classic lock (no `# yarn lockfile v1` line, no +/// `__metadata`) is refused by the flavor router; the read-only fallback +/// reads it the way yarn does — as classic — through the one grammar +/// decision, never as berry. +#[tokio::test] +async fn headerless_yarn_classic_lock_is_inventoried_as_classic_by_the_fallback() { + let tmp = tempfile::tempdir().unwrap(); + let headerless = YARN_CLASSIC + .lines() + .filter(|l| !l.starts_with('#')) + .collect::>() + .join("\n"); + assert!(!headerless.contains("lockfile v1"), "fixture drops the header"); + write(tmp.path(), "yarn.lock", &headerless).await; + let (flavor, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap(); + assert_eq!(flavor, NpmLockFlavor::YarnClassic); + assert_eq!( + entry(&entries, "left-pad").integrity, + LockIntegrity::Sri("sha512-XI5MPz==".into()) + ); +} + // ── yarn berry ──────────────────────────────────────────────────────── const YARN_BERRY: &str = "# This file is generated by running \"yarn install\" inside your project. diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index c3c21f8e9..bf2e64bf3 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -7,6 +7,7 @@ use toml_edit::{DocumentMut, Item}; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK}; use crate::formats::pnpm::PnpmLock; +use crate::formats::yarn::blocks::{berry_field, classic_field}; use crate::formats::yarn::is_berry_lock; use crate::utils::digest::is_sri_pin; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; @@ -15,8 +16,6 @@ use crate::utils::python_lock::{ }; use crate::vendor::bun_lock_text::{decode_json_string, split_name_spec}; use crate::vendor::bun_lockb::BunLockb; -use crate::vendor::yarn_berry_lock::berry_field; -use crate::vendor::yarn_classic_lock::classic_field; use crate::vex::discover::{vendor_ref, vendor_ref_decorated}; use super::bun::bun_text_entries; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs index 9a41f7401..dcf8b704e 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -4,12 +4,16 @@ #[cfg(test)] use std::path::Path; -use crate::utils::digest::is_hex; -use crate::vendor::yarn_berry_lock::{berry_field, parse_berry_locator, BerryLocator}; -use crate::vendor::yarn_classic_lock::{ - self, classic_field, live_blocks, scan_blocks, split_berry_key_patterns, split_key_patterns, - split_resolved_sha1, LockBlock, +use crate::formats::yarn::blocks::{ + berry_field, classic_field, live_blocks, scan_blocks, LockBlock, +}; +use crate::formats::yarn::patterns::{ + parse_berry_locator, pattern_real_name, split_berry_key_patterns, split_key_patterns, + split_pattern, split_resolved_sha1, BerryLocator, }; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; +use crate::utils::digest::is_hex; +use crate::vendor::yarn_classic_lock; use super::view::ProjectView; use super::{http_url, LockIntegrity, LockfileEntry}; @@ -77,7 +81,7 @@ pub(crate) struct BerryLock { /// discovery share (see [`classic_entries`]). pub(crate) fn berry_entries(text: &str) -> BerryLock { let blocks = scan_blocks(text); - let cache_key = crate::formats::yarn::berry_gates::cache_key(text).map(str::to_string); + let cache_key = crate::formats::yarn::berry_gates::cache_key(&blocks).map(str::to_string); let mut entries = yarn_entries(blocks, split_berry_key_patterns); entries.retain(|e| e.block.key != "__metadata"); BerryLock { cache_key, entries } @@ -125,24 +129,26 @@ fn classic_registry_view(text: &str) -> Vec { if yarn_classic_lock::block_points_into_vendor(&block.lines) { continue; } - let Some(name) = patterns - .first() - .and_then(|p| yarn_classic_lock::pattern_real_name(p)) - else { + let Some(name) = patterns.first().and_then(|p| pattern_real_name(p)) else { continue; }; let Some(version) = classic_field(&block.lines, "version") else { continue; }; // `resolved "url#sha1hex"` — the fragment is the legacy verifier of - // a registry tarball. A non-registry resolution (a git repository, - // over any protocol, or a local file) records hashes of an artifact - // no registry serves — a git fragment is a commit id — so neither it - // nor an `integrity` field verifies a registry fetch. + // a registry tarball. A non-registry copy (git over any protocol, a + // `file:` tarball, a URL or hosted-git tarball — the shared + // [`classic_copy_source`] rule the rewriters use) records hashes of + // an artifact no registry serves — a git fragment is a commit id — + // so neither it nor an `integrity` field verifies a registry fetch. let (resolved, sha1_hex, registry) = match classic_field(&block.lines, "resolved") { Some(raw) => { let (url, sha1) = split_resolved_sha1(raw); - match http_url(url).filter(|u| !is_git_resolution(raw, u)) { + let registry_copy = !matches!( + classic_copy_source(&patterns, Some(raw)), + CopySource::Git | CopySource::RemoteTarball + ); + match http_url(url).filter(|_| registry_copy) { Some(url) => (Some(url), sha1, true), None => (None, None, false), } @@ -159,18 +165,6 @@ fn classic_registry_view(text: &str) -> Vec { out } -/// Whether a classic `resolved` value names a git repository rather than a -/// registry tarball: a `git+`/`git:`/`github:`/`ssh:` spec, an http(s) URL -/// of a `.git` repository, or a GitHub codeload tarball of a commit. -fn is_git_resolution(raw: &str, url: &str) -> bool { - let path = url.split(['?', '#']).next().unwrap_or(url); - ["git+", "git:", "github:", "ssh:"] - .iter() - .any(|p| raw.starts_with(p)) - || path.ends_with(".git") - || path.contains("://codeload.github.com/") -} - #[cfg(test)] pub(super) async fn inventory_yarn_berry(root: &Path) -> Option> { inventory_yarn_berry_in(&ProjectView::Disk(root)).await @@ -197,7 +191,6 @@ fn berry_hosted_tarball_entry( version: &str, reference: &str, ) -> bool { - use crate::vendor::yarn_classic_lock::split_pattern; crate::patch::redirect::hosted_url::hosted_url_names(reference, name, version) && !entry.patterns.is_empty() && entry.patterns.iter().all(|p| { diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index ce4fc0078..5bd94ed76 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -328,10 +328,9 @@ async fn read_lock(view: &ProjectView<'_>, name: &str) -> Result) -> Result { let text = read_lock(view, "yarn.lock").await?; // Berry wins the check (it must never be mistaken for classic). The diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index 05f076e13..5e36eef4a 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -46,6 +46,10 @@ use sha2::{Digest, Sha256, Sha512}; use crate::constants::SOCKET_DIR; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; use crate::formats::yarn::berry_gates::{self, BerryGate, Yarnrc, SUPPORTED_CACHE_KEY}; +use crate::formats::yarn::blocks::{ + berry_field, block_eol, replace_block, scan_blocks, LockBlock, +}; +use crate::formats::yarn::patterns::{pattern_real_name, split_berry_key_patterns, split_pattern}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::{normalize_file_path, PatchSources}; use crate::utils::fs::{ @@ -67,9 +71,7 @@ use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::yarn_classic_lock::{ - block_eol, body_field_line, forget_block_scans, lines_to_json, pattern_real_name, - read_yarn_lock, replace_block, revert_recorded_block, scan_blocks, scan_blocks_shared, - split_berry_key_patterns, split_pattern, LockBlock, + forget_block_scans, lines_to_json, read_yarn_lock, revert_recorded_block, scan_blocks_shared, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -1329,23 +1331,6 @@ pub(crate) fn checksum_in_lock_spelling(lock_text: &str, checksum: &str) -> Stri } } -/// Read a berry scalar field (`: `, value possibly quoted). -pub(crate) fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { - for line in lines.iter().skip(1) { - let Some(rest) = body_field_line(line) else { - continue; - }; - let Some(value) = rest.strip_prefix(field) else { - continue; - }; - let Some(value) = value.strip_prefix(':') else { - continue; - }; - return Some(value.trim().trim_matches('"')); - } - None -} - /// The root workspace's name: the lock's single-pattern `@workspace:.` /// entry (the key + resolution of our file: entry embed it). fn root_workspace_name(blocks: &[LockBlock]) -> Option { @@ -1361,61 +1346,6 @@ fn root_workspace_name(blocks: &[LockBlock]) -> Option { None } -/// A berry `resolution:` locator `name@`, split at the first `@` -/// past a leading `@scope/` marker ([`split_pattern`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct BerryLocator<'a> { - pub(crate) name: &'a str, - pub(crate) reference: &'a str, -} - -impl<'a> BerryLocator<'a> { - /// `(version, bindings)` of a registry locator `npm:[::]` - /// (`bindings` is `""` without a `::`); `None` for any other protocol. - pub(crate) fn npm(&self) -> Option<(&'a str, &'a str)> { - let npm = self.reference.strip_prefix("npm:")?; - Some(npm.split_once("::").unwrap_or((npm, ""))) - } - - /// The `__archiveUrl=` binding of a registry locator (bindings are - /// `&`-joined), still percent-encoded — what hosted redirects up to 5.0 - /// wrote (and what yarn itself writes for a custom registry). - pub(crate) fn archive_url(&self) -> Option<&'a str> { - self.npm()? - .1 - .split('&') - .find_map(|b| b.strip_prefix("__archiveUrl=")) - } -} - -/// Parse a berry `resolution:` value into its locator. -pub(crate) fn parse_berry_locator(resolution: &str) -> Option> { - split_pattern(resolution).map(|(name, reference)| BerryLocator { name, reference }) -} - -/// The package a berry `resolutions` selector overrides: its LAST -/// descriptor's ident (`name`, `name@range`, `**/name`, `parent/name`, -/// `@scope/name`, `parent/@scope/name@range`), or `None` when it has none. -pub(crate) fn resolution_selector_target(selector: &str) -> Option<&str> { - let s = selector.trim(); - // The last descriptor starts after the last `/` that is not a scope's - // own separator (the segment before it starts with `@`). - let mut start = 0; - let bytes = s.as_bytes(); - let mut seg_start = 0; - for (i, &b) in bytes.iter().enumerate() { - if b == b'/' { - if !s[seg_start..i].starts_with('@') { - start = i + 1; - } - seg_start = i + 1; - } - } - let last = &s[start..]; - let name = split_pattern(last).map(|(n, _)| n).unwrap_or(last); - (!name.is_empty() && name != "**").then_some(name) -} - #[cfg(test)] mod tests { use super::*; @@ -4133,23 +4063,6 @@ __metadata: assert!(!lock_spells_bare_checksums(&nested)); } - #[test] - fn resolution_selector_targets() { - for (sel, want) in [ - ("left-pad", Some("left-pad")), - ("left-pad@npm:1.3.0", Some("left-pad")), - ("**/left-pad", Some("left-pad")), - ("parent/left-pad", Some("left-pad")), - ("@scope/pkg", Some("@scope/pkg")), - ("@p/parent/@scope/pkg@^2", Some("@scope/pkg")), - ("@scope/parent/left-pad", Some("left-pad")), - ("**", None), - ("", None), - ] { - assert_eq!(resolution_selector_target(sel), want, "{sel}"); - } - } - // ── download-plan pre-flight parity ─────────────────────────────────── /// `(the plan's verdict, the loop's own outcome)` for the fixture's 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 b49564873..5e650c3d4 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -27,6 +27,12 @@ use std::sync::Arc; use serde_json::Value; use crate::constants::SOCKET_DIR; +use crate::formats::yarn::blocks::{ + block_eol, body_field_line, classic_field, repin_classic_block, replace_block, scan_blocks, + LockBlock, +}; +use crate::formats::yarn::patterns::{classic_key_real_name, split_key_patterns}; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; @@ -284,7 +290,8 @@ fn refuse_berry_lock(text: &str) -> Result<(), Box> { /// [`vendor_yarn_classic`]'s step 3: classify every block of /// `name@version` and return the rewritable keys plus a named warning for -/// each copy that can't be rewired (link, `file:` directory, git). Refused +/// each copy that can't be rewired (link, `file:` directory, git, a +/// non-registry tarball). Refused /// when nothing is rewritable — as `vendor_lock_entry_not_rewritable`, /// naming the skipped copies, when the package IS locked but only through /// such copies (#857: `yarn install` can't help there) — or when a key sits @@ -317,6 +324,20 @@ fn rewritable_candidates( BlockClass::UnresolvedSkip(detail) => { skipped.push(VendorWarning::new("vendor_link_entry_skipped", detail)); } + BlockClass::RemoteSkip(detail) => { + unrewritable.push(detail.clone()); + skipped.push(VendorWarning::new( + "vendor_yarn_classic_non_registry_entry_skipped", + detail, + )); + } + BlockClass::LegacyWired(detail) => { + candidate_keys.push(block.key.clone()); + skipped.push(VendorWarning::new( + "vendor_yarn_classic_non_registry_legacy_wiring", + detail, + )); + } BlockClass::NoMatch => {} } } @@ -325,9 +346,9 @@ fn rewritable_candidates( return Err(Box::new(refused( "vendor_lock_entry_not_rewritable", format!( - "every {YARN_LOCK} block for {name}@{version} installs from git, a link or a \ - file: directory, which vendoring can't rewire — those copies stay UNPATCHED \ - and `yarn install` will not help: {}", + "every {YARN_LOCK} block for {name}@{version} installs from git, a link, a \ + file: directory or a non-registry tarball, which vendoring can't rewire — \ + those copies stay UNPATCHED and `yarn install` will not help: {}", details.join("; ") ), ))); @@ -698,19 +719,23 @@ enum BlockClass { /// Matches the target but has no `resolved` (a stale lock `yarn install` /// re-locks); carries the warning detail. UnresolvedSkip(String), + /// Matches the target but installs a non-registry tarball (B16); + /// carries the warning detail. + RemoteSkip(String), + /// A non-registry copy an older release already wired into + /// `.socket/vendor/` (B16 upgrade state): kept as a candidate so an + /// in-sync re-run stays a no-op, but named; carries the warning detail. + LegacyWired(String), NoMatch, } /// Does this block stand for `name@version`, and can it be rewired? fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> BlockClass { let patterns = split_key_patterns(&block.key); - if patterns.is_empty() { - return BlockClass::NoMatch; - } // Every key pattern must resolve to the target package's real name (an // `alias@npm:left-pad@^1.3.0` pattern carries the real name inside the // range — spike Y5's alias block). - if !patterns.iter().all(|p| pattern_real_name(p) == Some(name)) { + if classic_key_real_name(&patterns) != Some(name) { return BlockClass::NoMatch; } if classic_field(&block.lines, "version") != Some(version) { @@ -719,25 +744,46 @@ fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> Block // link: and file:-DIRECTORY ranges resolve from the working tree, not a // tarball — rewriting their resolved would not change what installs. let resolved = classic_field(&block.lines, "resolved"); - match classic_block_source(&patterns, resolved) { - ClassicBlockSource::Tarball => BlockClass::Candidate, - ClassicBlockSource::Link => BlockClass::LinkSkip(format!( + match classic_copy_source(&patterns, resolved) { + CopySource::Registry => BlockClass::Candidate, + // The vendored tarball is the patch service's build of the REGISTRY + // package (B16): wiring a fork, local build or hosted-git copy to it + // would swap the user's code for registry bytes. + // Checked before the copy-source refusal: a block whose `resolved` + // is already ours installs the vendored build, not the user's own + // artifact, and its original is in the ledger for `vendor --revert`. + CopySource::RemoteTarball if block_points_into_vendor(&block.lines) => { + BlockClass::LegacyWired(format!( + "lock block `{}` is a file: tarball, URL or hosted-git dependency that an \ + older release wired to the vendored registry build of {name}@{version}, so \ + it installs that build rather than your own; `socket-patch vendor --revert` \ + restores its original source", + block.key + )) + } + CopySource::RemoteTarball => BlockClass::RemoteSkip(format!( + "lock block `{}` installs from a tarball that is not the registry's (a \ + file: tarball, URL or hosted-git dependency), and the vendored artifact is \ + built from the registry package; skipped, so that copy stays unpatched", + block.key + )), + CopySource::Link => BlockClass::LinkSkip(format!( "lock block `{}` is a link: dependency; skipped", block.key )), - ClassicBlockSource::Directory => BlockClass::LinkSkip(format!( + CopySource::Directory => BlockClass::LinkSkip(format!( "lock block `{}` is a file: directory dependency; skipped, so that copy \ stays unpatched", block.key )), - ClassicBlockSource::Unresolved => BlockClass::UnresolvedSkip(format!( + CopySource::Unresolved => BlockClass::UnresolvedSkip(format!( "lock block `{}` has no resolved tarball; skipped", block.key )), // 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. - ClassicBlockSource::Git => BlockClass::GitSkip(format!( + CopySource::Git => 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 @@ -745,8 +791,8 @@ fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> Block } } -/// Rebuild a block's lines with the vendored `resolved`/`integrity` (adding -/// the integrity line when absent — yarn then enforces both hashes) and, +/// Rebuild a block's lines with the vendored `resolved`/`integrity` +/// ([`repin_classic_block`], the pin every classic writer shares) and, /// when the patch rewrote the package's own manifest, the recomputed /// dependency sub-maps. fn rewrite_classic_block( @@ -755,58 +801,41 @@ fn rewrite_classic_block( integrity_value: &str, staged_pkg: Option<&Value>, ) -> Vec { - let has_integrity = lines - .iter() - .skip(1) - .any(|l| body_field_line(l).is_some_and(|r| r.starts_with("integrity "))); - let mut out = vec![lines[0].clone()]; - let mut i = 1; - while i < lines.len() { - let line = &lines[i]; - if let Some(rest) = body_field_line(line) { - if rest.starts_with("resolved ") { - out.push(format!(" resolved \"{resolved_value}\"")); - if !has_integrity { - // yarn's field order: version, resolved, integrity, deps. - out.push(format!(" integrity {integrity_value}")); - } - i += 1; - continue; - } - if rest.starts_with("integrity ") { - out.push(format!(" integrity {integrity_value}")); - i += 1; - continue; - } - if staged_pkg.is_some() && (rest == "dependencies:" || rest == "optionalDependencies:") - { - // Drop the stale sub-map (header + 4-space entries); the - // recomputed ones are appended below in yarn's order. + let pinned = repin_classic_block(lines, resolved_value, integrity_value); + let Some(pkg) = staged_pkg else { + return pinned; + }; + let mut out = Vec::with_capacity(pinned.len()); + let mut i = 0; + while i < pinned.len() { + if i > 0 + && body_field_line(&pinned[i]) + .is_some_and(|r| r == "dependencies:" || r == "optionalDependencies:") + { + // Drop the stale sub-map (header + 4-space entries); the + // recomputed ones are appended below in yarn's order. + i += 1; + while i < pinned.len() && body_field_line(&pinned[i]).is_none() { i += 1; - while i < lines.len() && body_field_line(&lines[i]).is_none() { - i += 1; - } - continue; } + continue; } - out.push(line.clone()); + out.push(pinned[i].clone()); i += 1; } - if let Some(pkg) = staged_pkg { - for field in ["dependencies", "optionalDependencies"] { - let Some(map) = pkg.get(field).and_then(Value::as_object) else { - continue; - }; - if map.is_empty() { - continue; - } - out.push(format!(" {field}:")); - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort_unstable(); - for k in keys { - if let Some(range) = map.get(k).and_then(Value::as_str) { - out.push(format!(" {} \"{range}\"", quote_yarn_key(k))); - } + for field in ["dependencies", "optionalDependencies"] { + let Some(map) = pkg.get(field).and_then(Value::as_object) else { + continue; + }; + if map.is_empty() { + continue; + } + out.push(format!(" {field}:")); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_unstable(); + for k in keys { + if let Some(range) = map.get(k).and_then(Value::as_str) { + out.push(format!(" {} \"{range}\"", quote_yarn_key(k))); } } } @@ -821,12 +850,6 @@ pub(super) fn block_points_into_vendor(lines: &[String]) -> bool { .is_some_and(|p| p.eco == "npm") } -/// `file:` path → tarball or directory? Directories cannot be rewired. -fn is_tarball_path(path: &str) -> bool { - let path = path.split('#').next().unwrap_or(path).trim_end_matches('/'); - path.ends_with(".tgz") || path.ends_with(".tar.gz") -} - // ─────────────────── shared yarn-lock text helpers ─────────────────── // (pub(super): the berry backend reuses the same block grammar — key line at // column 0 ending `:`, indented body, blank-line separated) @@ -852,20 +875,6 @@ pub(super) async fn read_yarn_lock(project_root: &Path) -> Result, -} - /// The run's yarn-lock block scans. `scan_blocks` walks every line of the /// lock and copies each one into the block it belongs to, and BOTH yarn /// backends re-scan the whole lock for every patched package — plus once @@ -888,340 +897,6 @@ pub(super) fn forget_block_scans() { BLOCK_MEMO.invalidate(); } -/// Scan a lockfile into blocks, CRLF-aware. Comments, blank lines, and -/// anything else outside blocks are left to the splicer untouched. A -/// leading UTF-8 BOM is encoding, not text (yarn's parsers drop it): it is -/// stripped from the first line and kept OUT of that line's span, so a -/// header-less lock still yields its first key and a splice keeps the BOM. -pub(crate) fn scan_blocks(text: &str) -> Vec { - // (start, end-incl-terminator, content-without-terminator, terminated) - let mut lines: Vec<(usize, usize, &str, bool)> = Vec::new(); - let mut pos = 0; - for seg in text.split_inclusive('\n') { - let mut start = pos; - pos += seg.len(); - let terminated = seg.ends_with('\n'); - let mut content = seg; - if terminated { - content = &content[..content.len() - 1]; - } - let mut content = content.strip_suffix('\r').unwrap_or(content); - if start == 0 { - if let Some(rest) = content.strip_prefix('\u{feff}') { - start = '\u{feff}'.len_utf8(); - content = rest; - } - } - lines.push((start, pos, content, terminated)); - } - let mut blocks = Vec::new(); - let mut i = 0; - while i < lines.len() { - let (start, _, content, _) = lines[i]; - if is_key_line(content) { - let mut j = i + 1; - while j < lines.len() && is_body_line(lines[j].2) { - j += 1; - } - blocks.push(LockBlock { - start, - end: lines[j - 1].1, - terminated: lines[j - 1].3, - key: content[..content.len() - 1].to_string(), - lines: lines[i..j].iter().map(|l| l.2.to_string()).collect(), - }); - i = j; - } else { - i += 1; - } - } - blocks -} - -fn is_key_line(s: &str) -> bool { - !s.is_empty() && !s.starts_with([' ', '\t', '#']) && s.ends_with(':') -} - -fn is_body_line(s: &str) -> bool { - s.starts_with(' ') || s.starts_with('\t') -} - -/// The line terminator `block` is written in: its first line's (`\r\n` or -/// `\n`), else — a block that is one unterminated last line — the file's -/// dominant one ([`detect_eol`]). For a uniformly-ended lock this is the -/// file's own terminator; in a lock whose endings were mixed after the -/// fact it keeps a restored block in the style of the block it replaces. -pub(super) fn block_eol(text: &str, block: &LockBlock) -> &'static str { - let span = &text[block.start..block.end]; - match span.find('\n') { - Some(i) if span[..i].ends_with('\r') => "\r\n", - Some(_) => "\n", - None => detect_eol(text), - } -} - -/// Splice `new_lines` over `block`'s byte range, preserving every byte -/// outside it. -pub(super) fn replace_block( - text: &str, - block: &LockBlock, - new_lines: &[String], - eol: &str, -) -> String { - let mut replacement = new_lines.join(eol); - if block.terminated { - replacement.push_str(eol); - } - format!( - "{}{}{}", - &text[..block.start], - replacement, - &text[block.end..] - ) -} - -/// A 2-space body field line (`version "1.3.0"` / `resolution: "..."`), -/// returned without the indent; deeper sub-map lines return `None`. -pub(super) fn body_field_line(line: &str) -> Option<&str> { - let rest = line.strip_prefix(" ")?; - if rest.starts_with(' ') { - return None; - } - Some(rest) -} - -/// Read a classic scalar field (` ""`, integrity unquoted). -pub(crate) fn classic_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { - for line in lines.iter().skip(1) { - let Some(rest) = body_field_line(line) else { - continue; - }; - let Some(value) = rest.strip_prefix(field) else { - continue; - }; - let Some(value) = value.strip_prefix(' ') else { - continue; - }; - return Some(value.trim().trim_matches('"')); - } - None -} - -/// Split a comma-joined key into its patterns, honoring quoting; the -/// surrounding quotes are dropped from each pattern. -pub(crate) fn split_key_patterns(key: &str) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_quotes = false; - for ch in key.chars() { - match ch { - '"' => in_quotes = !in_quotes, - ',' if !in_quotes => { - let p = cur.trim(); - if !p.is_empty() { - out.push(p.to_string()); - } - cur.clear(); - } - _ => cur.push(ch), - } - } - let p = cur.trim(); - if !p.is_empty() { - out.push(p.to_string()); - } - out -} - -/// Split a berry lock key into its comma-joined descriptor patterns. yarn -/// wraps a multi-descriptor key in ONE outer quote pair (`"a@npm:^1, -/// a@npm:^2"`), so strip a single wrapping pair first, THEN split on `, ` — -/// that surfaces every descriptor (letting a genuinely mixed-name key be -/// detected as ambiguous) while a single quoted descriptor stays intact. -/// Twin of the TS `splitKeyPatterns`. The ONE berry key splitter: the -/// vendored and hosted berry backends and the lock inventory's -/// `berry_entries` (lockfile discovery's entry model) all read berry keys -/// with it — [`split_key_patterns`] is the classic grammar's, and treats -/// the outer pair as one quoted pattern. -pub(crate) fn split_berry_key_patterns(key: &str) -> Vec { - let trimmed = key.trim(); - let inner = if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') { - &trimmed[1..trimmed.len() - 1] - } else { - trimmed - }; - inner - .split(", ") - .map(str::trim) - .filter(|p| !p.is_empty()) - .map(str::to_string) - .collect() -} - -/// Split `name@range` at the first `@` past a leading `@scope/` marker. -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..]); - if name.is_empty() || range.is_empty() { - return None; - } - Some((name, range)) -} - -/// The real package a key pattern stands for: its name, unless the range is -/// an `npm:` alias — then the aliased target's name. -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) { - Some((real, _)) => Some(real), - None => Some(aliased), // `npm:left-pad` with no range - }; - } - Some(name) -} - -/// Where yarn 1 installs a lock block's copy from, as far as a lock -/// rewrite is concerned. Hosted, vendored and `vex` all classify a block of -/// the patched `name@version` through this one rule (#857, #921), so a copy -/// one of them cannot rewire is never silently counted as wired by another. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ClassicBlockSource { - /// A tarball `resolved` (registry, URL or `file:` tarball): rewritable. - Tarball, - /// A `link:` range: a symlink into the working tree. - Link, - /// A `file:` directory range: yarn COPIES the directory into - /// `node_modules`, so that copy keeps its own bytes. - Directory, - /// Fetched by yarn's git fetcher ([`classic_block_is_git`]). - Git, - /// Any other range with no `resolved`: not something yarn writes for a - /// locked package, so the lock is stale and `yarn install` re-locks it. - Unresolved, -} - -/// [`ClassicBlockSource`] of a block from its key patterns and `resolved`. -pub(crate) fn classic_block_source( - patterns: &[String], - resolved: Option<&str>, -) -> ClassicBlockSource { - for pattern in patterns { - let range = split_pattern(pattern).map(|(_, r)| r).unwrap_or(""); - if range.starts_with("link:") { - return ClassicBlockSource::Link; - } - if let Some(path) = range.strip_prefix("file:") { - if !is_tarball_path(path) { - return ClassicBlockSource::Directory; - } - } - } - if classic_block_is_git(patterns, resolved) { - return ClassicBlockSource::Git; - } - match resolved { - Some(_) => ClassicBlockSource::Tarball, - None => ClassicBlockSource::Unresolved, - } -} - -/// 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 -/// block with no patterns is never live. -pub(crate) fn live_blocks(patterns: &[Vec]) -> Vec { - let mut last: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); - for (i, pats) in patterns.iter().enumerate() { - for p in pats { - last.insert(p.as_str(), i); - } - } - patterns - .iter() - .enumerate() - .map(|(i, pats)| pats.iter().any(|p| last.get(p.as_str()) == Some(&i))) - .collect() -} - -/// A classic `resolved` value split at its first `#`: the url before it, -/// and the fragment as a lowercase sha1 when it is 40 hex digits (either -/// case) — the legacy tarball verifier yarn v1 enforces when no -/// `integrity` line is present. -pub(crate) fn split_resolved_sha1(raw: &str) -> (&str, Option) { - match raw.split_once('#') { - Some((url, frag)) => (url, crate::utils::digest::sha1_hex(frag)), - None => (raw, None), - } -} - /// yarn v1's lockfile key quoting (stringify.js `shouldWrapKey`): wrap when /// the key would not parse bare. fn quote_yarn_key(key: &str) -> String { @@ -1254,6 +929,7 @@ pub(super) fn json_to_lines(value: &Value) -> Option> { #[cfg(test)] mod tests { use super::*; + use crate::formats::yarn::patterns::pattern_real_name; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; use crate::patch::apply::{ApplyResult, VerifyStatus}; @@ -1809,6 +1485,47 @@ left-pad@^1.3.0: ); } + /// B16 upgrade state: an older release wired a URL-keyed fork block + /// into `.socket/vendor/`. That block is ours, so an in-sync re-run + /// stays a byte-stable no-op (not `vendor_lock_entry_not_rewritable`, + /// and never "stays UNPATCHED"), and the legacy wiring is named with + /// the way back. + #[tokio::test] + async fn legacy_wired_non_registry_copy_rerun_is_in_sync_and_named() { + let fx = fixture_with_lock(Y2_BEFORE).await; + expect_done(fx.vendor(false).await); + let wired = fx.lock_text().await; + let legacy = wired.replacen( + "left-pad@^1.3.0:", + "\"left-pad@https://host.test/fork/left-pad-1.3.0.tgz\":", + 1, + ); + assert_ne!(legacy, wired, "fixture re-keys the wired block"); + tokio::fs::write(fx.lock_path(), &legacy).await.unwrap(); + + let (result, entry, warnings) = expect_done(fx.vendor(false).await); + assert!(result.success, "{:?}", result.error); + assert!(entry.is_none(), "in sync: no new ledger entry"); + let codes: Vec<&str> = warnings + .iter() + .map(|w| w.code) + .filter(|&c| c != "vendor_prebuilt_downloaded") + .collect(); + assert_eq!(codes, ["vendor_yarn_classic_non_registry_legacy_wiring"]); + let detail = &warnings + .iter() + .find(|w| w.code == codes[0]) + .unwrap() + .detail; + assert!( + detail.contains("host.test/fork") + && detail.contains("vendor --revert") + && !detail.contains("UNPATCHED"), + "{detail}" + ); + assert_eq!(fx.lock_text().await, legacy, "lock byte-stable"); + } + #[tokio::test] async fn dry_run_writes_nothing() { let fx = fixture_with_lock(Y2_BEFORE).await; @@ -2610,12 +2327,12 @@ left-pad@^1.3.0: ); } - /// A `file:`-TARBALL range (unlike a `file:` directory) resolves from a - /// packable tarball, so it must fall through the LinkSkip gate and be - /// rewritten — a flipped `is_tarball_path` polarity would silently skip - /// real tarball deps. + /// B16: a `file:`-TARBALL range is the user's own artifact, not the + /// registry package the vendored tarball is built from, so it is never + /// wired to it: as the only copy it is refused as not rewritable, the + /// lock untouched. (It used to be rewired to the registry build.) #[tokio::test] - async fn file_tarball_range_block_is_rewritten_not_skipped() { + async fn file_tarball_range_only_copy_is_refused_untouched() { let lock = r#"# yarn lockfile v1 "left-pad@file:./old/left-pad-1.3.0.tgz": @@ -2623,35 +2340,13 @@ left-pad@^1.3.0: resolved "file:./old/left-pad-1.3.0.tgz#0123456789abcdef0123456789abcdef01234567" "#; let fx = fixture_with_lock(lock).await; - let (result, entry, warnings) = expect_done(fx.vendor(false).await); - assert!(result.success, "{:?}", result.error); + let detail = expect_refused(fx.vendor(false).await, "vendor_lock_entry_not_rewritable"); assert!( - !warnings - .iter() - .any(|w| w.code == "vendor_link_entry_skipped"), - "a file: TARBALL range is rewritable, not a link-skip: {warnings:?}" - ); - let entry = entry.expect("success carries a ledger entry"); - assert_eq!(entry.wiring.len(), 1); - assert_eq!( - entry.wiring[0].key.as_deref(), - Some("\"left-pad@file:./old/left-pad-1.3.0.tgz\""), - "verbatim quoted key line (no colon)" - ); - - let (sha1, sri) = fx.packed_hashes().await; - let text = fx.lock_text().await; - let lines: Vec<&str> = text.lines().collect(); - assert_eq!( - lines[4], - format!(" resolved \"file:./.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz#{sha1}\""), - "resolved repointed at the vendored tarball" - ); - assert_eq!( - lines[5], - format!(" integrity {sri}"), - "integrity line added so both hash checks are enforced" + detail.contains("left-pad@file:./old/left-pad-1.3.0.tgz") + && detail.contains("not the registry's"), + "{detail}" ); + assert_eq!(fx.lock_text().await, lock); } /// `--preserve-state` (`keep_artifact`): the wiring restore runs @@ -3247,63 +2942,6 @@ left-pad@^1.3.0: 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. @@ -3403,20 +3041,31 @@ left-pad@^1.3.0: assert!(fx.lock_text().await.contains(extra.trim_start())); } - /// #363 scope note: the hosted-git SHORTHAND locks to a codeload tarball - /// that yarn fetches as a tarball, so it stays rewritable. + /// B16: a hosted-git shorthand (locked to a codeload tarball) or URL + /// copy beside the registry block: the registry block is wired, and + /// each non-registry copy is named as staying unpatched, its block + /// byte-identical. #[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; + async fn non_registry_tarball_copies_beside_registry_are_skipped_with_warning() { + let extra = "\nleft-pad@stevemao/left-pad#v1.3.0:\n version \"1.3.0\"\n \ + resolved \"https://codeload.github.com/stevemao/left-pad/tar.gz/ff8e7ba5\"\n\ + \n\"left-pad@https://host.test/fork/left-pad-1.3.0.tgz\":\n version \"1.3.0\"\n \ + resolved \"https://host.test/fork/left-pad-1.3.0.tgz\"\n"; + 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, "{warnings:?}"); + assert_eq!(entry.unwrap().wiring.len(), 1, "only the registry block"); + let skipped: Vec<&VendorWarning> = warnings + .iter() + .filter(|w| w.code == "vendor_yarn_classic_non_registry_entry_skipped") + .collect(); + assert_eq!(skipped.len(), 2, "{warnings:?}"); + assert!( + skipped[0].detail.contains("stevemao/left-pad#v1.3.0"), + "{skipped:?}" + ); + assert!(skipped[1].detail.contains("host.test/fork"), "{skipped:?}"); + assert!(fx.lock_text().await.ends_with(extra)); } } diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index a9000bcfc..4b5c895a6 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -20,7 +20,7 @@ //! A block `name@range[, name@range2]:` with `version "X"` and //! `resolved ""`. The package is the REAL name of the key patterns //! (`alias@npm:real@range` names `real` — -//! [`crate::vendor::yarn_classic_lock::pattern_real_name`]); every pattern +//! [`crate::formats::yarn::patterns::pattern_real_name`]); every pattern //! must agree, otherwise a Socket-wired block is diagnosed (the rewriters //! refuse mixed keys). `link:` keys are skipped: yarn installs them from the //! working tree, never from `resolved`. @@ -89,16 +89,18 @@ use super::{ DiscoverCtx, Discovery, LocateOpts, PatchedRef, VendorRef, Wired, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; -use crate::patch::redirect::is_berry_lock; +use crate::formats::yarn::blocks::{berry_field, classic_field}; +use crate::formats::yarn::patterns::{ + classic_key_real_name, pattern_real_name, resolution_selector_target, split_resolved_sha1, + BerryLocator, +}; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; +use crate::formats::yarn::is_berry_lock; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::yarn::{ berry_checksum_pin, berry_entries, classic_entries, BerryLock, YarnEntry, }; 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_block_source, classic_field, pattern_real_name, split_resolved_sha1, ClassicBlockSource, -}; const YARN_LOCK: &str = "yarn.lock"; const PACKAGE_JSON: &str = "package.json"; @@ -147,16 +149,11 @@ fn extract_classic(ctx: &DiscoverCtx<'_>, entries: Vec, out: &mut Dis /// The purl a block stands for when every key pattern names one package. fn classic_block_purl(entry: &YarnEntry) -> Option { - let patterns = &entry.patterns; match ( - patterns.first().and_then(|p| pattern_real_name(p)), + classic_key_real_name(&entry.patterns), classic_field(&entry.block.lines, "version"), ) { - (Some(name), Some(version)) - if patterns.iter().all(|p| pattern_real_name(p) == Some(name)) => - { - npm_purl(name, version) - } + (Some(name), Some(version)) => npm_purl(name, version), _ => None, } } @@ -173,10 +170,10 @@ fn classic_block(ctx: &DiscoverCtx<'_>, entry: &YarnEntry, out: &mut Discovery) block, patterns, .. } = entry; let resolved = classic_field(&block.lines, "resolved"); - match classic_block_source(patterns, resolved) { + match classic_copy_source(patterns, resolved) { // yarn 1 fetches a git pattern with git, from `resolved` (#363): the // copy is the git bytes, whatever `resolved` names. - ClassicBlockSource::Git => { + CopySource::Git => { // 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( @@ -200,7 +197,7 @@ fn classic_block(ctx: &DiscoverCtx<'_>, entry: &YarnEntry, out: &mut Discovery) } // yarn 1 copies a `file:` directory into node_modules (#921): that // copy is the directory's bytes, and no `resolved` there is fetched. - ClassicBlockSource::Directory => { + CopySource::Directory => { out.unpatched_copy( YARN_LOCK, classic_block_purl(entry), @@ -211,8 +208,10 @@ fn classic_block(ctx: &DiscoverCtx<'_>, entry: &YarnEntry, out: &mut Discovery) return; } // `link:` ranges install from the working tree; `resolved` is inert. - ClassicBlockSource::Link | ClassicBlockSource::Unresolved => return, - ClassicBlockSource::Tarball => {} + CopySource::Link | CopySource::Unresolved => return, + // A hosted pin an older release wrote on a remote tarball copy + // replaced it with the registry artifact, so that copy IS Socket's. + CopySource::Registry | CopySource::RemoteTarball => {} } let Some(resolved) = resolved else { return; diff --git a/crates/socket-patch-core/tests/equivalence/yarn_classic_rewrite.golden b/crates/socket-patch-core/tests/equivalence/yarn_classic_rewrite.golden index 8205b61d0..3fa3b9b51 100644 --- a/crates/socket-patch-core/tests/equivalence/yarn_classic_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/yarn_classic_rewrite.golden @@ -1,202 +1,202 @@ # One seeded yarn.lock (v1) + overrides. # 0-1 1190d243c3c9a00e e8dc1c09e202ea26 -2-3 ba98bffaf480d822 3869a61f85e0e1b8 -4-5 d80b15a695b0937f 7d6b98165170e620 +2-3 ba98bffaf480d822 462668fcfc8da48c +4-5 d80b15a695b0937f f74e430dd31cb692 6-7 8e178c2cb5e2cb14 1fb27d735de4cbbe 8-9 e43fd997c62b9e42 7ad679dee1ec8d49 10-11 edc61bca32cfc9f9 53c9924e9d031970 12-13 175b8cae66ba4ad3 fd167be2cc4baa0f -14-15 ef91b060692096d2 adb4cb06017ef9fc -16-17 6f0831d9cf265f98 0ce72eed267ebc06 +14-15 ef91b060692096d2 60054b64244b4a06 +16-17 6f0831d9cf265f98 5863c757ec9c66cf 18-19 751e330a33334e51 612115cd88da46da -20-21 7bbcf7e2f9c9c88e ea5a9b414bbbb8bf -22-23 5e8538d25f0c3d2f b94672fd7806ae91 +20-21 7bbcf7e2f9c9c88e 32c8de817bbb68a8 +22-23 5e8538d25f0c3d2f c43a36d8396fed6b 24-25 1b52d945841c8298 fe67293582497cf7 26-27 85871c4064b2c3e3 1de8cff6d3df0db6 28-29 352dff2ab375c6a3 b99cfc831d7ee07a -30-31 edc5d9ff68527344 99f9347e2bc2b412 -32-33 ce2c969dad5ecb04 e72cd966fddda02c -34-35 de24932e90101396 2a589c4fbeb4d40d +30-31 edc5d9ff68527344 9b5489d8616a87d7 +32-33 ce2c969dad5ecb04 bf2748e4cae93ce8 +34-35 de24932e90101396 78823b01178c4d29 36-37 33a2e2444574429e 652c5bf017c28bdb 38-39 c57c275705d2a2a7 a5198a89e6282db9 -40-41 b5152432f665aeb8 53da7c91b1a67df9 +40-41 b5152432f665aeb8 1dc9268841f54dc9 42-43 eef444c636c55e21 7080fec0ca9bc4ad 44-45 5cd2da04fa3dac81 35ee0e3337c41a31 46-47 9dec65795e922fa4 adbd137d774d1f2b 48-49 1d9d1d776f1f7250 e56094c868ceb733 50-51 2d362a1b1674d398 e97b13ca8bbc60cb -52-53 be73a4ad6b8e5aee 284a1324e548f2ab -54-55 ba9646f86c624aaf ea7cf5752cc054aa -56-57 3e96a5f86e6af680 e6ac8694ea759078 +52-53 be73a4ad6b8e5aee 85fc1bac8aa8787b +54-55 ba9646f86c624aaf 1000b6f75857cc22 +56-57 3e96a5f86e6af680 b9d86d938087da55 58-59 8dcd4ce057935cc3 9e260b7c24d783df 60-61 6333a5219075a09d 9a223786aae324f4 62-63 f84be846c0d54615 fce13931283bad56 64-65 e6f8ad9ceedd2e4c def49b7bcdf8b86a 66-67 c4e236ff81ac9a8d 90885de7399544af -68-69 dadc04add7c7c9e0 60507aa4a3c8d08e +68-69 dadc04add7c7c9e0 1747157119d5b11e 70-71 a5711b822e995dfd 758907b08d749c04 72-73 c976cf46cd6b6f85 7ac909f471bf7bf9 74-75 18b9eec756bb47e3 9f12e86f7660befd -76-77 54ce49f58715780c 0479eadf8d02cb89 +76-77 54ce49f58715780c 49d0d6da2b082b7a 78-79 e8288b75119434a1 859bc3d7150208fd -80-81 94ff16825564c958 9a9162acc0d9a57b -82-83 bb940199daee8606 aaa4b0419b62b0ad -84-85 2ff605c25684cc72 fe0e6827fc7705da +80-81 94ff16825564c958 cd44f0d17cbef723 +82-83 bb940199daee8606 809887c8dc52b568 +84-85 2ff605c25684cc72 363e0bf5b728e6ee 86-87 157e638a5bba3a3c 1c649f5a07123fbf 88-89 f5d52d058b7378f8 4852e6e124420ac6 -90-91 c756f82012e55c10 cb1978d1fa63e951 +90-91 c756f82012e55c10 bd61a1a01854a986 92-93 7d2b44d28176a529 80b8d9bc47b04a3a -94-95 174fb249b68ff4ad df88da0ccd6e0553 -96-97 5dbedd5a89add533 46f9dd9d8bbcd3fc +94-95 174fb249b68ff4ad 1f97c07fd988e832 +96-97 5dbedd5a89add533 475f6acd20d72596 98-99 3078474883707efc 252802dcfc97d3ae -100-101 85bdf6d5adbbf56d 5777ad61dbb23d66 -102-103 baa7ffe0727796d9 9f845d084e712e91 -104-105 292106ad225e1037 8a8f51dd658ed10c +100-101 85bdf6d5adbbf56d 66e1f6cd9783ed1a +102-103 baa7ffe0727796d9 91120e8d58cb8056 +104-105 292106ad225e1037 b878a24eec363564 106-107 d08889126f5fe2a3 9d4aadc6ba4e7e2e 108-109 d7c8957c4b25e62a fc7752feb4e46245 110-111 3a71c785f0a34d60 19ef39a099202dc3 -112-113 cd9963e800c7f246 53a797787215d9d4 +112-113 cd9963e800c7f246 873028674465efe9 114-115 e2ec8f5f04db713d 2554ace2dcc93b15 -116-117 9028fc942e7550c3 01efc52f1d4ee70b -118-119 8ebdd92eeeef6f36 da62a103796737e9 +116-117 9028fc942e7550c3 0c43c41785fd0f9b +118-119 8ebdd92eeeef6f36 644ac9870b4a558d 120-121 92a4e961365a2f97 4cd92c06d45fef35 122-123 5482e22a9366a649 ed8406bf03a1046b 124-125 1791bc6454f83fd8 93a3bb7b27cf2ddd -126-127 3fc012d899a28130 e24509e78a17553b +126-127 3fc012d899a28130 ef648c3ada60396a 128-129 6d3b3fc00498115f c9a345287353c637 130-131 27f5cff8b7be114c 5e871bed9b4e9c98 132-133 caeabae83aeb4228 3d2716a58b333b48 -134-135 a376820bfb6ab010 89e0f11f493042c1 +134-135 a376820bfb6ab010 2fd7c05333693711 136-137 cfd0efc3c3db5202 a4274a2d1fad5840 -138-139 c587b2ef7d70c2aa 9d297739c36b0b80 -140-141 e09bae32f9966aca 15261be202c167a0 +138-139 c587b2ef7d70c2aa 97f2b37525bc85cc +140-141 e09bae32f9966aca 06ef69f7399cb88c 142-143 60ce0d4f6fa6794b 49565331a76e0c68 -144-145 410e854b4e2efd89 1adfef48bc413bd8 +144-145 410e854b4e2efd89 34dbfc1fa684eb27 146-147 fd493568c3ac3652 47ac1f33a38092c0 -148-149 deb618ecb555fd73 61b46c8c1debb3fe -150-151 d6f51b46647d1d15 fdd9261e4cd099b2 +148-149 deb618ecb555fd73 a728c7291d2cee98 +150-151 d6f51b46647d1d15 c379ad5a24211a5d 152-153 d0f497c3393351fc cdf20344f77fb72d -154-155 a5e2964501d49184 b0e8dfb0419b9ae3 +154-155 a5e2964501d49184 fd6b3228cdd8ed82 156-157 342978fadea628c9 e041f45858df4f0a 158-159 c068738f7de05532 48b7cbdae908784e -160-161 82f66cf7979cc1e3 aa028597bee05f68 -162-163 79bb0f03807f6b44 295c7736b5336291 +160-161 82f66cf7979cc1e3 412821bc28f037fe +162-163 79bb0f03807f6b44 e8f59cefb9b266c4 164-165 22981a3f179cfc9a 97689d6bbf14955e -166-167 0aa1b383329a8627 2f6e344ae5cdbc6c +166-167 0aa1b383329a8627 498dc7bf4214e559 168-169 3f162034aef1dee7 fa02bb7098abd57d 170-171 d270a58f50af5bc2 7f0255cf04696296 172-173 3de38dd2c44458ee 6e10d8da0eb9599f 174-175 a1224e468a2a3299 bd55e6c08af6d476 -176-177 2907e9afcb20dcd1 ad46822096f8e346 -178-179 33c22e07e587c29c e555bf01c4758766 +176-177 2907e9afcb20dcd1 be6367180453d478 +178-179 33c22e07e587c29c a3b705bfcecd38cb 180-181 e6f1f6e30a93629b 2c5dbad8dbee8761 182-183 aaa04fba9f217938 8618a7b9108deb7d -184-185 f74901f680aa0406 0e06140ab6510bdf +184-185 f74901f680aa0406 fce4db7397ff6831 186-187 57f8170e481abfa8 7710e41cc383d389 -188-189 2e20a5ea93aa89f6 c77c404f92ce6c4b -190-191 3f828aa93ae947b7 0d053b599bb56554 +188-189 2e20a5ea93aa89f6 de34b80c4c9ecf80 +190-191 3f828aa93ae947b7 b684772f130c4e7b 192-193 dd2bebf464535865 0717e0198c5e0e5e -194-195 5d92b24539551fa0 bdec007fb99201fe -196-197 507866064aec4111 49b056e43d6bdd14 +194-195 5d92b24539551fa0 66db9abc47d08fcf +196-197 507866064aec4111 a60c79c674ad30ae 198-199 17c0359ab8b2f84c afa62f7958d04aa1 -200-201 53f4743f0fae2db0 4d5e3ed6a85b5ab3 -202-203 6640c39887079e0f 61cf7d4629179860 +200-201 53f4743f0fae2db0 33bd7d870f50e1a0 +202-203 6640c39887079e0f 7e24971780376772 204-205 c08391b643d19e32 1b07d17000ef2731 -206-207 6b805f5639c7107c 7f20a40bc4e3b245 +206-207 6b805f5639c7107c 41b500b7989ce9a9 208-209 5c5bfc2ad062e253 e86681d615a21831 210-211 e041d12d9e34a7e4 6e07ace24940e58a 212-213 8162e7cdf8275290 eabc32e6e90af593 214-215 1de934fb8b35e04a 4464d026070c1392 216-217 410197c9dfc7c2f2 182d9bad1a3a1fcd -218-219 d739a2f19922bb59 a4bd3eb4c4e69e18 -220-221 82e62cba865edff5 38d8f49d3fbf76d3 -222-223 87879277b6d6b27a 6e6823fdfe1b8664 +218-219 d739a2f19922bb59 728139f0e5060737 +220-221 82e62cba865edff5 75d0f607bc7f55da +222-223 87879277b6d6b27a 79f9502a4e455875 224-225 926079079c0ecb3f 5caeaeeb19f12ee2 -226-227 d7a6db3f2ad44ad0 b9491474b79285e1 -228-229 872bca09ed8ba084 90f67ba67e384abd -230-231 7d7bf22a0e9cb805 52b4c6b05ea94696 -232-233 be20fb9e96cb7c53 1f14c14eefd61f83 -234-235 acd2ff104a2ac96f 1223921bfe62a2b8 +226-227 d7a6db3f2ad44ad0 6da057b1baadd966 +228-229 872bca09ed8ba084 30337baa82fd71b8 +230-231 7d7bf22a0e9cb805 a6fa3b4a6040bb86 +232-233 be20fb9e96cb7c53 c3d5737b92d166dc +234-235 acd2ff104a2ac96f 895cadeaaa008c99 236-237 196b2da9f9488cde aaa4a32af1f0f003 238-239 afeb25d31570da3e 2de273da1e9fd774 240-241 ca7bab4e4dc37bdf 8bf52d2859163808 -242-243 0004a191c10ab26d ef342c38d53e55a9 -244-245 d29b7cf3240f3a55 0d7e27edbbb990d1 -246-247 756953a9e086f2d5 7f327d597a8763f8 -248-249 1f0d7cd899a8ef8c d5f144cf6a62f93e +242-243 0004a191c10ab26d 0d7eafc82bf7097e +244-245 d29b7cf3240f3a55 43f2a3301300e012 +246-247 756953a9e086f2d5 f6ff6c6ea51a6673 +248-249 1f0d7cd899a8ef8c 41e0ebe9ba5884c1 250-251 21c745b4215a4f2b fdcc68e1c5401a2f -252-253 6eb261d8c1405b0f 8e3682cf9479eb22 -254-255 1ddd81908edf76f1 63f4dad63dd645d7 -256-257 d9bbc8bfea3bad46 34140dbf8695bcbf -258-259 25a4e018fbb1645a 39bf6b563208c8c4 +252-253 6eb261d8c1405b0f 38fc7a68680f26bc +254-255 1ddd81908edf76f1 13e4867bb251a8ce +256-257 d9bbc8bfea3bad46 f0356551f5ff06ff +258-259 25a4e018fbb1645a 2a6e03f3e9266729 260-261 62e250bc92ffe414 edef78c7b7eaedf5 262-263 e784b7716f83965a b390cf8ddcc0e2bc -264-265 e458082ea4c109ad 9cc761805a766424 +264-265 e458082ea4c109ad 8e78e6bd7ea93e6f 266-267 cac892ea348b5ecc 6afe645267922bb7 268-269 85dd46c8a49a55ef aad62d95630c7d9e -270-271 506bc333993d7c90 170bb06e956b20aa -272-273 e97bb5a51749b02b ccef1212c7a4008d -274-275 292826999ded1d5e 7d3e52ba7bad54cd -276-277 d90823b57af9eda7 b9acb73685495232 +270-271 506bc333993d7c90 7e7137d926b2464d +272-273 e97bb5a51749b02b ef6ce98c980586bb +274-275 292826999ded1d5e 8c2c23beed816afb +276-277 d90823b57af9eda7 e350507d35fcbe48 278-279 ad19b90a41936767 f7e03678929d10b9 280-281 edafe6d07499b8e4 398bd84b1ca365cb 282-283 50064db2df0bd060 b8f3b508121835bd -284-285 c8ee70bf288a3dcd cb1a580793aa0e46 -286-287 04dfa6be66fb6d83 8861a26b49603cef -288-289 514eb6ba7feedc57 b042339412932249 +284-285 c8ee70bf288a3dcd 390056053ca0a303 +286-287 04dfa6be66fb6d83 80459154dc821aad +288-289 514eb6ba7feedc57 4b37958179ba4753 290-291 e8eaef431b35e2b3 1f289c9ce2cfacd2 -292-293 eb6dffaf52bd3be4 da8a5b2821d9cc30 -294-295 d904ba055623d9f8 c192f9eab5afc324 -296-297 1f2c741d7d420ce6 47f1b5477ce206b9 -298-299 c03582ccddb96cee 09276f6ba92aa176 -300-301 748a964dddc5b4dd 3fdc694c82dfe2cb +292-293 eb6dffaf52bd3be4 70ff58718f5ba2aa +294-295 d904ba055623d9f8 aff38e9152d3646c +296-297 1f2c741d7d420ce6 e0052ccf080709c6 +298-299 c03582ccddb96cee fd101649a3ae3776 +300-301 748a964dddc5b4dd 0214d8cc16358c6d 302-303 42d640238fbf8361 3dfa9851b74e68f4 -304-305 228ec73b8d5ae872 babbaea8499f2201 -306-307 1655281cb4d8c9ce b0341ed0bc387c7a -308-309 dd3eb82b3e77ec6f 4bb9115ee0710b4d -310-311 f811e22101402c2c df1b46fdcaa0ab81 -312-313 e44293fa25db1eaa 9093779bcb7d694e -314-315 1e14263df9269f95 42c43eadd5448d5e +304-305 228ec73b8d5ae872 5b1c242e99f743d8 +306-307 1655281cb4d8c9ce e7b0156702300412 +308-309 dd3eb82b3e77ec6f a34e37e47900b3ae +310-311 f811e22101402c2c 9e3eee7f95f190cb +312-313 e44293fa25db1eaa e59b9f4e5d385811 +314-315 1e14263df9269f95 7af91158b39e50a0 316-317 686729154bf30a49 3634962bd2e8c100 318-319 77a4f2ba9a0cf3df 26c5a8b2f0b809df 320-321 0330a69284ddd224 6744a6189354a042 322-323 441245a0d0613419 e1dcf138b89effe3 -324-325 27bfb24e0adaca59 3f0d70a5a920e61d -326-327 dfbfe9addb1ce656 d04a411ef047cac5 -328-329 8d28a268abb3b404 335e7a320f7f4e04 -330-331 48f7939e56400f0f 78144de0c0ce70a0 +324-325 27bfb24e0adaca59 10e53e29998b0621 +326-327 dfbfe9addb1ce656 48e02d2febcf3593 +328-329 8d28a268abb3b404 78af330619e8376f +330-331 48f7939e56400f0f daf78b85646bf8a9 332-333 581fc9a0c31802f0 9c86402aa689b375 334-335 b0350490b1ee0793 90b01dba37cd6e67 336-337 421a82f155863960 d3a8a82c2c57ed78 338-339 e1a31b3355f246bf 3859f4780498b2a7 340-341 4e99c984d6efa99b 35c48ff807ba1e57 -342-343 93cd9abc548829ff 6acade12c4e1dbda +342-343 93cd9abc548829ff 1d109ecb63aea187 344-345 4940857b43e7fce5 d2d578b99a6ee9eb -346-347 089a20d53499ef50 fc16c04ca675aa6b -348-349 d6cda592cf180789 dba43a02d257d9c3 -350-351 88cf16f5d1148288 f30e1b86ff002b10 +346-347 089a20d53499ef50 56b9ad97e643a786 +348-349 d6cda592cf180789 f221ece07493184e +350-351 88cf16f5d1148288 267d4c2db16acc9d 352-353 13fcaa8b580b87c8 bf6f349d67fbfb90 354-355 0c50455095172a40 fc276d10f198f125 356-357 4b370e3af0e4f194 14968653c3426a35 -358-359 7f1f14a0f8535a2f 2a1aad673965dcbc +358-359 7f1f14a0f8535a2f 46e4e4e09f9492af 360-361 a34fbcd774798fac 8f9a63a647a536c1 -362-363 e8deea23e015fbce 897bd9c60815a5b0 +362-363 e8deea23e015fbce 4d4ce8f0cfee9d1c 364-365 118298c4bd6929b9 4b3baf5be3e9ca86 366-367 a88c88ad0662add9 6da67d5894ca93e0 368-369 7e222e2654d0869d 067e679412065805 -370-371 192d435734b4b17f af6e29e5d26f2598 +370-371 192d435734b4b17f 9c4bf9d9ab7cadac 372-373 8b9b5c3c4a391ee0 1278c723844e9009 -374-375 30c7c3e962b54688 81c8538c61256b1a -376-377 58fecebbcdbd5a7e f639404251e3e17e -378-379 8347ff535fcf69f8 9b870e9074ecac7f +374-375 30c7c3e962b54688 a974738c309b67f1 +376-377 58fecebbcdbd5a7e bcebfd3ea5c01b6f +378-379 8347ff535fcf69f8 2437af1e6d04d99b 380-381 8c4209360acca12c 0e7873e2e3061475 382-383 8c93f53cc0d92461 253c644b42808762 384-385 19edb8803a2e153b e20810e72dbe25da -386-387 c70af0e28589e6cf e7a9a9e3d5c53358 -388-389 5602e24e0e9c95ae 81cfcfddccb90d15 +386-387 c70af0e28589e6cf 6a796c30e15fe621 +388-389 5602e24e0e9c95ae 1eecf75baae784a9 390-391 defead10329f9dda 99e8ae5f267a94f2 392-393 24b162373746f101 674f91f3d20953e0 394-395 449abdb26f8b082e b3b9e49e34865327 -396-397 bf06982a6266b8d1 cb51ba17099eac24 +396-397 bf06982a6266b8d1 6e8bcd57834345a2 398-399 4a73ec47d01832f8 4681f54698a20616 diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 714df8b63..4b0a11d80 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -139,15 +139,31 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. (`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. +- **yarn classic non-registry tarballs** — a `file:` tarball (`left-pad@file:./x.tgz`), + a URL (`left-pad@https://host/fork.tgz`) or a hosted-git shorthand (`owner/repo`, + `github:owner/repo`, which yarn locks to a GitHub codeload tarball) is the project's + own artifact, not the registry package the patch is built for. Hosted and vendored + modes both pin to Socket's build of the registry package, so they leave such an + entry untouched (`redirect_yarn_classic_non_registry_entry_skipped` / + `vendor_yarn_classic_non_registry_entry_skipped`) and that copy stays unpatched; + rollback refuses a hosted pin an older release wrote on one, since its original + `resolved` was never recorded. Such a copy an older release already pinned installs + Socket's build, not an unpatched one: a hosted re-run names it + (`redirect_yarn_classic_non_registry_legacy_pin`, restore `yarn.lock` from version + control to undo it), and a vendored re-run keeps the existing wiring in sync and names + it (`vendor_yarn_classic_non_registry_legacy_wiring`, undone by `vendor --revert`). +- **yarn classic entries with no `resolved`** — a registry entry with no `resolved` + line is a stale lock with no tarball to repoint; `yarn install` re-locks it from the + registry. Hosted mode leaves it untouched and names it + (`redirect_yarn_classic_unresolved_entry_skipped`); vendored mode skips it + (`vendor_link_entry_skipped`). - **yarn classic `file:` directory dependencies** — yarn 1 copies a `file:` directory (an entry with no `resolved` tarball) into `node_modules`, so no lock rewrite reaches that copy. Hosted and vendored modes leave the entry untouched (`redirect_yarn_classic_directory_skipped` / `vendor_link_entry_skipped`, naming it) and that copy stays unpatched; `vex` never attests the package from that lock while the - copy is there. When every entry of the package is such a copy (git, `file:` directory - or `link:`), vendoring refuses with `vendor_lock_entry_not_rewritable` naming them, + copy is there. When every entry of the package is such a copy (git, `file:` directory, + non-registry tarball or `link:`), vendoring refuses with `vendor_lock_entry_not_rewritable` naming them, since `yarn install` can't help. - **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