From 798235e4ce7c8e7e14f1d6bbe1c23ae645599c75 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:43:03 -0400 Subject: [PATCH 01/12] Move the yarn.lock block grammar into formats/yarn The block walk, field readers, key/descriptor/locator patterns and the classic copy-source classifier lived in the vendored backends (vendor::yarn_classic_lock, vendor::yarn_berry_lock), and the hosted rewriters, the lock inventory and VEX discovery imported them from there: the layering was inverted (E08). They now live in formats/yarn: - blocks.rs: LockBlock, scan_blocks, block_eol, replace_block, body_field_line, classic_field, berry_field, berry_metadata, live_blocks - patterns.rs: split_key_patterns, split_berry_key_patterns, split_pattern, pattern_real_name, split_resolved_sha1, BerryLocator, parse_berry_locator, resolution_selector_target - source.rs: ClassicBlockSource and the yarn 1 git classifier Every caller imports them from formats/yarn; the vendor copies are gone. Pure move: no behavior change, unit tests moved with their functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/blocks.rs | 182 ++++++++ .../socket-patch-core/src/formats/yarn/mod.rs | 23 +- .../src/formats/yarn/patterns.rs | 168 +++++++ .../src/formats/yarn/source.rs | 183 ++++++++ .../src/patch/redirect/mod.rs | 18 +- .../src/patch/redirect/upstream/npm.rs | 6 +- .../src/vendor/lock_inventory/recover.rs | 2 +- .../src/vendor/lock_inventory/wired.rs | 5 +- .../src/vendor/lock_inventory/yarn.rs | 19 +- .../src/vendor/yarn_berry_lock.rs | 97 +--- .../src/vendor/yarn_classic_lock.rs | 416 +----------------- .../src/vex/discover/yarn.rs | 11 +- 12 files changed, 588 insertions(+), 542 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/yarn/blocks.rs create mode 100644 crates/socket-patch-core/src/formats/yarn/patterns.rs create mode 100644 crates/socket-patch-core/src/formats/yarn/source.rs 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..b1013f7fc --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/blocks.rs @@ -0,0 +1,182 @@ +//! 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 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) +} + +/// 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 +} + +/// 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>(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 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") +} diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index e6fc97b2d..16a336c4d 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -1,15 +1,22 @@ -//! `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 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; use super::text::strip_bom; 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..2e0837019 --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/patterns.rs @@ -0,0 +1,168 @@ +//! 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) +} + +/// 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) +} + +#[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..5983752cd --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -0,0 +1,183 @@ +//! 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; + +/// 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, + } +} + +/// `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") + )); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 74892e9a6..786cefbb6 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3450,7 +3450,7 @@ fn rewrite_yarn_classic( overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::vendor::yarn_classic_lock::{split_key_patterns, split_pattern}; + use crate::formats::yarn::patterns::{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") { @@ -3547,7 +3547,7 @@ fn rewrite_yarn_classic( .lines() .find_map(|l| l.strip_prefix(" resolved ")) .map(|v| v.trim().trim_matches('"')); - use crate::vendor::yarn_classic_lock::{classic_block_source, ClassicBlockSource}; + use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; let source = classic_block_source(&patterns, resolved); // yarn 1 COPIES a `file:` directory (or a block with no // `resolved`) into node_modules (#921): there is no tarball to @@ -3705,7 +3705,7 @@ fn rewrite_yarn_classic( /// 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}; + use crate::formats::yarn::patterns::{pattern_real_name, split_key_patterns}; let key_line = block .lines() .find(|l| !l.is_empty() && !l.starts_with([' ', '\t', '#']))?; @@ -3847,7 +3847,7 @@ fn rewrite_yarn_berry_with_manifests( 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}; + use crate::formats::yarn::patterns::{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; @@ -4346,7 +4346,7 @@ pub(crate) fn berry_entries_sorted(blocks: &[String]) -> bool { /// 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}; + use crate::formats::yarn::patterns::{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 { @@ -4381,7 +4381,7 @@ pub(crate) fn berry_bin_entries(content: &str) -> Vec<&str> { /// 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}; + use crate::formats::yarn::patterns::{split_berry_key_patterns, split_pattern}; if bin_entries.is_empty() { return false; } @@ -4425,7 +4425,7 @@ pub(crate) fn berry_pin_needs_manifest(bin_entries: &[&str], dep: &DepOverride) /// 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) + crate::formats::yarn::patterns::split_pattern(body).map(|(real, _)| real) } /// The root manifest the yarn berry hosted pin edits. @@ -4546,7 +4546,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, @@ -4601,7 +4601,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(); 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..127fa8ea3 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -369,7 +369,7 @@ 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::{patterns::split_key_patterns, source::classic_block_is_git}; let eol = LineEndings::of(raw); if eol == LineEndings::Mixed { @@ -551,8 +551,8 @@ 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}; + use crate::formats::yarn::patterns::resolution_selector_target; + use crate::formats::yarn::patterns::{split_berry_key_patterns, split_pattern}; let (bom, body) = match raw.strip_prefix('\u{feff}') { Some(rest) => ("\u{feff}", rest), 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/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index 9ed45e49d..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,16 +7,15 @@ 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}; use crate::utils::python_lock::{ lock_artifact, lock_package_collection, package_artifacts, uv_source_location, }; -use crate::formats::yarn::is_berry_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..bdb1c7c20 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,15 @@ #[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, berry_metadata, 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::utils::digest::is_hex; +use crate::vendor::yarn_classic_lock; use super::view::ProjectView; use super::{http_url, LockIntegrity, LockfileEntry}; @@ -125,10 +128,7 @@ 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 { @@ -197,7 +197,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/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index f00df5b3b..4a9f4a61b 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, berry_metadata, 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}; @@ -1319,23 +1321,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 { @@ -1351,61 +1336,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::*; @@ -4098,23 +4028,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..df2efcaf4 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,11 @@ 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, replace_block, scan_blocks, LockBlock, +}; +use crate::formats::yarn::patterns::{pattern_real_name, split_key_patterns}; +use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; @@ -821,12 +826,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 +851,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 +873,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 { @@ -3247,63 +2898,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. diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 8ea1cc8c8..cdd151f46 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,17 @@ use super::{ DiscoverCtx, Discovery, LocateOpts, PatchedRef, VendorRef, Wired, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::yarn::blocks::{berry_field, classic_field}; +use crate::formats::yarn::patterns::{ + pattern_real_name, resolution_selector_target, split_resolved_sha1, BerryLocator, +}; +use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; use crate::patch::redirect::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"; From f8b5ffc858b90be0b24608c50a7734c048dc7613 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:35:01 -0400 Subject: [PATCH 02/12] Decide the yarn.lock grammar in one place The grammar of a yarn.lock was decided three ways (B60): the vendored router and the in-memory router sniffed only the first 30 lines, every hosted rewriter, the hosted restore, VEX and the classic vendored gate scanned the whole file for `__metadata:`, and the inventory fallback ran both readers and took whichever returned entries. A classic header above a hand-merged `__metadata:` key past line 30 was classic to vendor and berry to everything else. sniff_grammar now scans the whole file, is_berry_lock wraps it, and the new grammar() reads a header-less lock as classic (what yarn 1 parses it as). The vendored router still refuses a header-less lock, since it must know the grammar it writes; the inventory fallback reads it through grammar() instead of trying both readers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../socket-patch-core/src/formats/yarn/mod.rs | 70 ++++++++++++------- .../src/vendor/lock_inventory/npm_family.rs | 28 ++++---- .../src/vendor/npm_flavor.rs | 7 +- 3 files changed, 63 insertions(+), 42 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs index 16a336c4d..d08bdcb13 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -29,39 +29,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)] @@ -83,8 +86,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/vendor/lock_inventory/npm_family.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm_family.rs index 7255825d7..fcaeedc54 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 @@ -8,6 +8,7 @@ use std::path::Path; use crate::constants::npm_family::{ BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_SHRINKWRAP_LEGACY, VLT_LOCK, }; +use crate::formats::yarn::{grammar as yarn_grammar, YarnLockGrammar}; use crate::utils::purl::npm_purl; use crate::vendor::npm_flavor::NpmLockFlavor; @@ -197,20 +198,23 @@ pub(super) async fn inventory_live_sibling_lock_in( )); } // yarn.lock — router step 5, where classic vs berry is a content - // decision. 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 + // decision. 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. if view.exists("yarn.lock").await { - let classic = inventory_yarn_classic_in(view).await.unwrap_or_default(); - if !classic.is_empty() { - return Some((NpmLockFlavor::YarnClassic, classic)); - } - return Some(( - NpmLockFlavor::YarnBerry, - inventory_yarn_berry_in(view).await.unwrap_or_default(), - )); + let text = view.read_text("yarn.lock").await.unwrap_or_default(); + return Some(match yarn_grammar(&text) { + YarnLockGrammar::Berry => ( + NpmLockFlavor::YarnBerry, + inventory_yarn_berry_in(view).await.unwrap_or_default(), + ), + YarnLockGrammar::Classic => ( + NpmLockFlavor::YarnClassic, + inventory_yarn_classic_in(view).await.unwrap_or_default(), + ), + }); } // npm — router step 6 (`inventory_package_lock` itself prefers the // shrinkwrap when both exist, mirroring npm). diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 21fb4aa46..271e80575 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -312,10 +312,9 @@ async fn read_lock(project_root: &Path, name: &str) -> Result Result { let text = read_lock(project_root, "yarn.lock").await?; // Berry wins the check (it must never be mistaken for classic). The From f050c6609021e5968d2cae7cd95b2db0c3c32b92 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 11:35:17 -0400 Subject: [PATCH 03/12] Rebuild the hosted yarn writers on the shared lock grammar The hosted yarn rewriters and restorers kept their own block grammar: seven split("\n\n") sites (rewrite_yarn_classic, berry_cache_key, rewrite_yarn_berry_with_manifests, berry_lock_locks, berry_bin_entries, restore_classic, restore_berry) plus regex field edits (E08). - Classic rewrite and restore now walk the lock with scan_blocks and splice each pinned block over its own bytes (replace_block), through the new repin_classic_block that the vendored backend also uses. Every untouched byte round-trips, so a lock mixing CRLF and LF lines keeps each line's ending (the old normalize/re-expand turned the LF lines into CRLF); only a bare CR is refused, by rewrite and restore alike. - No regex replacement is left: a `$` in a patch-server URL was read as a capture group by the classic rewriter (restore escaped it, the rewriter did not). Replacements are plain line edits now. - A classic block with no `resolved` line is left untouched; the old rewriter still swapped its integrity for the patched sha512. - Berry rewrite and restore share formats/yarn/stanzas.rs (BOM, line endings, trailing newlines, sorted re-insertion), which replaces berry_sort_key, berry_entries_sorted and berry_reposition_blocks; berry_cache_key, berry_lock_locks and berry_bin_entries read blocks. - classic_key_real_name replaces yarn_classic_block_head and the vendored and VEX copies of the same "every pattern names one package" check. The yarn_classic_rewrite golden is re-blessed: an oracle run of the old rewriter over its 400 seeds differed only in the edit records' leading blank line and in the unresolved-block integrity swap above. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/blocks.rs | 113 ++++- .../socket-patch-core/src/formats/yarn/mod.rs | 3 + .../src/formats/yarn/patterns.rs | 9 + .../src/formats/yarn/stanzas.rs | 176 +++++++ crates/socket-patch-core/src/hosted/engine.rs | 3 +- .../src/patch/redirect/mod.rs | 451 ++++++++---------- .../src/patch/redirect/upstream/npm.rs | 154 +++--- .../src/vendor/yarn_classic_lock.rs | 92 ++-- .../src/vex/discover/yarn.rs | 12 +- .../equivalence/yarn_classic_rewrite.golden | 300 ++++++------ 10 files changed, 745 insertions(+), 568 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/yarn/stanzas.rs diff --git a/crates/socket-patch-core/src/formats/yarn/blocks.rs b/crates/socket-patch-core/src/formats/yarn/blocks.rs index b1013f7fc..45e804728 100644 --- a/crates/socket-patch-core/src/formats/yarn/blocks.rs +++ b/crates/socket-patch-core/src/formats/yarn/blocks.rs @@ -124,10 +124,68 @@ pub(crate) fn body_field_line(line: &str) -> Option<&str> { 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>(lines: &'a [String], field: &str) -> Option<&'a str> { +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) else { + let Some(rest) = body_field_line(line.as_ref()) else { continue; }; let Some(value) = rest.strip_prefix(field) else { @@ -160,9 +218,9 @@ pub(crate) fn live_blocks(patterns: &[Vec]) -> Vec { } /// Read a berry scalar field (`: `, value possibly quoted). -pub(crate) fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a str> { +pub(crate) fn berry_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) else { + let Some(rest) = body_field_line(line.as_ref()) else { continue; }; let Some(value) = rest.strip_prefix(field) else { @@ -180,3 +238,50 @@ pub(crate) fn berry_field<'a>(lines: &'a [String], field: &str) -> Option<&'a st pub(crate) fn berry_metadata(blocks: &[LockBlock]) -> Option<&LockBlock> { blocks.iter().find(|b| b.key == "__metadata") } + +#[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 d08bdcb13..5e6dba3fd 100644 --- a/crates/socket-patch-core/src/formats/yarn/mod.rs +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -5,6 +5,8 @@ //! * 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`, @@ -17,6 +19,7 @@ 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; diff --git a/crates/socket-patch-core/src/formats/yarn/patterns.rs b/crates/socket-patch-core/src/formats/yarn/patterns.rs index 2e0837019..f6a575620 100644 --- a/crates/socket-patch-core/src/formats/yarn/patterns.rs +++ b/crates/socket-patch-core/src/formats/yarn/patterns.rs @@ -79,6 +79,15 @@ pub(crate) fn pattern_real_name(pattern: &str) -> Option<&str> { 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 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..b8cd20cfb --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/stanzas.rs @@ -0,0 +1,176 @@ +//! 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); + } +} diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f0c1440b7..9f852ab86 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -851,8 +851,7 @@ pub fn yarn_berry_manifest_targets<'a>( 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::patch::redirect::berry_bin_entries(lock); if bin_entries.is_empty() { return Vec::new(); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 786cefbb6..fd66951c2 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,7 +25,7 @@ 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}; +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}; @@ -61,6 +61,15 @@ use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; +use crate::formats::yarn::blocks::{ + berry_field, berry_metadata, block_eol, classic_field, is_body_field, repin_classic_block, + replace_block, scan_blocks, LockBlock, +}; +use crate::formats::yarn::patterns::{ + classic_key_real_name, split_berry_key_patterns, split_key_patterns, split_pattern, +}; +use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; +use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; #[cfg(test)] mod pnpm_equivalence_tests; #[cfg(test)] @@ -3450,8 +3459,6 @@ fn rewrite_yarn_classic( overrides: &[DepOverride], result: &mut RewriteResult, ) { - use crate::formats::yarn::patterns::{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; @@ -3470,40 +3477,28 @@ fn rewrite_yarn_classic( files.get(npmrc::NPMRC_REL).map(String::as_str), ) .err(); - // CRLF locks (core.autocrlf Windows checkouts — yarn v1 parses them fine) - // are processed LF-normalized and re-expanded on output, so untouched - // lines round-trip byte-identically. Without this, `split("\n\n")` never - // 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 ([`replace_block`]), 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 mut text = raw.clone(); + let mut blocks = scan_blocks(&text); + // Each block's key patterns and the one real package they all stand + // for, computed once per block and redone only for a block this run + // rewrites — not re-split per block per dep. + let mut heads: Vec<(Vec, Option)> = + blocks.iter().map(|b| classic_block_head(&b.key)).collect(); let mut changed = false; let mut any_pinned = false; for dep in &npm { @@ -3515,40 +3510,27 @@ fn rewrite_yarn_classic( }); 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 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::formats::yarn::source::{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_block_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 — @@ -3567,6 +3549,11 @@ fn rewrite_yarn_classic( }); continue; } + // 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 == ClassicBlockSource::Git { copy_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); @@ -3614,47 +3601,26 @@ fn rewrite_yarn_classic( .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); 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(block.lines.join(eol))), + new: Some(Value::String(pinned.join(eol))), }); - *block = rewritten; - heads[i] = yarn_classic_block_head(block); + text = replace_block(&text, block, &pinned, eol); + blocks = scan_blocks(&text); + heads[i] = classic_block_head(&blocks[i].key); changed = true; } } @@ -3691,32 +3657,27 @@ fn rewrite_yarn_classic( } } 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(), text); } } -/// 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::formats::yarn::patterns::{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(':')?; +/// 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) +} + +/// 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')) } // ── yarn.lock (berry / v2+) ────────────────────────────────────────────────── @@ -3846,8 +3807,6 @@ fn rewrite_yarn_berry_with_manifests( manifests: &BTreeMap, result: &mut RewriteResult, ) { - // Descriptors split with the classic grammar's `name@range` rule. - use crate::formats::yarn::patterns::{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; @@ -3858,18 +3817,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, @@ -3880,25 +3830,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. @@ -3925,10 +3870,10 @@ 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| { + let lines: Vec<&str> = stanza.lines().collect(); + berry_field(&lines, "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)` — @@ -3940,15 +3885,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; } @@ -3972,7 +3912,7 @@ 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) + if locks_version(block) && parsed.iter().any(|p| { p.expect("every pattern parsed — None-bearing keys are skipped above") .1 @@ -4003,7 +3943,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 @@ -4205,10 +4145,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()) @@ -4275,8 +4212,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); @@ -4287,11 +4224,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()) { @@ -4325,38 +4261,13 @@ 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::formats::yarn::patterns::{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| { +/// Whether a berry lock holds an entry for `name` at `version`, under any +/// descriptor (npm, tarball, `patch:`, …). +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) }) @@ -4364,14 +4275,14 @@ fn berry_lock_locks(content: &str, name: &str, version: &str) -> bool { }) } -/// 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 +/// 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(content: &str) -> Vec<&str> { - content - .split("\n\n") - .filter(|block| block.contains("\n bin:")) +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() } @@ -4380,30 +4291,15 @@ pub(crate) fn berry_bin_entries(content: &str) -> Vec<&str> { /// 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::formats::yarn::patterns::{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)| { @@ -4425,7 +4321,7 @@ pub(crate) fn berry_pin_needs_manifest(bin_entries: &[&str], dep: &DepOverride) /// plain `npm:` or any other protocol). fn berry_npm_alias_target(range: &str) -> Option<&str> { let body = range.strip_prefix("npm:")?; - crate::formats::yarn::patterns::split_pattern(body).map(|(real, _)| real) + split_pattern(body).map(|(real, _)| real) } /// The root manifest the yarn berry hosted pin edits. @@ -4674,42 +4570,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}, @@ -10193,6 +10053,77 @@ 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); + } + /// Bare carriage returns outside a CRLF pair make the normalize/expand /// round-trip lossy — the lock is refused untouched with a warning. #[test] 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 127fa8ea3..bbef01560 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. @@ -369,42 +367,39 @@ async fn restore_classic( ctx: &Ctx<'_>, result: &mut FormatResult, ) { - use crate::formats::yarn::{patterns::split_key_patterns, source::classic_block_is_git}; + use crate::formats::yarn::blocks::{ + block_eol, classic_field, 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_block_is_git; - 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 + // [`super::super::classic_line_endings_supported`]). + if !super::super::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(uuid) = classic_field(&block.lines, "resolved").and_then(|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, @@ -415,10 +410,12 @@ async fn restore_classic( ); 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 +427,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 +449,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 +547,12 @@ async fn restore_berry( ctx: &Ctx<'_>, result: &mut FormatResult, ) { - use crate::formats::yarn::patterns::resolution_selector_target; - use crate::formats::yarn::patterns::{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 +570,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 +592,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 +616,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 +724,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 +734,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 +795,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/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index df2efcaf4..eb04b8e55 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -28,9 +28,10 @@ use serde_json::Value; use crate::constants::SOCKET_DIR; use crate::formats::yarn::blocks::{ - block_eol, body_field_line, classic_field, replace_block, scan_blocks, LockBlock, + block_eol, body_field_line, classic_field, repin_classic_block, replace_block, scan_blocks, + LockBlock, }; -use crate::formats::yarn::patterns::{pattern_real_name, split_key_patterns}; +use crate::formats::yarn::patterns::{classic_key_real_name, split_key_patterns}; use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; @@ -709,13 +710,10 @@ enum BlockClass { /// 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) { @@ -750,8 +748,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( @@ -760,58 +758,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))); } } } @@ -905,6 +886,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}; diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index cdd151f46..e2d5f1420 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -91,7 +91,8 @@ use super::{ }; use crate::formats::yarn::blocks::{berry_field, classic_field}; use crate::formats::yarn::patterns::{ - pattern_real_name, resolution_selector_target, split_resolved_sha1, BerryLocator, + classic_key_real_name, pattern_real_name, resolution_selector_target, split_resolved_sha1, + BerryLocator, }; use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; use crate::patch::redirect::is_berry_lock; @@ -148,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, } } 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..28b69b999 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 -6-7 8e178c2cb5e2cb14 1fb27d735de4cbbe -8-9 e43fd997c62b9e42 7ad679dee1ec8d49 -10-11 edc61bca32cfc9f9 53c9924e9d031970 -12-13 175b8cae66ba4ad3 fd167be2cc4baa0f -14-15 ef91b060692096d2 adb4cb06017ef9fc -16-17 6f0831d9cf265f98 0ce72eed267ebc06 -18-19 751e330a33334e51 612115cd88da46da +0-1 1190d243c3c9a00e 22dffa890a0391d0 +2-3 ba98bffaf480d822 1637b2b0163ebd27 +4-5 d80b15a695b0937f 07f35f113f6bcf1a +6-7 8e178c2cb5e2cb14 0edaa8f806cdf917 +8-9 e43fd997c62b9e42 b677a7150555d872 +10-11 edc61bca32cfc9f9 db8e85ac057ddb98 +12-13 175b8cae66ba4ad3 57be5e1d05513b32 +14-15 ef91b060692096d2 b9ccbeff91fa7d21 +16-17 6f0831d9cf265f98 7de7003ea24e8711 +18-19 751e330a33334e51 d8742198772a96c4 20-21 7bbcf7e2f9c9c88e ea5a9b414bbbb8bf -22-23 5e8538d25f0c3d2f b94672fd7806ae91 -24-25 1b52d945841c8298 fe67293582497cf7 -26-27 85871c4064b2c3e3 1de8cff6d3df0db6 -28-29 352dff2ab375c6a3 b99cfc831d7ee07a -30-31 edc5d9ff68527344 99f9347e2bc2b412 -32-33 ce2c969dad5ecb04 e72cd966fddda02c -34-35 de24932e90101396 2a589c4fbeb4d40d -36-37 33a2e2444574429e 652c5bf017c28bdb -38-39 c57c275705d2a2a7 a5198a89e6282db9 -40-41 b5152432f665aeb8 53da7c91b1a67df9 -42-43 eef444c636c55e21 7080fec0ca9bc4ad +22-23 5e8538d25f0c3d2f 44756329c843a486 +24-25 1b52d945841c8298 a6e7dab5f5861589 +26-27 85871c4064b2c3e3 2bac40e65537c083 +28-29 352dff2ab375c6a3 35af942f1bae74fe +30-31 edc5d9ff68527344 d5c6ed5e4b803d21 +32-33 ce2c969dad5ecb04 382527403260df4d +34-35 de24932e90101396 d419dc5acb620cc1 +36-37 33a2e2444574429e 1b79a683b3582492 +38-39 c57c275705d2a2a7 3d40a25decd791d7 +40-41 b5152432f665aeb8 e94a2abc32d82d3f +42-43 eef444c636c55e21 06e119412dcb4f9e 44-45 5cd2da04fa3dac81 35ee0e3337c41a31 -46-47 9dec65795e922fa4 adbd137d774d1f2b +46-47 9dec65795e922fa4 f4ea7497d11e2fbd 48-49 1d9d1d776f1f7250 e56094c868ceb733 -50-51 2d362a1b1674d398 e97b13ca8bbc60cb -52-53 be73a4ad6b8e5aee 284a1324e548f2ab -54-55 ba9646f86c624aaf ea7cf5752cc054aa -56-57 3e96a5f86e6af680 e6ac8694ea759078 +50-51 2d362a1b1674d398 8f35ff459ec958c5 +52-53 be73a4ad6b8e5aee 0d42c278b967b572 +54-55 ba9646f86c624aaf dd9c1d3f17460fe2 +56-57 3e96a5f86e6af680 d4141d7b4d3beb0a 58-59 8dcd4ce057935cc3 9e260b7c24d783df 60-61 6333a5219075a09d 9a223786aae324f4 -62-63 f84be846c0d54615 fce13931283bad56 -64-65 e6f8ad9ceedd2e4c def49b7bcdf8b86a -66-67 c4e236ff81ac9a8d 90885de7399544af +62-63 f84be846c0d54615 70ed01b2a4629cf1 +64-65 e6f8ad9ceedd2e4c 82447ff1313645d4 +66-67 c4e236ff81ac9a8d 1b4d6542cbab5bac 68-69 dadc04add7c7c9e0 60507aa4a3c8d08e -70-71 a5711b822e995dfd 758907b08d749c04 +70-71 a5711b822e995dfd e61e15252ad587e8 72-73 c976cf46cd6b6f85 7ac909f471bf7bf9 -74-75 18b9eec756bb47e3 9f12e86f7660befd -76-77 54ce49f58715780c 0479eadf8d02cb89 -78-79 e8288b75119434a1 859bc3d7150208fd +74-75 18b9eec756bb47e3 d383f147d01ed85b +76-77 54ce49f58715780c 5a88270822bc2c38 +78-79 e8288b75119434a1 583e4b89982f8088 80-81 94ff16825564c958 9a9162acc0d9a57b 82-83 bb940199daee8606 aaa4b0419b62b0ad -84-85 2ff605c25684cc72 fe0e6827fc7705da -86-87 157e638a5bba3a3c 1c649f5a07123fbf -88-89 f5d52d058b7378f8 4852e6e124420ac6 -90-91 c756f82012e55c10 cb1978d1fa63e951 +84-85 2ff605c25684cc72 9f65bc552fb22286 +86-87 157e638a5bba3a3c a26e29df7e70b2b4 +88-89 f5d52d058b7378f8 514fd8dbf2532ee5 +90-91 c756f82012e55c10 ddd5840e7a25274f 92-93 7d2b44d28176a529 80b8d9bc47b04a3a -94-95 174fb249b68ff4ad df88da0ccd6e0553 -96-97 5dbedd5a89add533 46f9dd9d8bbcd3fc -98-99 3078474883707efc 252802dcfc97d3ae +94-95 174fb249b68ff4ad 7cdae7c5323b6bcf +96-97 5dbedd5a89add533 ebced9574824bed6 +98-99 3078474883707efc 0baa1286bdd3fe6d 100-101 85bdf6d5adbbf56d 5777ad61dbb23d66 -102-103 baa7ffe0727796d9 9f845d084e712e91 -104-105 292106ad225e1037 8a8f51dd658ed10c -106-107 d08889126f5fe2a3 9d4aadc6ba4e7e2e +102-103 baa7ffe0727796d9 3823fde6fba2c52a +104-105 292106ad225e1037 05722b99a18c1d7e +106-107 d08889126f5fe2a3 1e620549fa883ec8 108-109 d7c8957c4b25e62a fc7752feb4e46245 -110-111 3a71c785f0a34d60 19ef39a099202dc3 -112-113 cd9963e800c7f246 53a797787215d9d4 -114-115 e2ec8f5f04db713d 2554ace2dcc93b15 -116-117 9028fc942e7550c3 01efc52f1d4ee70b -118-119 8ebdd92eeeef6f36 da62a103796737e9 +110-111 3a71c785f0a34d60 42ca6a2cc0ace023 +112-113 cd9963e800c7f246 ccbd97d6520838bb +114-115 e2ec8f5f04db713d f211610ecceb011a +116-117 9028fc942e7550c3 5748caa6b7aa6b3f +118-119 8ebdd92eeeef6f36 c76a23f566a0efe7 120-121 92a4e961365a2f97 4cd92c06d45fef35 -122-123 5482e22a9366a649 ed8406bf03a1046b -124-125 1791bc6454f83fd8 93a3bb7b27cf2ddd -126-127 3fc012d899a28130 e24509e78a17553b +122-123 5482e22a9366a649 8c5b3520c32a001b +124-125 1791bc6454f83fd8 8a5dbb09beeac49f +126-127 3fc012d899a28130 62a6779ca67dbaf6 128-129 6d3b3fc00498115f c9a345287353c637 -130-131 27f5cff8b7be114c 5e871bed9b4e9c98 -132-133 caeabae83aeb4228 3d2716a58b333b48 -134-135 a376820bfb6ab010 89e0f11f493042c1 -136-137 cfd0efc3c3db5202 a4274a2d1fad5840 -138-139 c587b2ef7d70c2aa 9d297739c36b0b80 -140-141 e09bae32f9966aca 15261be202c167a0 +130-131 27f5cff8b7be114c 97412dfebc9beb8a +132-133 caeabae83aeb4228 408fab245b6add8a +134-135 a376820bfb6ab010 adf8747385dd6a18 +136-137 cfd0efc3c3db5202 46c907229cbe31e0 +138-139 c587b2ef7d70c2aa 7f580f0f613f5edf +140-141 e09bae32f9966aca c2a92df95e420471 142-143 60ce0d4f6fa6794b 49565331a76e0c68 -144-145 410e854b4e2efd89 1adfef48bc413bd8 -146-147 fd493568c3ac3652 47ac1f33a38092c0 -148-149 deb618ecb555fd73 61b46c8c1debb3fe -150-151 d6f51b46647d1d15 fdd9261e4cd099b2 -152-153 d0f497c3393351fc cdf20344f77fb72d -154-155 a5e2964501d49184 b0e8dfb0419b9ae3 -156-157 342978fadea628c9 e041f45858df4f0a -158-159 c068738f7de05532 48b7cbdae908784e -160-161 82f66cf7979cc1e3 aa028597bee05f68 -162-163 79bb0f03807f6b44 295c7736b5336291 +144-145 410e854b4e2efd89 c4fa6060a911a6cc +146-147 fd493568c3ac3652 cd206ba5a489ae47 +148-149 deb618ecb555fd73 d84e8ab898fa1f90 +150-151 d6f51b46647d1d15 6ee305a89c02bf83 +152-153 d0f497c3393351fc 52a3197c36f0095e +154-155 a5e2964501d49184 5b5ff71bc76b201c +156-157 342978fadea628c9 53d1bc06e04046d5 +158-159 c068738f7de05532 6d8ca52aa655b104 +160-161 82f66cf7979cc1e3 5e910b61a00f98fa +162-163 79bb0f03807f6b44 d3f83c903695c717 164-165 22981a3f179cfc9a 97689d6bbf14955e 166-167 0aa1b383329a8627 2f6e344ae5cdbc6c 168-169 3f162034aef1dee7 fa02bb7098abd57d 170-171 d270a58f50af5bc2 7f0255cf04696296 -172-173 3de38dd2c44458ee 6e10d8da0eb9599f +172-173 3de38dd2c44458ee 5c3fd159d153dc15 174-175 a1224e468a2a3299 bd55e6c08af6d476 -176-177 2907e9afcb20dcd1 ad46822096f8e346 -178-179 33c22e07e587c29c e555bf01c4758766 -180-181 e6f1f6e30a93629b 2c5dbad8dbee8761 +176-177 2907e9afcb20dcd1 e364186a689d4fcc +178-179 33c22e07e587c29c 4b0ffb574f88244d +180-181 e6f1f6e30a93629b 4d59173de2524859 182-183 aaa04fba9f217938 8618a7b9108deb7d -184-185 f74901f680aa0406 0e06140ab6510bdf -186-187 57f8170e481abfa8 7710e41cc383d389 -188-189 2e20a5ea93aa89f6 c77c404f92ce6c4b -190-191 3f828aa93ae947b7 0d053b599bb56554 -192-193 dd2bebf464535865 0717e0198c5e0e5e -194-195 5d92b24539551fa0 bdec007fb99201fe -196-197 507866064aec4111 49b056e43d6bdd14 -198-199 17c0359ab8b2f84c afa62f7958d04aa1 -200-201 53f4743f0fae2db0 4d5e3ed6a85b5ab3 -202-203 6640c39887079e0f 61cf7d4629179860 -204-205 c08391b643d19e32 1b07d17000ef2731 -206-207 6b805f5639c7107c 7f20a40bc4e3b245 -208-209 5c5bfc2ad062e253 e86681d615a21831 -210-211 e041d12d9e34a7e4 6e07ace24940e58a +184-185 f74901f680aa0406 10faf558fb2c1a84 +186-187 57f8170e481abfa8 b4eac3283383819f +188-189 2e20a5ea93aa89f6 a637ea687754f9c8 +190-191 3f828aa93ae947b7 d07b665083e8e599 +192-193 dd2bebf464535865 e8dd3404dc0c72fb +194-195 5d92b24539551fa0 2ca5eea17239c8cb +196-197 507866064aec4111 548804bf1710122b +198-199 17c0359ab8b2f84c 25b8322e1e40f574 +200-201 53f4743f0fae2db0 00f8e98bbcc938c9 +202-203 6640c39887079e0f 5205e566b4c7ae93 +204-205 c08391b643d19e32 0bdd97a46b64aae4 +206-207 6b805f5639c7107c 889f10d9bf8d8207 +208-209 5c5bfc2ad062e253 5ebba49b54184f8f +210-211 e041d12d9e34a7e4 e360c960ad0924a0 212-213 8162e7cdf8275290 eabc32e6e90af593 -214-215 1de934fb8b35e04a 4464d026070c1392 +214-215 1de934fb8b35e04a bbfea515108c54f2 216-217 410197c9dfc7c2f2 182d9bad1a3a1fcd -218-219 d739a2f19922bb59 a4bd3eb4c4e69e18 -220-221 82e62cba865edff5 38d8f49d3fbf76d3 -222-223 87879277b6d6b27a 6e6823fdfe1b8664 +218-219 d739a2f19922bb59 d0e9ce24fb253a5d +220-221 82e62cba865edff5 7d2c27084234d7e8 +222-223 87879277b6d6b27a 346e0893bc9e29ad 224-225 926079079c0ecb3f 5caeaeeb19f12ee2 -226-227 d7a6db3f2ad44ad0 b9491474b79285e1 -228-229 872bca09ed8ba084 90f67ba67e384abd -230-231 7d7bf22a0e9cb805 52b4c6b05ea94696 -232-233 be20fb9e96cb7c53 1f14c14eefd61f83 +226-227 d7a6db3f2ad44ad0 8fb13d2514499fd6 +228-229 872bca09ed8ba084 e041c1e60894ec73 +230-231 7d7bf22a0e9cb805 5ef66ecdfd46d3bd +232-233 be20fb9e96cb7c53 bab6f22231144a65 234-235 acd2ff104a2ac96f 1223921bfe62a2b8 -236-237 196b2da9f9488cde aaa4a32af1f0f003 +236-237 196b2da9f9488cde 1460e6949d396dc4 238-239 afeb25d31570da3e 2de273da1e9fd774 -240-241 ca7bab4e4dc37bdf 8bf52d2859163808 -242-243 0004a191c10ab26d ef342c38d53e55a9 +240-241 ca7bab4e4dc37bdf 84bde59d0254e344 +242-243 0004a191c10ab26d d00e7d510eb482d2 244-245 d29b7cf3240f3a55 0d7e27edbbb990d1 -246-247 756953a9e086f2d5 7f327d597a8763f8 -248-249 1f0d7cd899a8ef8c d5f144cf6a62f93e +246-247 756953a9e086f2d5 c10ccd10a470d5e6 +248-249 1f0d7cd899a8ef8c 457754f025bb6ec9 250-251 21c745b4215a4f2b fdcc68e1c5401a2f -252-253 6eb261d8c1405b0f 8e3682cf9479eb22 -254-255 1ddd81908edf76f1 63f4dad63dd645d7 -256-257 d9bbc8bfea3bad46 34140dbf8695bcbf -258-259 25a4e018fbb1645a 39bf6b563208c8c4 -260-261 62e250bc92ffe414 edef78c7b7eaedf5 -262-263 e784b7716f83965a b390cf8ddcc0e2bc +252-253 6eb261d8c1405b0f 52549edbea33fae9 +254-255 1ddd81908edf76f1 82186c3eb9ae9b11 +256-257 d9bbc8bfea3bad46 f6b54e04b40c5088 +258-259 25a4e018fbb1645a 16ba9fdafe48bc01 +260-261 62e250bc92ffe414 a5034013ce8983db +262-263 e784b7716f83965a 149748b06e758f0c 264-265 e458082ea4c109ad 9cc761805a766424 266-267 cac892ea348b5ecc 6afe645267922bb7 268-269 85dd46c8a49a55ef aad62d95630c7d9e -270-271 506bc333993d7c90 170bb06e956b20aa -272-273 e97bb5a51749b02b ccef1212c7a4008d -274-275 292826999ded1d5e 7d3e52ba7bad54cd -276-277 d90823b57af9eda7 b9acb73685495232 -278-279 ad19b90a41936767 f7e03678929d10b9 -280-281 edafe6d07499b8e4 398bd84b1ca365cb -282-283 50064db2df0bd060 b8f3b508121835bd +270-271 506bc333993d7c90 59cd745d403495d7 +272-273 e97bb5a51749b02b 969c4d711c31eef1 +274-275 292826999ded1d5e 6474e08e91de490b +276-277 d90823b57af9eda7 0715a5a2b5285b02 +278-279 ad19b90a41936767 0da298f1df3bb98f +280-281 edafe6d07499b8e4 efffab60d772292b +282-283 50064db2df0bd060 21098a790e8a2dea 284-285 c8ee70bf288a3dcd cb1a580793aa0e46 286-287 04dfa6be66fb6d83 8861a26b49603cef -288-289 514eb6ba7feedc57 b042339412932249 +288-289 514eb6ba7feedc57 bb0cf5da35976da5 290-291 e8eaef431b35e2b3 1f289c9ce2cfacd2 -292-293 eb6dffaf52bd3be4 da8a5b2821d9cc30 -294-295 d904ba055623d9f8 c192f9eab5afc324 -296-297 1f2c741d7d420ce6 47f1b5477ce206b9 +292-293 eb6dffaf52bd3be4 dabb5268b982a34a +294-295 d904ba055623d9f8 020ee759e7df5be9 +296-297 1f2c741d7d420ce6 57c8eda636ebf001 298-299 c03582ccddb96cee 09276f6ba92aa176 -300-301 748a964dddc5b4dd 3fdc694c82dfe2cb +300-301 748a964dddc5b4dd baad5025206a6a82 302-303 42d640238fbf8361 3dfa9851b74e68f4 -304-305 228ec73b8d5ae872 babbaea8499f2201 -306-307 1655281cb4d8c9ce b0341ed0bc387c7a -308-309 dd3eb82b3e77ec6f 4bb9115ee0710b4d +304-305 228ec73b8d5ae872 849ad35d9540396a +306-307 1655281cb4d8c9ce 047db19f42c1a677 +308-309 dd3eb82b3e77ec6f 59171b651664e86d 310-311 f811e22101402c2c df1b46fdcaa0ab81 -312-313 e44293fa25db1eaa 9093779bcb7d694e -314-315 1e14263df9269f95 42c43eadd5448d5e -316-317 686729154bf30a49 3634962bd2e8c100 -318-319 77a4f2ba9a0cf3df 26c5a8b2f0b809df +312-313 e44293fa25db1eaa 72490364b70912f2 +314-315 1e14263df9269f95 9b50d38b0c42ce8e +316-317 686729154bf30a49 313e9d65febfbef7 +318-319 77a4f2ba9a0cf3df 57ce65426ebb70c1 320-321 0330a69284ddd224 6744a6189354a042 322-323 441245a0d0613419 e1dcf138b89effe3 -324-325 27bfb24e0adaca59 3f0d70a5a920e61d -326-327 dfbfe9addb1ce656 d04a411ef047cac5 -328-329 8d28a268abb3b404 335e7a320f7f4e04 -330-331 48f7939e56400f0f 78144de0c0ce70a0 -332-333 581fc9a0c31802f0 9c86402aa689b375 +324-325 27bfb24e0adaca59 b4c184025f0b014d +326-327 dfbfe9addb1ce656 24ab50dd44a0b001 +328-329 8d28a268abb3b404 52ef0b79d5e9ee37 +330-331 48f7939e56400f0f afec449918db4c80 +332-333 581fc9a0c31802f0 353eed8190a724f1 334-335 b0350490b1ee0793 90b01dba37cd6e67 -336-337 421a82f155863960 d3a8a82c2c57ed78 +336-337 421a82f155863960 9e95cc5c5c96acd7 338-339 e1a31b3355f246bf 3859f4780498b2a7 340-341 4e99c984d6efa99b 35c48ff807ba1e57 -342-343 93cd9abc548829ff 6acade12c4e1dbda +342-343 93cd9abc548829ff 8b7df85d2d8bb17b 344-345 4940857b43e7fce5 d2d578b99a6ee9eb -346-347 089a20d53499ef50 fc16c04ca675aa6b +346-347 089a20d53499ef50 c7a926374687410f 348-349 d6cda592cf180789 dba43a02d257d9c3 -350-351 88cf16f5d1148288 f30e1b86ff002b10 -352-353 13fcaa8b580b87c8 bf6f349d67fbfb90 -354-355 0c50455095172a40 fc276d10f198f125 +350-351 88cf16f5d1148288 5fe174ad32f3767b +352-353 13fcaa8b580b87c8 e8f6d0b7eecc920b +354-355 0c50455095172a40 498f5f2a376cd06f 356-357 4b370e3af0e4f194 14968653c3426a35 -358-359 7f1f14a0f8535a2f 2a1aad673965dcbc -360-361 a34fbcd774798fac 8f9a63a647a536c1 -362-363 e8deea23e015fbce 897bd9c60815a5b0 -364-365 118298c4bd6929b9 4b3baf5be3e9ca86 -366-367 a88c88ad0662add9 6da67d5894ca93e0 -368-369 7e222e2654d0869d 067e679412065805 -370-371 192d435734b4b17f af6e29e5d26f2598 +358-359 7f1f14a0f8535a2f 9d2404b64c99f0bc +360-361 a34fbcd774798fac 50269ad8a06ec82b +362-363 e8deea23e015fbce 19c8bfa283fbbdf9 +364-365 118298c4bd6929b9 183c41395d6d62e7 +366-367 a88c88ad0662add9 dc8dd3f8aa59fe56 +368-369 7e222e2654d0869d 4e51f1cdde66f823 +370-371 192d435734b4b17f c81f3b17df070d0d 372-373 8b9b5c3c4a391ee0 1278c723844e9009 -374-375 30c7c3e962b54688 81c8538c61256b1a -376-377 58fecebbcdbd5a7e f639404251e3e17e +374-375 30c7c3e962b54688 75a01e0ce81f0743 +376-377 58fecebbcdbd5a7e 922439ce7c7001a1 378-379 8347ff535fcf69f8 9b870e9074ecac7f -380-381 8c4209360acca12c 0e7873e2e3061475 +380-381 8c4209360acca12c 75976b7f092e1665 382-383 8c93f53cc0d92461 253c644b42808762 -384-385 19edb8803a2e153b e20810e72dbe25da -386-387 c70af0e28589e6cf e7a9a9e3d5c53358 -388-389 5602e24e0e9c95ae 81cfcfddccb90d15 -390-391 defead10329f9dda 99e8ae5f267a94f2 -392-393 24b162373746f101 674f91f3d20953e0 +384-385 19edb8803a2e153b 536f57f2de668c74 +386-387 c70af0e28589e6cf b475b38fb7327627 +388-389 5602e24e0e9c95ae 9dce58b656c551e9 +390-391 defead10329f9dda 3e987831a5f1b97c +392-393 24b162373746f101 c389ae752815f57f 394-395 449abdb26f8b082e b3b9e49e34865327 396-397 bf06982a6266b8d1 cb51ba17099eac24 -398-399 4a73ec47d01832f8 4681f54698a20616 +398-399 4a73ec47d01832f8 493dbe55b21dfe08 From 0fa987bd8cff10a0e635b1d3585022174a3d86ac Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:00:27 -0400 Subject: [PATCH 04/12] Never pin a non-registry yarn classic copy to the registry artifact A classic block keyed by a `file:` tarball, a URL or a hosted-git shorthand (locked to a GitHub codeload tarball) is the project's own artifact: a fork or a local build (B16). The hosted rewriter repointed its `resolved` at Socket's patched registry artifact, silently swapping the user's code for registry bytes, and rollback then wrote the registry tarball back, losing the original `resolved`. The vendored backend did the same with the service-built tarball, and the lock inventory called codeload copies git while the rewriters called them plain tarballs. formats/yarn/source.rs now holds one classifier, CopySource (was ClassicBlockSource), which splits the old Tarball case into Registry and RemoteTarball through the shared npm_spec_is_registry rule, with a policy table for every mode: - hosted rewrite skips a RemoteTarball copy, named (redirect_yarn_classic_non_registry_skipped), and keeps it out of the in-run VEX like the git and file: directory copies; - hosted restore refuses a pin an older release wrote on one; - vendored skips it (vendor_yarn_classic_non_registry_entry_skipped) and refuses with vendor_lock_entry_not_rewritable when it is the only copy; - the lock inventory drops its registry verifiers through the same rule, replacing its own is_git_resolution. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/source.rs | 123 +++++++++++++++--- .../src/patch/redirect/mod.rs | 91 ++++++++++++- .../src/patch/redirect/upstream/npm.rs | 40 ++++-- .../src/vendor/lock_inventory/yarn.rs | 28 ++-- .../src/vendor/yarn_classic_lock.rs | 119 +++++++++-------- .../src/vex/discover/yarn.rs | 14 +- docs/ecosystems.md | 15 ++- 7 files changed, 312 insertions(+), 118 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/source.rs b/crates/socket-patch-core/src/formats/yarn/source.rs index 5983752cd..859827ead 100644 --- a/crates/socket-patch-core/src/formats/yarn/source.rs +++ b/crates/socket-patch-core/src/formats/yarn/source.rs @@ -2,15 +2,37 @@ //! 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, 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. +/// 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 ClassicBlockSource { - /// A tarball `resolved` (registry, URL or `file:` tarball): rewritable. - Tarball, +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 @@ -23,31 +45,42 @@ pub(crate) enum ClassicBlockSource { Unresolved, } -/// [`ClassicBlockSource`] of a block from its key patterns and `resolved`. -pub(crate) fn classic_block_source( - patterns: &[String], - resolved: Option<&str>, -) -> ClassicBlockSource { +/// [`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 ClassicBlockSource::Link; + return CopySource::Link; } if let Some(path) = range.strip_prefix("file:") { if !is_tarball_path(path) { - return ClassicBlockSource::Directory; + return CopySource::Directory; } } } if classic_block_is_git(patterns, resolved) { - return ClassicBlockSource::Git; + return CopySource::Git; } - match resolved { - Some(_) => ClassicBlockSource::Tarball, - None => ClassicBlockSource::Unresolved, + 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 @@ -180,4 +213,58 @@ mod tests { 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/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index fd66951c2..ced8cced0 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -69,7 +69,7 @@ use crate::formats::yarn::patterns::{ classic_key_real_name, split_berry_key_patterns, split_key_patterns, split_pattern, }; use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; -use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; #[cfg(test)] mod pnpm_equivalence_tests; #[cfg(test)] @@ -3530,12 +3530,12 @@ fn rewrite_yarn_classic( } let key = &block.key; let resolved = classic_field(&block.lines, "resolved"); - let source = classic_block_source(patterns, 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 { @@ -3554,7 +3554,7 @@ fn rewrite_yarn_classic( // 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 == ClassicBlockSource::Git { + if source == CopySource::Git { copy_skipped = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { @@ -3568,6 +3568,27 @@ fn rewrite_yarn_classic( }); 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. + 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_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 reached only through `alias@npm:@range` // descriptors is left byte-identical (mirroring the berry // rewriter), but never silently: that copy keeps installing the @@ -10300,6 +10321,60 @@ 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_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_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 @@ -10436,7 +10511,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 \ @@ -10445,8 +10521,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_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 bbef01560..9efc3834c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -371,7 +371,7 @@ async fn restore_classic( block_eol, classic_field, 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_block_is_git; + use crate::formats::yarn::source::{classic_copy_source, CopySource}; // The same byte splice as the hosted rewriter (see // [`super::super::classic_line_endings_supported`]). @@ -389,7 +389,8 @@ async fn restore_classic( // (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) = classic_field(&block.lines, "resolved").and_then(|r| ctx.hosted_uuid(r)) + let Some((resolved, uuid)) = + classic_field(&block.lines, "resolved").and_then(|r| Some((r, ctx.hosted_uuid(r)?))) else { continue; }; @@ -400,15 +401,32 @@ async fn restore_classic( // 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. - 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 = classic_key_real_name(&patterns); let version = classic_field(&block.lines, "version"); 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 bdb1c7c20..037620816 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -11,6 +11,7 @@ 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; @@ -135,14 +136,19 @@ fn classic_registry_view(text: &str) -> Vec { 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 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 eb04b8e55..11c99d507 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -32,7 +32,7 @@ use crate::formats::yarn::blocks::{ LockBlock, }; use crate::formats::yarn::patterns::{classic_key_real_name, split_key_patterns}; -use crate::formats::yarn::source::{classic_block_source, ClassicBlockSource}; +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}; @@ -290,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 @@ -323,6 +324,13 @@ 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::NoMatch => {} } } @@ -331,9 +339,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("; ") ), ))); @@ -704,6 +712,9 @@ 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), NoMatch, } @@ -722,25 +733,34 @@ 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. + 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 @@ -2243,12 +2263,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": @@ -2256,35 +2276,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 @@ -2979,20 +2977,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 e2d5f1420..0a7ebec94 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -94,7 +94,7 @@ 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_block_source, ClassicBlockSource}; +use crate::formats::yarn::source::{classic_copy_source, CopySource}; use crate::patch::redirect::is_berry_lock; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::yarn::{ @@ -170,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( @@ -197,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), @@ -208,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/docs/ecosystems.md b/docs/ecosystems.md index b8347d15b..aab9408f9 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -134,15 +134,22 @@ 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_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. - **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 From 71211b28e811bac92b9d3a647c6462bf479a991b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:10:24 -0400 Subject: [PATCH 05/12] Test that rollback refuses a hosted pin on a non-registry yarn copy An older release could pin a URL-keyed yarn classic block (a fork tarball) to the hosted artifact. Rollback must refuse it rather than write the registry tarball under the fork's key, and still restore the registry pin beside it (B16). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/in_process_rollback_hosted.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 5091adb59..f9943eeb6 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -1158,6 +1158,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 // --------------------------------------------------------------------------- From 27884582b737fdc65c3c7d37909e63519dee6e96 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:01:41 -0400 Subject: [PATCH 06/12] Read the berry gate metadata through the shared block grammar #657 added formats/yarn/berry_gates.rs with its own __metadata and field reader (metadata_fields, scalar_field), a second copy of blocks::berry_metadata and blocks::berry_field. The gates now scan the lock with scan_blocks and read cacheKey with berry_metadata and berry_field, and the private readers are gone. berry_gates::cache_key takes the scanned blocks, so the lock inventory reads the key from the blocks it already has, and the hosted rewriter's own berry_cache_key copy (dropped in the rebase) is not brought back. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/berry_gates.rs | 41 ++++++------------- .../src/patch/redirect/mod.rs | 3 +- .../src/vendor/lock_inventory/yarn.rs | 4 +- .../src/vendor/yarn_berry_lock.rs | 2 +- 4 files changed, 17 insertions(+), 33 deletions(-) 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/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index ced8cced0..93c2a5bee 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -25,6 +25,7 @@ use serde_json::{json, Value}; use crate::formats::yarn::berry_gates::{self, Yarnrc}; use crate::utils::digest::is_hex64_lower; +#[cfg(test)] use crate::utils::line_endings::LineEndings; use crate::vendor::common::{parse_json_text, JsonLayout}; use crate::vendor::lock_inventory::npm_legacy_identity; @@ -62,7 +63,7 @@ use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; use crate::formats::yarn::blocks::{ - berry_field, berry_metadata, block_eol, classic_field, is_body_field, repin_classic_block, + berry_field, block_eol, classic_field, is_body_field, repin_classic_block, replace_block, scan_blocks, LockBlock, }; use crate::formats::yarn::patterns::{ 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 037620816..dcf8b704e 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/yarn.rs @@ -5,7 +5,7 @@ use std::path::Path; use crate::formats::yarn::blocks::{ - berry_field, berry_metadata, classic_field, live_blocks, scan_blocks, LockBlock, + 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, @@ -81,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 } 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 4a9f4a61b..b6f347e3a 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -47,7 +47,7 @@ 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, berry_metadata, block_eol, replace_block, scan_blocks, LockBlock, + 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; From 7ba6537b95f77d27e0ffc31027af48fafb53b743 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:19:09 -0400 Subject: [PATCH 07/12] Move the last yarn grammar helpers out of patch::redirect The bare-CR line-ending check, the berry lock-locks and bin-entry block scans and the berry npm alias-target parser still lived in patch/redirect/mod.rs, and VEX discovery and the hosted engine reached is_berry_lock through a re-export there. They now live in formats/yarn (blocks.rs and patterns.rs), every caller imports them from there, and the re-export is gone, so vex and the hosted engine no longer depend on patch::redirect for a grammar question. Pure move. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/blocks.rs | 37 ++++++++++++ .../src/formats/yarn/patterns.rs | 11 ++++ crates/socket-patch-core/src/hosted/engine.rs | 8 +-- .../src/patch/redirect/mod.rs | 56 +++---------------- .../src/patch/redirect/upstream/npm.rs | 9 +-- .../src/vex/discover/yarn.rs | 2 +- 6 files changed, 66 insertions(+), 57 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/blocks.rs b/crates/socket-patch-core/src/formats/yarn/blocks.rs index 45e804728..f055b338c 100644 --- a/crates/socket-patch-core/src/formats/yarn/blocks.rs +++ b/crates/socket-patch-core/src/formats/yarn/blocks.rs @@ -6,6 +6,7 @@ //! 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). @@ -239,6 +240,42 @@ 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::*; diff --git a/crates/socket-patch-core/src/formats/yarn/patterns.rs b/crates/socket-patch-core/src/formats/yarn/patterns.rs index f6a575620..35f052c79 100644 --- a/crates/socket-patch-core/src/formats/yarn/patterns.rs +++ b/crates/socket-patch-core/src/formats/yarn/patterns.rs @@ -154,6 +154,17 @@ pub(crate) fn resolution_selector_target(selector: &str) -> Option<&str> { (!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::*; diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9f852ab86..63ddd4359 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -464,7 +464,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 / @@ -534,7 +534,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; @@ -847,11 +847,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 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/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 93c2a5bee..bfa1bf66a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -55,22 +55,25 @@ use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::gem::gemfile; use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source}; use crate::formats::gem::lock_lists_direct_dependency; -pub(crate) use crate::formats::yarn::is_berry_lock; use crate::formats::cargo::hosted::CargoLockPlan; #[cfg(test)] use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; +#[cfg(test)] +use crate::formats::yarn::blocks::berry_bin_entries; use crate::formats::yarn::blocks::{ - berry_field, block_eol, classic_field, is_body_field, repin_classic_block, - replace_block, scan_blocks, LockBlock, + berry_field, berry_lock_locks, block_eol, classic_field, classic_line_endings_supported, + repin_classic_block, replace_block, scan_blocks, LockBlock, }; +use crate::formats::yarn::is_berry_lock; use crate::formats::yarn::patterns::{ - classic_key_real_name, split_berry_key_patterns, split_key_patterns, split_pattern, + berry_npm_alias_target, classic_key_real_name, split_berry_key_patterns, split_key_patterns, + split_pattern, }; -use crate::formats::yarn::stanzas::{stanza_key, BerryStanzas}; 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)] @@ -3691,17 +3694,6 @@ fn classic_block_head(key: &str) -> (Vec, Option) { (patterns, real_name) } -/// 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')) -} - // ── yarn.lock (berry / v2+) ────────────────────────────────────────────────── // Berry fetches each package from its lock entry's `resolution:` locator and // verifies the CONVERTED CACHE ZIP against the lock's `checksum:` (a @@ -4283,31 +4275,6 @@ fn rewrite_yarn_berry_with_manifests( } } -/// Whether a berry lock holds an entry for `name` at `version`, under any -/// descriptor (npm, tarball, `patch:`, …). -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() -} - /// 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:` @@ -4339,13 +4306,6 @@ pub(crate) fn berry_pin_needs_manifest(bin_entries: &[LockBlock], dep: &DepOverr }) } -/// 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:")?; - split_pattern(body).map(|(real, _)| real) -} - /// The root manifest the yarn berry hosted pin edits. const BERRY_MANIFEST: &str = "package.json"; 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 9efc3834c..4b95c7c07 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -350,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; @@ -368,14 +368,15 @@ async fn restore_classic( result: &mut FormatResult, ) { use crate::formats::yarn::blocks::{ - block_eol, classic_field, repin_classic_block, replace_block, scan_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}; // The same byte splice as the hosted rewriter (see - // [`super::super::classic_line_endings_supported`]). - if !super::super::classic_line_endings_supported(raw) { + // [`classic_line_endings_supported`]). + if !classic_line_endings_supported(raw) { refuse_all_in( pins, rel, diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 0a7ebec94..33d1ba8c6 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -95,7 +95,7 @@ use crate::formats::yarn::patterns::{ BerryLocator, }; use crate::formats::yarn::source::{classic_copy_source, CopySource}; -use crate::patch::redirect::is_berry_lock; +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, From 385742eef74aed7a3ab3f9bce0adf8be912f752f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:19:10 -0400 Subject: [PATCH 08/12] Name yarn classic legacy pins and unresolved entries in hosted mode Three hosted yarn classic cases gave a wrong or no answer: - A file: tarball, URL or hosted-git block an older release already pinned to this run's artifact was reported as staying unpatched and kept out of the in-run VEX, although it installs Socket's build (VEX discovery counts it as Socket's, rollback refuses it). It now gets redirect_yarn_classic_non_registry_legacy_pin, which names the pin and points to restoring yarn.lock from version control, and counts as matched. - A registry block with no resolved line was silently counted as matched with no edit, which also suppressed entry_not_found. It now gets redirect_yarn_classic_unresolved_entry_skipped and stays out of the in-run VEX. The yarn_classic_rewrite golden is re-blessed for that warning (input digests unchanged). - redirect_yarn_classic_non_registry_skipped is renamed to redirect_yarn_classic_non_registry_entry_skipped, matching npm's redirect_npm_non_registry_entry_skipped and the vendored code, before it ships. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../redirect/lock_index_equivalence_tests.rs | 1 + .../src/patch/redirect/mod.rs | 104 ++++++++- .../equivalence/yarn_classic_rewrite.golden | 200 +++++++++--------- 3 files changed, 200 insertions(+), 105 deletions(-) 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 bfa1bf66a..1b982bf57 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3578,11 +3578,36 @@ fn rewrite_yarn_classic( // 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_skipped".into(), + 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 \ @@ -3593,6 +3618,23 @@ fn rewrite_yarn_classic( }); 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 @@ -10104,6 +10146,46 @@ mod tests { 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 @@ -10319,7 +10401,11 @@ mod tests { 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_skipped"], "{copy}"); + 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. @@ -10330,9 +10416,17 @@ mod tests { ); 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); + 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_skipped"], "{copy}"); + assert_eq!( + codes, + ["redirect_yarn_classic_non_registry_entry_skipped"], + "{copy}" + ); } } @@ -10484,7 +10578,7 @@ mod tests { rewrite_yarn_classic(&files, std::slice::from_ref(&ovr), &mut r); 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_skipped"]); + 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/tests/equivalence/yarn_classic_rewrite.golden b/crates/socket-patch-core/tests/equivalence/yarn_classic_rewrite.golden index 28b69b999..7932ec13d 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 22dffa890a0391d0 -2-3 ba98bffaf480d822 1637b2b0163ebd27 -4-5 d80b15a695b0937f 07f35f113f6bcf1a +2-3 ba98bffaf480d822 d298c8554a66e561 +4-5 d80b15a695b0937f f74e430dd31cb692 6-7 8e178c2cb5e2cb14 0edaa8f806cdf917 8-9 e43fd997c62b9e42 b677a7150555d872 10-11 edc61bca32cfc9f9 db8e85ac057ddb98 12-13 175b8cae66ba4ad3 57be5e1d05513b32 -14-15 ef91b060692096d2 b9ccbeff91fa7d21 -16-17 6f0831d9cf265f98 7de7003ea24e8711 +14-15 ef91b060692096d2 db6f34b6ffb4a60f +16-17 6f0831d9cf265f98 de741e2c14f89185 18-19 751e330a33334e51 d8742198772a96c4 -20-21 7bbcf7e2f9c9c88e ea5a9b414bbbb8bf -22-23 5e8538d25f0c3d2f 44756329c843a486 +20-21 7bbcf7e2f9c9c88e 32c8de817bbb68a8 +22-23 5e8538d25f0c3d2f 198d85b8f09e34a2 24-25 1b52d945841c8298 a6e7dab5f5861589 26-27 85871c4064b2c3e3 2bac40e65537c083 28-29 352dff2ab375c6a3 35af942f1bae74fe -30-31 edc5d9ff68527344 d5c6ed5e4b803d21 -32-33 ce2c969dad5ecb04 382527403260df4d -34-35 de24932e90101396 d419dc5acb620cc1 +30-31 edc5d9ff68527344 73673467d0f178be +32-33 ce2c969dad5ecb04 db4acc8e1618936f +34-35 de24932e90101396 78823b01178c4d29 36-37 33a2e2444574429e 1b79a683b3582492 38-39 c57c275705d2a2a7 3d40a25decd791d7 -40-41 b5152432f665aeb8 e94a2abc32d82d3f +40-41 b5152432f665aeb8 1dc9268841f54dc9 42-43 eef444c636c55e21 06e119412dcb4f9e 44-45 5cd2da04fa3dac81 35ee0e3337c41a31 46-47 9dec65795e922fa4 f4ea7497d11e2fbd 48-49 1d9d1d776f1f7250 e56094c868ceb733 50-51 2d362a1b1674d398 8f35ff459ec958c5 -52-53 be73a4ad6b8e5aee 0d42c278b967b572 -54-55 ba9646f86c624aaf dd9c1d3f17460fe2 -56-57 3e96a5f86e6af680 d4141d7b4d3beb0a +52-53 be73a4ad6b8e5aee c5b18818b7a90e76 +54-55 ba9646f86c624aaf ee58304050a7ba6a +56-57 3e96a5f86e6af680 b9d86d938087da55 58-59 8dcd4ce057935cc3 9e260b7c24d783df 60-61 6333a5219075a09d 9a223786aae324f4 62-63 f84be846c0d54615 70ed01b2a4629cf1 64-65 e6f8ad9ceedd2e4c 82447ff1313645d4 66-67 c4e236ff81ac9a8d 1b4d6542cbab5bac -68-69 dadc04add7c7c9e0 60507aa4a3c8d08e +68-69 dadc04add7c7c9e0 1747157119d5b11e 70-71 a5711b822e995dfd e61e15252ad587e8 72-73 c976cf46cd6b6f85 7ac909f471bf7bf9 74-75 18b9eec756bb47e3 d383f147d01ed85b -76-77 54ce49f58715780c 5a88270822bc2c38 +76-77 54ce49f58715780c 5ae97fb095c1ca77 78-79 e8288b75119434a1 583e4b89982f8088 -80-81 94ff16825564c958 9a9162acc0d9a57b -82-83 bb940199daee8606 aaa4b0419b62b0ad -84-85 2ff605c25684cc72 9f65bc552fb22286 +80-81 94ff16825564c958 cd44f0d17cbef723 +82-83 bb940199daee8606 809887c8dc52b568 +84-85 2ff605c25684cc72 4c9756fb84322611 86-87 157e638a5bba3a3c a26e29df7e70b2b4 88-89 f5d52d058b7378f8 514fd8dbf2532ee5 -90-91 c756f82012e55c10 ddd5840e7a25274f +90-91 c756f82012e55c10 bd61a1a01854a986 92-93 7d2b44d28176a529 80b8d9bc47b04a3a -94-95 174fb249b68ff4ad 7cdae7c5323b6bcf -96-97 5dbedd5a89add533 ebced9574824bed6 +94-95 174fb249b68ff4ad d0b416f84bba8bef +96-97 5dbedd5a89add533 91484b3a850a1a3e 98-99 3078474883707efc 0baa1286bdd3fe6d -100-101 85bdf6d5adbbf56d 5777ad61dbb23d66 -102-103 baa7ffe0727796d9 3823fde6fba2c52a -104-105 292106ad225e1037 05722b99a18c1d7e +100-101 85bdf6d5adbbf56d 66e1f6cd9783ed1a +102-103 baa7ffe0727796d9 91120e8d58cb8056 +104-105 292106ad225e1037 91fbad91269e7426 106-107 d08889126f5fe2a3 1e620549fa883ec8 108-109 d7c8957c4b25e62a fc7752feb4e46245 110-111 3a71c785f0a34d60 42ca6a2cc0ace023 -112-113 cd9963e800c7f246 ccbd97d6520838bb +112-113 cd9963e800c7f246 873028674465efe9 114-115 e2ec8f5f04db713d f211610ecceb011a -116-117 9028fc942e7550c3 5748caa6b7aa6b3f -118-119 8ebdd92eeeef6f36 c76a23f566a0efe7 +116-117 9028fc942e7550c3 0c43c41785fd0f9b +118-119 8ebdd92eeeef6f36 2559222a2a5cd59a 120-121 92a4e961365a2f97 4cd92c06d45fef35 122-123 5482e22a9366a649 8c5b3520c32a001b 124-125 1791bc6454f83fd8 8a5dbb09beeac49f -126-127 3fc012d899a28130 62a6779ca67dbaf6 +126-127 3fc012d899a28130 0ee06e10d1fc4dc0 128-129 6d3b3fc00498115f c9a345287353c637 130-131 27f5cff8b7be114c 97412dfebc9beb8a 132-133 caeabae83aeb4228 408fab245b6add8a -134-135 a376820bfb6ab010 adf8747385dd6a18 +134-135 a376820bfb6ab010 73bd349b5a457869 136-137 cfd0efc3c3db5202 46c907229cbe31e0 -138-139 c587b2ef7d70c2aa 7f580f0f613f5edf -140-141 e09bae32f9966aca c2a92df95e420471 +138-139 c587b2ef7d70c2aa e37fe239a7aef586 +140-141 e09bae32f9966aca 06ef69f7399cb88c 142-143 60ce0d4f6fa6794b 49565331a76e0c68 -144-145 410e854b4e2efd89 c4fa6060a911a6cc +144-145 410e854b4e2efd89 16cd3db2d2ae641f 146-147 fd493568c3ac3652 cd206ba5a489ae47 -148-149 deb618ecb555fd73 d84e8ab898fa1f90 -150-151 d6f51b46647d1d15 6ee305a89c02bf83 +148-149 deb618ecb555fd73 6fc4ed8739a2ded0 +150-151 d6f51b46647d1d15 c762972c674a4950 152-153 d0f497c3393351fc 52a3197c36f0095e -154-155 a5e2964501d49184 5b5ff71bc76b201c +154-155 a5e2964501d49184 fd6b3228cdd8ed82 156-157 342978fadea628c9 53d1bc06e04046d5 158-159 c068738f7de05532 6d8ca52aa655b104 -160-161 82f66cf7979cc1e3 5e910b61a00f98fa -162-163 79bb0f03807f6b44 d3f83c903695c717 +160-161 82f66cf7979cc1e3 bae6519916021d6a +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 5c3fd159d153dc15 174-175 a1224e468a2a3299 bd55e6c08af6d476 -176-177 2907e9afcb20dcd1 e364186a689d4fcc -178-179 33c22e07e587c29c 4b0ffb574f88244d +176-177 2907e9afcb20dcd1 4b504f736ce2cfed +178-179 33c22e07e587c29c a3b705bfcecd38cb 180-181 e6f1f6e30a93629b 4d59173de2524859 182-183 aaa04fba9f217938 8618a7b9108deb7d -184-185 f74901f680aa0406 10faf558fb2c1a84 +184-185 f74901f680aa0406 fce4db7397ff6831 186-187 57f8170e481abfa8 b4eac3283383819f -188-189 2e20a5ea93aa89f6 a637ea687754f9c8 -190-191 3f828aa93ae947b7 d07b665083e8e599 +188-189 2e20a5ea93aa89f6 de34b80c4c9ecf80 +190-191 3f828aa93ae947b7 b684772f130c4e7b 192-193 dd2bebf464535865 e8dd3404dc0c72fb -194-195 5d92b24539551fa0 2ca5eea17239c8cb -196-197 507866064aec4111 548804bf1710122b +194-195 5d92b24539551fa0 4d2c355df9c0bf9c +196-197 507866064aec4111 a60c79c674ad30ae 198-199 17c0359ab8b2f84c 25b8322e1e40f574 -200-201 53f4743f0fae2db0 00f8e98bbcc938c9 -202-203 6640c39887079e0f 5205e566b4c7ae93 +200-201 53f4743f0fae2db0 1ecf848211175cb6 +202-203 6640c39887079e0f 429d0b579472d8d8 204-205 c08391b643d19e32 0bdd97a46b64aae4 -206-207 6b805f5639c7107c 889f10d9bf8d8207 +206-207 6b805f5639c7107c 41b500b7989ce9a9 208-209 5c5bfc2ad062e253 5ebba49b54184f8f 210-211 e041d12d9e34a7e4 e360c960ad0924a0 212-213 8162e7cdf8275290 eabc32e6e90af593 214-215 1de934fb8b35e04a bbfea515108c54f2 216-217 410197c9dfc7c2f2 182d9bad1a3a1fcd -218-219 d739a2f19922bb59 d0e9ce24fb253a5d -220-221 82e62cba865edff5 7d2c27084234d7e8 -222-223 87879277b6d6b27a 346e0893bc9e29ad +218-219 d739a2f19922bb59 728139f0e5060737 +220-221 82e62cba865edff5 75d0f607bc7f55da +222-223 87879277b6d6b27a 15836a91490da61e 224-225 926079079c0ecb3f 5caeaeeb19f12ee2 -226-227 d7a6db3f2ad44ad0 8fb13d2514499fd6 -228-229 872bca09ed8ba084 e041c1e60894ec73 -230-231 7d7bf22a0e9cb805 5ef66ecdfd46d3bd -232-233 be20fb9e96cb7c53 bab6f22231144a65 -234-235 acd2ff104a2ac96f 1223921bfe62a2b8 +226-227 d7a6db3f2ad44ad0 21e76c6160a519ca +228-229 872bca09ed8ba084 b0c5ea178e9bf572 +230-231 7d7bf22a0e9cb805 3ba0b7017f84fab5 +232-233 be20fb9e96cb7c53 c3d5737b92d166dc +234-235 acd2ff104a2ac96f 895cadeaaa008c99 236-237 196b2da9f9488cde 1460e6949d396dc4 238-239 afeb25d31570da3e 2de273da1e9fd774 240-241 ca7bab4e4dc37bdf 84bde59d0254e344 -242-243 0004a191c10ab26d d00e7d510eb482d2 -244-245 d29b7cf3240f3a55 0d7e27edbbb990d1 -246-247 756953a9e086f2d5 c10ccd10a470d5e6 -248-249 1f0d7cd899a8ef8c 457754f025bb6ec9 +242-243 0004a191c10ab26d 0d7eafc82bf7097e +244-245 d29b7cf3240f3a55 43f2a3301300e012 +246-247 756953a9e086f2d5 94e0db07cf34a9a6 +248-249 1f0d7cd899a8ef8c 5d62988769525011 250-251 21c745b4215a4f2b fdcc68e1c5401a2f -252-253 6eb261d8c1405b0f 52549edbea33fae9 -254-255 1ddd81908edf76f1 82186c3eb9ae9b11 -256-257 d9bbc8bfea3bad46 f6b54e04b40c5088 -258-259 25a4e018fbb1645a 16ba9fdafe48bc01 +252-253 6eb261d8c1405b0f c559aac39c3ca0f6 +254-255 1ddd81908edf76f1 af8ec445df22e69b +256-257 d9bbc8bfea3bad46 f0356551f5ff06ff +258-259 25a4e018fbb1645a 95051f1e1d016b9c 260-261 62e250bc92ffe414 a5034013ce8983db 262-263 e784b7716f83965a 149748b06e758f0c -264-265 e458082ea4c109ad 9cc761805a766424 +264-265 e458082ea4c109ad 8e78e6bd7ea93e6f 266-267 cac892ea348b5ecc 6afe645267922bb7 268-269 85dd46c8a49a55ef aad62d95630c7d9e -270-271 506bc333993d7c90 59cd745d403495d7 -272-273 e97bb5a51749b02b 969c4d711c31eef1 -274-275 292826999ded1d5e 6474e08e91de490b -276-277 d90823b57af9eda7 0715a5a2b5285b02 +270-271 506bc333993d7c90 19c03c325b057319 +272-273 e97bb5a51749b02b 65f55b733ced688c +274-275 292826999ded1d5e e8af5a6e9c64dafc +276-277 d90823b57af9eda7 e350507d35fcbe48 278-279 ad19b90a41936767 0da298f1df3bb98f 280-281 edafe6d07499b8e4 efffab60d772292b 282-283 50064db2df0bd060 21098a790e8a2dea -284-285 c8ee70bf288a3dcd cb1a580793aa0e46 -286-287 04dfa6be66fb6d83 8861a26b49603cef -288-289 514eb6ba7feedc57 bb0cf5da35976da5 +284-285 c8ee70bf288a3dcd 390056053ca0a303 +286-287 04dfa6be66fb6d83 80459154dc821aad +288-289 514eb6ba7feedc57 4f1a3cd6c84e1a2a 290-291 e8eaef431b35e2b3 1f289c9ce2cfacd2 -292-293 eb6dffaf52bd3be4 dabb5268b982a34a -294-295 d904ba055623d9f8 020ee759e7df5be9 -296-297 1f2c741d7d420ce6 57c8eda636ebf001 -298-299 c03582ccddb96cee 09276f6ba92aa176 -300-301 748a964dddc5b4dd baad5025206a6a82 +292-293 eb6dffaf52bd3be4 764cdab24c09523e +294-295 d904ba055623d9f8 f545fda15adf6910 +296-297 1f2c741d7d420ce6 e0052ccf080709c6 +298-299 c03582ccddb96cee fd101649a3ae3776 +300-301 748a964dddc5b4dd 50ff1bc475cab9e6 302-303 42d640238fbf8361 3dfa9851b74e68f4 -304-305 228ec73b8d5ae872 849ad35d9540396a -306-307 1655281cb4d8c9ce 047db19f42c1a677 -308-309 dd3eb82b3e77ec6f 59171b651664e86d -310-311 f811e22101402c2c df1b46fdcaa0ab81 -312-313 e44293fa25db1eaa 72490364b70912f2 -314-315 1e14263df9269f95 9b50d38b0c42ce8e +304-305 228ec73b8d5ae872 12b4c12c42c83bca +306-307 1655281cb4d8c9ce 008a3139c4d28594 +308-309 dd3eb82b3e77ec6f a34e37e47900b3ae +310-311 f811e22101402c2c 9e3eee7f95f190cb +312-313 e44293fa25db1eaa 9395c458b99504a7 +314-315 1e14263df9269f95 72694debc39adaa4 316-317 686729154bf30a49 313e9d65febfbef7 318-319 77a4f2ba9a0cf3df 57ce65426ebb70c1 320-321 0330a69284ddd224 6744a6189354a042 322-323 441245a0d0613419 e1dcf138b89effe3 -324-325 27bfb24e0adaca59 b4c184025f0b014d -326-327 dfbfe9addb1ce656 24ab50dd44a0b001 -328-329 8d28a268abb3b404 52ef0b79d5e9ee37 -330-331 48f7939e56400f0f afec449918db4c80 +324-325 27bfb24e0adaca59 5b4dd07fdb30aec2 +326-327 dfbfe9addb1ce656 964560bae591633f +328-329 8d28a268abb3b404 0ad8a74efada0c9b +330-331 48f7939e56400f0f daf78b85646bf8a9 332-333 581fc9a0c31802f0 353eed8190a724f1 334-335 b0350490b1ee0793 90b01dba37cd6e67 336-337 421a82f155863960 9e95cc5c5c96acd7 338-339 e1a31b3355f246bf 3859f4780498b2a7 340-341 4e99c984d6efa99b 35c48ff807ba1e57 -342-343 93cd9abc548829ff 8b7df85d2d8bb17b +342-343 93cd9abc548829ff 1d109ecb63aea187 344-345 4940857b43e7fce5 d2d578b99a6ee9eb -346-347 089a20d53499ef50 c7a926374687410f -348-349 d6cda592cf180789 dba43a02d257d9c3 -350-351 88cf16f5d1148288 5fe174ad32f3767b +346-347 089a20d53499ef50 4ea50253de22e7fd +348-349 d6cda592cf180789 f221ece07493184e +350-351 88cf16f5d1148288 23b3e60eb4b5b503 352-353 13fcaa8b580b87c8 e8f6d0b7eecc920b 354-355 0c50455095172a40 498f5f2a376cd06f 356-357 4b370e3af0e4f194 14968653c3426a35 -358-359 7f1f14a0f8535a2f 9d2404b64c99f0bc +358-359 7f1f14a0f8535a2f 9c2ea92f940e8a23 360-361 a34fbcd774798fac 50269ad8a06ec82b -362-363 e8deea23e015fbce 19c8bfa283fbbdf9 +362-363 e8deea23e015fbce 4d4ce8f0cfee9d1c 364-365 118298c4bd6929b9 183c41395d6d62e7 366-367 a88c88ad0662add9 dc8dd3f8aa59fe56 368-369 7e222e2654d0869d 4e51f1cdde66f823 -370-371 192d435734b4b17f c81f3b17df070d0d +370-371 192d435734b4b17f 8549e3d9b5c8c07c 372-373 8b9b5c3c4a391ee0 1278c723844e9009 -374-375 30c7c3e962b54688 75a01e0ce81f0743 -376-377 58fecebbcdbd5a7e 922439ce7c7001a1 -378-379 8347ff535fcf69f8 9b870e9074ecac7f +374-375 30c7c3e962b54688 87fb7e2506af103c +376-377 58fecebbcdbd5a7e 1abc71663964a26c +378-379 8347ff535fcf69f8 2437af1e6d04d99b 380-381 8c4209360acca12c 75976b7f092e1665 382-383 8c93f53cc0d92461 253c644b42808762 384-385 19edb8803a2e153b 536f57f2de668c74 -386-387 c70af0e28589e6cf b475b38fb7327627 -388-389 5602e24e0e9c95ae 9dce58b656c551e9 +386-387 c70af0e28589e6cf 3e58d08606cbbba2 +388-389 5602e24e0e9c95ae 1eecf75baae784a9 390-391 defead10329f9dda 3e987831a5f1b97c 392-393 24b162373746f101 c389ae752815f57f 394-395 449abdb26f8b082e b3b9e49e34865327 -396-397 bf06982a6266b8d1 cb51ba17099eac24 +396-397 bf06982a6266b8d1 6e8bcd57834345a2 398-399 4a73ec47d01832f8 493dbe55b21dfe08 From 51c4b9f4f86a90795ab4d4817b92a9adfea9a422 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:19:10 -0400 Subject: [PATCH 09/12] Keep an older release's vendored yarn wiring on a non-registry copy An older release could wire a URL- or file:-tarball-keyed yarn classic block into .socket/vendor/. The new copy-source classifier saw the non-registry key and refused the block, so an in-sync vendor re-run of a project whose only copy was that block failed with vendor_lock_entry_not_rewritable and said the copy stays UNPATCHED, which is false. The classifier now checks block_points_into_vendor first: such a block stays a candidate (the re-run is a byte-stable no-op) and is named with vendor_yarn_classic_non_registry_legacy_wiring, pointing to vendor --revert. docs/ecosystems.md lists the new codes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/yarn_classic_lock.rs | 64 +++++++++++++++++++ docs/ecosystems.md | 13 +++- 2 files changed, 75 insertions(+), 2 deletions(-) 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 11c99d507..5e650c3d4 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -331,6 +331,13 @@ fn rewritable_candidates( detail, )); } + BlockClass::LegacyWired(detail) => { + candidate_keys.push(block.key.clone()); + skipped.push(VendorWarning::new( + "vendor_yarn_classic_non_registry_legacy_wiring", + detail, + )); + } BlockClass::NoMatch => {} } } @@ -715,6 +722,10 @@ enum BlockClass { /// 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, } @@ -738,6 +749,18 @@ fn classify_classic_block(block: &LockBlock, name: &str, version: &str) -> Block // 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 \ @@ -1462,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; diff --git a/docs/ecosystems.md b/docs/ecosystems.md index aab9408f9..2e012c70d 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -139,10 +139,19 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. `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_skipped` / + 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. + `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 From 574a3e6557b3730c81d112382a7cab76916f5a3f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:19:10 -0400 Subject: [PATCH 10/12] Test the yarn classic inventory on forks and header-less locks Pins two inventory changes from this branch: a file: tarball or URL fork copy carries no registry verifiers (resolved, sha1, integrity), and a header-less classic lock refused by the router is read as classic by the fallback through the one grammar decision. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vendor/lock_inventory/tests.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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. From 209e36ea9dc75da8d7add0870a195a1a42e88e2b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 13:20:56 -0400 Subject: [PATCH 11/12] Test that the berry stanza view and block scan agree The hosted berry writers read the lock as blank-line stanzas and the vendored backend and field readers through scan_blocks. A test now asserts both name the same blocks in the same order across LF, CRLF, BOM, header-comment and no-trailing-newline locks, so the two reads cannot drift on any shape yarn writes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/stanzas.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/socket-patch-core/src/formats/yarn/stanzas.rs b/crates/socket-patch-core/src/formats/yarn/stanzas.rs index b8cd20cfb..bbedb8bd4 100644 --- a/crates/socket-patch-core/src/formats/yarn/stanzas.rs +++ b/crates/socket-patch-core/src/formats/yarn/stanzas.rs @@ -173,4 +173,43 @@ mod tests { 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:?}"); + } + } } From 719a415905c156351f38cc98399fddee31a398e1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 14:16:22 -0400 Subject: [PATCH 12/12] Keep the yarn classic edit record and the yarn rewriters' speed The shared redirect fixtures (npm/yarn-classic/basic, consumed by depscan's TS golden test too) record a classic edit's original/new as the split("\n\n") segment holding the block: a block after two blank lines carries a leading "\n", the last block the file's final newline. The block-scan rewriter recorded the bare block lines, failing redirect_golden on every test leg. ClassicSegments rebuilds that segment from separators found once per lock; yarn_classic_rewrite.golden is re-blessed back to those records. The scan performance check flagged yarn-classic/hosted +95% and yarn-berry hosted/rescan +35-40%: - classic re-scanned and re-copied the whole lock after every pinned block; pins now live in the scanned blocks and are spliced in one pass (splice_blocks). - berry's per-dep version check collected every stanza's lines for every block that did not name the dep; it now reads the field straight off the stanza (berry_stanza_field), after the cheaper alias test. Local compare vs 431b8188 (perf profile): yarn-classic hosted +4.6%, rescan +1.0%; yarn-berry hosted +1.6%, rescan -3.4%. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/yarn/blocks.rs | 30 ++- .../src/patch/redirect/mod.rs | 152 +++++++++-- .../equivalence/yarn_classic_rewrite.golden | 242 +++++++++--------- 3 files changed, 263 insertions(+), 161 deletions(-) diff --git a/crates/socket-patch-core/src/formats/yarn/blocks.rs b/crates/socket-patch-core/src/formats/yarn/blocks.rs index f055b338c..9529edcf4 100644 --- a/crates/socket-patch-core/src/formats/yarn/blocks.rs +++ b/crates/socket-patch-core/src/formats/yarn/blocks.rs @@ -220,19 +220,23 @@ pub(crate) fn live_blocks(patterns: &[Vec]) -> Vec { /// Read a berry scalar field (`: `, value possibly quoted). pub(crate) fn berry_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 + 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). diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 1b982bf57..d5f641184 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -64,8 +64,8 @@ use crate::formats::yarn::berry_entry::{manifest_bin, render_pinned_entry, Pin}; #[cfg(test)] use crate::formats::yarn::blocks::berry_bin_entries; use crate::formats::yarn::blocks::{ - berry_field, berry_lock_locks, block_eol, classic_field, classic_line_endings_supported, - repin_classic_block, replace_block, scan_blocks, LockBlock, + 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::is_berry_lock; use crate::formats::yarn::patterns::{ @@ -3458,6 +3458,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)) + } +} + fn rewrite_yarn_classic( files: &BTreeMap, overrides: &[DepOverride], @@ -3482,7 +3554,7 @@ fn rewrite_yarn_classic( ) .err(); // Line endings: the rewrite splices each pinned block over its own byte - // span ([`replace_block`]), in the line ending that block is written in, + // 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 @@ -3496,13 +3568,19 @@ fn rewrite_yarn_classic( }); return; } - let mut text = raw.clone(); - let mut blocks = scan_blocks(&text); + 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 and redone only for a block this run - // rewrites — not re-split per block per dep. - let mut heads: Vec<(Vec, Option)> = + // 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 { @@ -3676,18 +3754,19 @@ fn rewrite_yarn_classic( 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 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(block.lines.join(eol))), - new: Some(Value::String(pinned.join(eol))), + original: Some(Value::String(original)), + new: Some(Value::String(new)), }); - text = replace_block(&text, block, &pinned, eol); - blocks = scan_blocks(&text); - heads[i] = classic_block_head(&blocks[i].key); + blocks[i].lines = pinned; + pinned_blocks[i] = true; changed = true; } } @@ -3724,10 +3803,32 @@ fn rewrite_yarn_classic( } } if changed { - result.files.insert("yarn.lock".into(), text); + result.files.insert( + "yarn.lock".into(), + splice_blocks(text, &blocks, &pinned_blocks), + ); } } +/// `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) { @@ -3926,10 +4027,8 @@ fn rewrite_yarn_berry_with_manifests( .yarn_berry10c0 .as_deref() .map(|c| crate::vendor::yarn_berry_lock::checksum_in_lock_spelling(content, c)); - let locks_version = |stanza: &str| { - let lines: Vec<&str> = stanza.lines().collect(); - berry_field(&lines, "version") == Some(dep.version.as_str()) - }; + 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)` — @@ -3968,14 +4067,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 locks_version(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 { 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 7932ec13d..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 22dffa890a0391d0 -2-3 ba98bffaf480d822 d298c8554a66e561 +0-1 1190d243c3c9a00e e8dc1c09e202ea26 +2-3 ba98bffaf480d822 462668fcfc8da48c 4-5 d80b15a695b0937f f74e430dd31cb692 -6-7 8e178c2cb5e2cb14 0edaa8f806cdf917 -8-9 e43fd997c62b9e42 b677a7150555d872 -10-11 edc61bca32cfc9f9 db8e85ac057ddb98 -12-13 175b8cae66ba4ad3 57be5e1d05513b32 -14-15 ef91b060692096d2 db6f34b6ffb4a60f -16-17 6f0831d9cf265f98 de741e2c14f89185 -18-19 751e330a33334e51 d8742198772a96c4 +6-7 8e178c2cb5e2cb14 1fb27d735de4cbbe +8-9 e43fd997c62b9e42 7ad679dee1ec8d49 +10-11 edc61bca32cfc9f9 53c9924e9d031970 +12-13 175b8cae66ba4ad3 fd167be2cc4baa0f +14-15 ef91b060692096d2 60054b64244b4a06 +16-17 6f0831d9cf265f98 5863c757ec9c66cf +18-19 751e330a33334e51 612115cd88da46da 20-21 7bbcf7e2f9c9c88e 32c8de817bbb68a8 -22-23 5e8538d25f0c3d2f 198d85b8f09e34a2 -24-25 1b52d945841c8298 a6e7dab5f5861589 -26-27 85871c4064b2c3e3 2bac40e65537c083 -28-29 352dff2ab375c6a3 35af942f1bae74fe -30-31 edc5d9ff68527344 73673467d0f178be -32-33 ce2c969dad5ecb04 db4acc8e1618936f +22-23 5e8538d25f0c3d2f c43a36d8396fed6b +24-25 1b52d945841c8298 fe67293582497cf7 +26-27 85871c4064b2c3e3 1de8cff6d3df0db6 +28-29 352dff2ab375c6a3 b99cfc831d7ee07a +30-31 edc5d9ff68527344 9b5489d8616a87d7 +32-33 ce2c969dad5ecb04 bf2748e4cae93ce8 34-35 de24932e90101396 78823b01178c4d29 -36-37 33a2e2444574429e 1b79a683b3582492 -38-39 c57c275705d2a2a7 3d40a25decd791d7 +36-37 33a2e2444574429e 652c5bf017c28bdb +38-39 c57c275705d2a2a7 a5198a89e6282db9 40-41 b5152432f665aeb8 1dc9268841f54dc9 -42-43 eef444c636c55e21 06e119412dcb4f9e +42-43 eef444c636c55e21 7080fec0ca9bc4ad 44-45 5cd2da04fa3dac81 35ee0e3337c41a31 -46-47 9dec65795e922fa4 f4ea7497d11e2fbd +46-47 9dec65795e922fa4 adbd137d774d1f2b 48-49 1d9d1d776f1f7250 e56094c868ceb733 -50-51 2d362a1b1674d398 8f35ff459ec958c5 -52-53 be73a4ad6b8e5aee c5b18818b7a90e76 -54-55 ba9646f86c624aaf ee58304050a7ba6a +50-51 2d362a1b1674d398 e97b13ca8bbc60cb +52-53 be73a4ad6b8e5aee 85fc1bac8aa8787b +54-55 ba9646f86c624aaf 1000b6f75857cc22 56-57 3e96a5f86e6af680 b9d86d938087da55 58-59 8dcd4ce057935cc3 9e260b7c24d783df 60-61 6333a5219075a09d 9a223786aae324f4 -62-63 f84be846c0d54615 70ed01b2a4629cf1 -64-65 e6f8ad9ceedd2e4c 82447ff1313645d4 -66-67 c4e236ff81ac9a8d 1b4d6542cbab5bac +62-63 f84be846c0d54615 fce13931283bad56 +64-65 e6f8ad9ceedd2e4c def49b7bcdf8b86a +66-67 c4e236ff81ac9a8d 90885de7399544af 68-69 dadc04add7c7c9e0 1747157119d5b11e -70-71 a5711b822e995dfd e61e15252ad587e8 +70-71 a5711b822e995dfd 758907b08d749c04 72-73 c976cf46cd6b6f85 7ac909f471bf7bf9 -74-75 18b9eec756bb47e3 d383f147d01ed85b -76-77 54ce49f58715780c 5ae97fb095c1ca77 -78-79 e8288b75119434a1 583e4b89982f8088 +74-75 18b9eec756bb47e3 9f12e86f7660befd +76-77 54ce49f58715780c 49d0d6da2b082b7a +78-79 e8288b75119434a1 859bc3d7150208fd 80-81 94ff16825564c958 cd44f0d17cbef723 82-83 bb940199daee8606 809887c8dc52b568 -84-85 2ff605c25684cc72 4c9756fb84322611 -86-87 157e638a5bba3a3c a26e29df7e70b2b4 -88-89 f5d52d058b7378f8 514fd8dbf2532ee5 +84-85 2ff605c25684cc72 363e0bf5b728e6ee +86-87 157e638a5bba3a3c 1c649f5a07123fbf +88-89 f5d52d058b7378f8 4852e6e124420ac6 90-91 c756f82012e55c10 bd61a1a01854a986 92-93 7d2b44d28176a529 80b8d9bc47b04a3a -94-95 174fb249b68ff4ad d0b416f84bba8bef -96-97 5dbedd5a89add533 91484b3a850a1a3e -98-99 3078474883707efc 0baa1286bdd3fe6d +94-95 174fb249b68ff4ad 1f97c07fd988e832 +96-97 5dbedd5a89add533 475f6acd20d72596 +98-99 3078474883707efc 252802dcfc97d3ae 100-101 85bdf6d5adbbf56d 66e1f6cd9783ed1a 102-103 baa7ffe0727796d9 91120e8d58cb8056 -104-105 292106ad225e1037 91fbad91269e7426 -106-107 d08889126f5fe2a3 1e620549fa883ec8 +104-105 292106ad225e1037 b878a24eec363564 +106-107 d08889126f5fe2a3 9d4aadc6ba4e7e2e 108-109 d7c8957c4b25e62a fc7752feb4e46245 -110-111 3a71c785f0a34d60 42ca6a2cc0ace023 +110-111 3a71c785f0a34d60 19ef39a099202dc3 112-113 cd9963e800c7f246 873028674465efe9 -114-115 e2ec8f5f04db713d f211610ecceb011a +114-115 e2ec8f5f04db713d 2554ace2dcc93b15 116-117 9028fc942e7550c3 0c43c41785fd0f9b -118-119 8ebdd92eeeef6f36 2559222a2a5cd59a +118-119 8ebdd92eeeef6f36 644ac9870b4a558d 120-121 92a4e961365a2f97 4cd92c06d45fef35 -122-123 5482e22a9366a649 8c5b3520c32a001b -124-125 1791bc6454f83fd8 8a5dbb09beeac49f -126-127 3fc012d899a28130 0ee06e10d1fc4dc0 +122-123 5482e22a9366a649 ed8406bf03a1046b +124-125 1791bc6454f83fd8 93a3bb7b27cf2ddd +126-127 3fc012d899a28130 ef648c3ada60396a 128-129 6d3b3fc00498115f c9a345287353c637 -130-131 27f5cff8b7be114c 97412dfebc9beb8a -132-133 caeabae83aeb4228 408fab245b6add8a -134-135 a376820bfb6ab010 73bd349b5a457869 -136-137 cfd0efc3c3db5202 46c907229cbe31e0 -138-139 c587b2ef7d70c2aa e37fe239a7aef586 +130-131 27f5cff8b7be114c 5e871bed9b4e9c98 +132-133 caeabae83aeb4228 3d2716a58b333b48 +134-135 a376820bfb6ab010 2fd7c05333693711 +136-137 cfd0efc3c3db5202 a4274a2d1fad5840 +138-139 c587b2ef7d70c2aa 97f2b37525bc85cc 140-141 e09bae32f9966aca 06ef69f7399cb88c 142-143 60ce0d4f6fa6794b 49565331a76e0c68 -144-145 410e854b4e2efd89 16cd3db2d2ae641f -146-147 fd493568c3ac3652 cd206ba5a489ae47 -148-149 deb618ecb555fd73 6fc4ed8739a2ded0 -150-151 d6f51b46647d1d15 c762972c674a4950 -152-153 d0f497c3393351fc 52a3197c36f0095e +144-145 410e854b4e2efd89 34dbfc1fa684eb27 +146-147 fd493568c3ac3652 47ac1f33a38092c0 +148-149 deb618ecb555fd73 a728c7291d2cee98 +150-151 d6f51b46647d1d15 c379ad5a24211a5d +152-153 d0f497c3393351fc cdf20344f77fb72d 154-155 a5e2964501d49184 fd6b3228cdd8ed82 -156-157 342978fadea628c9 53d1bc06e04046d5 -158-159 c068738f7de05532 6d8ca52aa655b104 -160-161 82f66cf7979cc1e3 bae6519916021d6a +156-157 342978fadea628c9 e041f45858df4f0a +158-159 c068738f7de05532 48b7cbdae908784e +160-161 82f66cf7979cc1e3 412821bc28f037fe 162-163 79bb0f03807f6b44 e8f59cefb9b266c4 164-165 22981a3f179cfc9a 97689d6bbf14955e 166-167 0aa1b383329a8627 498dc7bf4214e559 168-169 3f162034aef1dee7 fa02bb7098abd57d 170-171 d270a58f50af5bc2 7f0255cf04696296 -172-173 3de38dd2c44458ee 5c3fd159d153dc15 +172-173 3de38dd2c44458ee 6e10d8da0eb9599f 174-175 a1224e468a2a3299 bd55e6c08af6d476 -176-177 2907e9afcb20dcd1 4b504f736ce2cfed +176-177 2907e9afcb20dcd1 be6367180453d478 178-179 33c22e07e587c29c a3b705bfcecd38cb -180-181 e6f1f6e30a93629b 4d59173de2524859 +180-181 e6f1f6e30a93629b 2c5dbad8dbee8761 182-183 aaa04fba9f217938 8618a7b9108deb7d 184-185 f74901f680aa0406 fce4db7397ff6831 -186-187 57f8170e481abfa8 b4eac3283383819f +186-187 57f8170e481abfa8 7710e41cc383d389 188-189 2e20a5ea93aa89f6 de34b80c4c9ecf80 190-191 3f828aa93ae947b7 b684772f130c4e7b -192-193 dd2bebf464535865 e8dd3404dc0c72fb -194-195 5d92b24539551fa0 4d2c355df9c0bf9c +192-193 dd2bebf464535865 0717e0198c5e0e5e +194-195 5d92b24539551fa0 66db9abc47d08fcf 196-197 507866064aec4111 a60c79c674ad30ae -198-199 17c0359ab8b2f84c 25b8322e1e40f574 -200-201 53f4743f0fae2db0 1ecf848211175cb6 -202-203 6640c39887079e0f 429d0b579472d8d8 -204-205 c08391b643d19e32 0bdd97a46b64aae4 +198-199 17c0359ab8b2f84c afa62f7958d04aa1 +200-201 53f4743f0fae2db0 33bd7d870f50e1a0 +202-203 6640c39887079e0f 7e24971780376772 +204-205 c08391b643d19e32 1b07d17000ef2731 206-207 6b805f5639c7107c 41b500b7989ce9a9 -208-209 5c5bfc2ad062e253 5ebba49b54184f8f -210-211 e041d12d9e34a7e4 e360c960ad0924a0 +208-209 5c5bfc2ad062e253 e86681d615a21831 +210-211 e041d12d9e34a7e4 6e07ace24940e58a 212-213 8162e7cdf8275290 eabc32e6e90af593 -214-215 1de934fb8b35e04a bbfea515108c54f2 +214-215 1de934fb8b35e04a 4464d026070c1392 216-217 410197c9dfc7c2f2 182d9bad1a3a1fcd 218-219 d739a2f19922bb59 728139f0e5060737 220-221 82e62cba865edff5 75d0f607bc7f55da -222-223 87879277b6d6b27a 15836a91490da61e +222-223 87879277b6d6b27a 79f9502a4e455875 224-225 926079079c0ecb3f 5caeaeeb19f12ee2 -226-227 d7a6db3f2ad44ad0 21e76c6160a519ca -228-229 872bca09ed8ba084 b0c5ea178e9bf572 -230-231 7d7bf22a0e9cb805 3ba0b7017f84fab5 +226-227 d7a6db3f2ad44ad0 6da057b1baadd966 +228-229 872bca09ed8ba084 30337baa82fd71b8 +230-231 7d7bf22a0e9cb805 a6fa3b4a6040bb86 232-233 be20fb9e96cb7c53 c3d5737b92d166dc 234-235 acd2ff104a2ac96f 895cadeaaa008c99 -236-237 196b2da9f9488cde 1460e6949d396dc4 +236-237 196b2da9f9488cde aaa4a32af1f0f003 238-239 afeb25d31570da3e 2de273da1e9fd774 -240-241 ca7bab4e4dc37bdf 84bde59d0254e344 +240-241 ca7bab4e4dc37bdf 8bf52d2859163808 242-243 0004a191c10ab26d 0d7eafc82bf7097e 244-245 d29b7cf3240f3a55 43f2a3301300e012 -246-247 756953a9e086f2d5 94e0db07cf34a9a6 -248-249 1f0d7cd899a8ef8c 5d62988769525011 +246-247 756953a9e086f2d5 f6ff6c6ea51a6673 +248-249 1f0d7cd899a8ef8c 41e0ebe9ba5884c1 250-251 21c745b4215a4f2b fdcc68e1c5401a2f -252-253 6eb261d8c1405b0f c559aac39c3ca0f6 -254-255 1ddd81908edf76f1 af8ec445df22e69b +252-253 6eb261d8c1405b0f 38fc7a68680f26bc +254-255 1ddd81908edf76f1 13e4867bb251a8ce 256-257 d9bbc8bfea3bad46 f0356551f5ff06ff -258-259 25a4e018fbb1645a 95051f1e1d016b9c -260-261 62e250bc92ffe414 a5034013ce8983db -262-263 e784b7716f83965a 149748b06e758f0c +258-259 25a4e018fbb1645a 2a6e03f3e9266729 +260-261 62e250bc92ffe414 edef78c7b7eaedf5 +262-263 e784b7716f83965a b390cf8ddcc0e2bc 264-265 e458082ea4c109ad 8e78e6bd7ea93e6f 266-267 cac892ea348b5ecc 6afe645267922bb7 268-269 85dd46c8a49a55ef aad62d95630c7d9e -270-271 506bc333993d7c90 19c03c325b057319 -272-273 e97bb5a51749b02b 65f55b733ced688c -274-275 292826999ded1d5e e8af5a6e9c64dafc +270-271 506bc333993d7c90 7e7137d926b2464d +272-273 e97bb5a51749b02b ef6ce98c980586bb +274-275 292826999ded1d5e 8c2c23beed816afb 276-277 d90823b57af9eda7 e350507d35fcbe48 -278-279 ad19b90a41936767 0da298f1df3bb98f -280-281 edafe6d07499b8e4 efffab60d772292b -282-283 50064db2df0bd060 21098a790e8a2dea +278-279 ad19b90a41936767 f7e03678929d10b9 +280-281 edafe6d07499b8e4 398bd84b1ca365cb +282-283 50064db2df0bd060 b8f3b508121835bd 284-285 c8ee70bf288a3dcd 390056053ca0a303 286-287 04dfa6be66fb6d83 80459154dc821aad -288-289 514eb6ba7feedc57 4f1a3cd6c84e1a2a +288-289 514eb6ba7feedc57 4b37958179ba4753 290-291 e8eaef431b35e2b3 1f289c9ce2cfacd2 -292-293 eb6dffaf52bd3be4 764cdab24c09523e -294-295 d904ba055623d9f8 f545fda15adf6910 +292-293 eb6dffaf52bd3be4 70ff58718f5ba2aa +294-295 d904ba055623d9f8 aff38e9152d3646c 296-297 1f2c741d7d420ce6 e0052ccf080709c6 298-299 c03582ccddb96cee fd101649a3ae3776 -300-301 748a964dddc5b4dd 50ff1bc475cab9e6 +300-301 748a964dddc5b4dd 0214d8cc16358c6d 302-303 42d640238fbf8361 3dfa9851b74e68f4 -304-305 228ec73b8d5ae872 12b4c12c42c83bca -306-307 1655281cb4d8c9ce 008a3139c4d28594 +304-305 228ec73b8d5ae872 5b1c242e99f743d8 +306-307 1655281cb4d8c9ce e7b0156702300412 308-309 dd3eb82b3e77ec6f a34e37e47900b3ae 310-311 f811e22101402c2c 9e3eee7f95f190cb -312-313 e44293fa25db1eaa 9395c458b99504a7 -314-315 1e14263df9269f95 72694debc39adaa4 -316-317 686729154bf30a49 313e9d65febfbef7 -318-319 77a4f2ba9a0cf3df 57ce65426ebb70c1 +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 5b4dd07fdb30aec2 -326-327 dfbfe9addb1ce656 964560bae591633f -328-329 8d28a268abb3b404 0ad8a74efada0c9b +324-325 27bfb24e0adaca59 10e53e29998b0621 +326-327 dfbfe9addb1ce656 48e02d2febcf3593 +328-329 8d28a268abb3b404 78af330619e8376f 330-331 48f7939e56400f0f daf78b85646bf8a9 -332-333 581fc9a0c31802f0 353eed8190a724f1 +332-333 581fc9a0c31802f0 9c86402aa689b375 334-335 b0350490b1ee0793 90b01dba37cd6e67 -336-337 421a82f155863960 9e95cc5c5c96acd7 +336-337 421a82f155863960 d3a8a82c2c57ed78 338-339 e1a31b3355f246bf 3859f4780498b2a7 340-341 4e99c984d6efa99b 35c48ff807ba1e57 342-343 93cd9abc548829ff 1d109ecb63aea187 344-345 4940857b43e7fce5 d2d578b99a6ee9eb -346-347 089a20d53499ef50 4ea50253de22e7fd +346-347 089a20d53499ef50 56b9ad97e643a786 348-349 d6cda592cf180789 f221ece07493184e -350-351 88cf16f5d1148288 23b3e60eb4b5b503 -352-353 13fcaa8b580b87c8 e8f6d0b7eecc920b -354-355 0c50455095172a40 498f5f2a376cd06f +350-351 88cf16f5d1148288 267d4c2db16acc9d +352-353 13fcaa8b580b87c8 bf6f349d67fbfb90 +354-355 0c50455095172a40 fc276d10f198f125 356-357 4b370e3af0e4f194 14968653c3426a35 -358-359 7f1f14a0f8535a2f 9c2ea92f940e8a23 -360-361 a34fbcd774798fac 50269ad8a06ec82b +358-359 7f1f14a0f8535a2f 46e4e4e09f9492af +360-361 a34fbcd774798fac 8f9a63a647a536c1 362-363 e8deea23e015fbce 4d4ce8f0cfee9d1c -364-365 118298c4bd6929b9 183c41395d6d62e7 -366-367 a88c88ad0662add9 dc8dd3f8aa59fe56 -368-369 7e222e2654d0869d 4e51f1cdde66f823 -370-371 192d435734b4b17f 8549e3d9b5c8c07c +364-365 118298c4bd6929b9 4b3baf5be3e9ca86 +366-367 a88c88ad0662add9 6da67d5894ca93e0 +368-369 7e222e2654d0869d 067e679412065805 +370-371 192d435734b4b17f 9c4bf9d9ab7cadac 372-373 8b9b5c3c4a391ee0 1278c723844e9009 -374-375 30c7c3e962b54688 87fb7e2506af103c -376-377 58fecebbcdbd5a7e 1abc71663964a26c +374-375 30c7c3e962b54688 a974738c309b67f1 +376-377 58fecebbcdbd5a7e bcebfd3ea5c01b6f 378-379 8347ff535fcf69f8 2437af1e6d04d99b -380-381 8c4209360acca12c 75976b7f092e1665 +380-381 8c4209360acca12c 0e7873e2e3061475 382-383 8c93f53cc0d92461 253c644b42808762 -384-385 19edb8803a2e153b 536f57f2de668c74 -386-387 c70af0e28589e6cf 3e58d08606cbbba2 +384-385 19edb8803a2e153b e20810e72dbe25da +386-387 c70af0e28589e6cf 6a796c30e15fe621 388-389 5602e24e0e9c95ae 1eecf75baae784a9 -390-391 defead10329f9dda 3e987831a5f1b97c -392-393 24b162373746f101 c389ae752815f57f +390-391 defead10329f9dda 99e8ae5f267a94f2 +392-393 24b162373746f101 674f91f3d20953e0 394-395 449abdb26f8b082e b3b9e49e34865327 396-397 bf06982a6266b8d1 6e8bcd57834345a2 -398-399 4a73ec47d01832f8 493dbe55b21dfe08 +398-399 4a73ec47d01832f8 4681f54698a20616