From 37d679bfdfda242ec90189ec66522a288f96fceb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:23:42 +0000 Subject: [PATCH 01/13] Drop never-read fields from the rollback covgap fixture clippy -D warnings rejects the dead before_hash/after_hash fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index bfa158519..3f48f26f1 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,8 +149,6 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], - before_hash: String, - after_hash: String, } fn patched_fixture() -> PatchedFixture { @@ -179,8 +177,6 @@ fn patched_fixture() -> PatchedFixture { purl, before, after, - before_hash, - after_hash, } } From c739e35e472848b88133ab5ee8201daf4fc505fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:23:42 +0000 Subject: [PATCH 02/13] Read pnpm-lock.yaml through one format model formats::pnpm owns the pnpm lock grammar in every generation (shrinkwrap, 5.x block, 5.4/6.0/9.0 flow, Rush nested locks): PnpmLock::parse once, then entries(), resolves(), wired_refs(), wired_integrity(), vendored_in_use(), plan_hosted() and the restore_upstream() hook. The redirect rewriter's pnpm leg, the lock inventory, lockfile discovery, repair's integrity anchor and flavor sniff, the vendored v9/legacy planners and get's pnpm-PnP probe all read through it instead of their own walkers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- crates/socket-patch-cli/src/commands/get.rs | 156 +---- .../src/commands/repair_vendor.rs | 15 +- .../src/commands/scan/hosted.rs | 40 +- crates/socket-patch-core/src/formats/mod.rs | 122 ++++ .../pnpm.rs => formats/pnpm/grammar.rs} | 15 +- .../src/formats/pnpm/hosted.rs | 426 +++++++++++++ .../src/formats/pnpm/lines.rs | 149 +++++ .../socket-patch-core/src/formats/pnpm/mod.rs | 586 ++++++++++++++++++ crates/socket-patch-core/src/lib.rs | 1 + .../patch/redirect/group_equivalence_tests.rs | 2 +- .../src/patch/redirect/mod.rs | 432 +------------ .../patch/redirect/pnpm_equivalence_tests.rs | 4 +- .../src/vendor/lock_inventory/mod.rs | 5 +- .../src/vendor/lock_inventory/pnpm.rs | 193 +----- .../src/vendor/lock_inventory/recover.rs | 21 +- .../src/vendor/lock_inventory/tests.rs | 2 +- .../src/vendor/lock_inventory/view.rs | 2 +- .../src/vendor/lock_inventory/wired.rs | 23 +- .../src/vendor/npm_flavor.rs | 4 +- .../socket-patch-core/src/vendor/pnpm_lock.rs | 249 +------- .../src/vendor/pnpm_lock_legacy.rs | 92 +-- .../socket-patch-core/src/vex/discover/npm.rs | 23 +- 22 files changed, 1394 insertions(+), 1168 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/mod.rs rename crates/socket-patch-core/src/{patch/redirect/pnpm.rs => formats/pnpm/grammar.rs} (95%) create mode 100644 crates/socket-patch-core/src/formats/pnpm/hosted.rs create mode 100644 crates/socket-patch-core/src/formats/pnpm/lines.rs create mode 100644 crates/socket-patch-core/src/formats/pnpm/mod.rs diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 76fe2a8bc..663957b54 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -11,6 +11,7 @@ use socket_patch_core::api::types::{ }; use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; +use socket_patch_core::formats::pnpm::PnpmLock; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; use socket_patch_core::manifest::schema::{ PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, @@ -1495,47 +1496,6 @@ fn purl_has_version(purl: &str) -> bool { }) } -/// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored -/// probes over the three lock grammars — a plain `contains` collides on -/// version prefixes (`left-pad@1.3.0` matches inside -/// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside -/// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside -/// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's -/// `/name/version` key spellings; a match counts only when the preceding -/// char cannot extend the name (start/whitespace/quote, or a `/` delimiter -/// itself preceded by such a boundary) and the following char cannot extend -/// the version (so `:`, `'`, `(`, and v5's `_peer` suffix all accept). -/// Heuristic by design: a false negative degrades to a calm skip, a false -/// positive costs one grant request the rewriter's per-dep confirmation -/// then ignores. -fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool { - let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')); - let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"'); - for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] { - for (pos, _) in text.match_indices(needle.as_str()) { - let before_ok = match text[..pos].chars().next_back() { - None => true, - // v5/v6's leading key delimiter — legitimate only when the - // char before it is itself a boundary (otherwise this is a - // scoped `@scope/` tail: a DIFFERENT package). - Some('/') => text[..pos - 1] - .chars() - .next_back() - .is_none_or(name_boundary), - Some(c) => name_boundary(c), - }; - let after_ok = text[pos + needle.len()..] - .chars() - .next() - .is_none_or(version_boundary); - if before_ok && after_ok { - return true; - } - } - } - false -} - /// Outcome of the coarse installed-VERSION narrowing over a CVE/GHSA/PURL /// search fan-out (see [`filter_to_installed_purls`]). struct InstalledNarrowing { @@ -1573,7 +1533,7 @@ struct InstalledNarrowing { /// `yarn_pnp_unsupported`, not a false "not installed"). pnpm PnP skips /// carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the /// refusal's own remedy — keeps the versions the raw pnpm-lock.yaml text -/// resolves ([`pnpm_lock_resolves`]), labels a judged miss +/// resolves ([`PnpmLock::resolves`]), labels a judged miss /// `package_not_installed` like any other mode, and reserves the layout /// code for an unreadable lock (no judgment possible). /// @@ -1642,6 +1602,7 @@ async fn filter_to_installed_purls( let pnpm_pnp_lock_text: Option = (pnp_pnpm && mode == super::scan::ScanMode::Hosted) .then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok()) .flatten(); + let pnpm_pnp_lock = pnpm_pnp_lock_text.as_deref().map(PnpmLock::parse); let mut out = InstalledNarrowing { kept: Vec::new(), @@ -1671,8 +1632,8 @@ async fn filter_to_installed_purls( // The pnpm PnP refusal's own remedy is the hosted lockfile // rewrite — but only for versions the lock ACTUALLY resolves: // keeping the whole fan-out would request grants for every - // version ever patched. Anchored probe over the raw lock text - // (see `pnpm_lock_resolves`); a hit is kept (the rewriter's + // version ever patched. The lock model's key probe + // (`PnpmLock::resolves`); a hit is kept (the rewriter's // per-dep confirmation still decides). A judged MISS is a // genuine "version not resolved" verdict — the layout blocked // nothing — so it carries the same `package_not_installed` code @@ -1681,9 +1642,9 @@ async fn filter_to_installed_purls( let decoded = canon(&result.purl); let coord = decoded.strip_prefix("pkg:npm/").unwrap_or(&decoded); if mode == super::scan::ScanMode::Hosted { - match (pnpm_pnp_lock_text.as_deref(), coord.rsplit_once('@')) { - (Some(text), Some((name, version))) => { - if pnpm_lock_resolves(text, name, version) { + match (&pnpm_pnp_lock, coord.rsplit_once('@')) { + (Some(lock), Some((name, version))) => { + if lock.resolves(name, version) { out.kept.push(result.clone()); continue; } @@ -3983,77 +3944,6 @@ pub(crate) fn base64_decode(input: &str) -> Result, String> { mod tests { use super::*; - /// The pnpm-PnP hosted lock probe must be boundary-anchored: plain - /// substring matching collides on version prefixes, name suffixes, and - /// unscoped-inside-scoped names. - #[test] - fn pnpm_lock_resolves_is_boundary_anchored() { - // v9/v6/v5 key spellings all resolve. - assert!(pnpm_lock_resolves( - "lockfileVersion: '9.0'\n\nsnapshots:\n\n left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad@1.3.0:\n resolution: {}\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad/1.3.0:\n resolution: {}\n", - "left-pad", - "1.3.0" - )); - // Peer-qualified keys still resolve: v9 `(peer)` and v5 `_peer`. - assert!(pnpm_lock_resolves( - " 'left-pad@1.3.0(react@18.0.0)':\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - " /left-pad/1.3.0_react@18.0.0:\n", - "left-pad", - "1.3.0" - )); - // Scoped names resolve in both quoted-v9 and v6 spellings. - assert!(pnpm_lock_resolves( - " '@scope/name@1.0.0':\n", - "@scope/name", - "1.0.0" - )); - assert!(pnpm_lock_resolves( - " /@scope/name@1.0.0:\n", - "@scope/name", - "1.0.0" - )); - - // Version-prefix collision: 1.3.0 must NOT match 1.3.0-beta.1. - assert!(!pnpm_lock_resolves( - " left-pad@1.3.0-beta.1:\n", - "left-pad", - "1.3.0" - )); - // Name-suffix collision: `pad` must NOT match inside `left-pad`. - assert!(!pnpm_lock_resolves(" left-pad@1.3.0:\n", "pad", "1.3.0")); - assert!(!pnpm_lock_resolves(" /left-pad/1.3.0:\n", "pad", "1.3.0")); - // Unscoped-inside-scoped: `name` must NOT match `@scope/name`. - assert!(!pnpm_lock_resolves( - " '@scope/name@1.0.0':\n", - "name", - "1.0.0" - )); - assert!(!pnpm_lock_resolves( - " /@scope/name@1.0.0:\n", - "name", - "1.0.0" - )); - // Absent version: never resolves. - assert!(!pnpm_lock_resolves( - " left-pad@1.3.0:\n", - "left-pad", - "2.0.0" - )); - } use socket_patch_core::api::types::{PatchFileResponse, VulnerabilityResponse}; use std::collections::HashMap; @@ -5032,36 +4922,6 @@ mod tests { ); } - // --- pnpm_lock_resolves: needle at byte 0 ------------------------------ - // The boundary probe reads the char BEFORE the match; a match at the very - // start of the text has none (`None => true`). A regression that indexes - // `text[..pos - 1]` unconditionally would underflow/panic here. - - #[test] - fn pnpm_lock_resolves_needle_at_start_of_text() { - // pos == 0, plain v9 spelling: no preceding char is a valid boundary. - assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0")); - // pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the - // leading `/` delimiter itself has nothing before it. - assert!(pnpm_lock_resolves( - "/left-pad/1.3.0:\n", - "left-pad", - "1.3.0" - )); - assert!(pnpm_lock_resolves( - "/left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - // Still boundary-checked at the start of text: a scoped tail whose - // name begins mid-token must NOT match. - assert!(!pnpm_lock_resolves( - "@scope/left-pad@1.3.0:\n", - "left-pad", - "1.3.0" - )); - } - // --- write_all_patch_blobs --------------------------------------------- // The per-patch fan-out over write_blob_entry: the FIRST bad entry must // fail the whole patch (Err(())) and leave nothing outside the blobs diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 662f5cd17..0217047fe 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -304,15 +304,12 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> } if let Some(text) = read("pnpm-lock.yaml").await { if text.contains(&needle) { - // Same version allowlist as core's `sniff_lock_grammar`. - return match text - .lines() - .find_map(|l| l.strip_prefix("lockfileVersion:")) - .map(|v| v.trim().trim_matches(['\'', '"'])) - { - Some("9.0") => Some("pnpm".to_string()), - Some("5.4") | Some("6.0") => Some("pnpm-legacy".to_string()), - _ => None, + // The format model's vendor allowlist sniff. + use socket_patch_core::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; + return match sniff_lock_grammar(&text) { + Ok(PnpmLockGrammar::V9) => Some("pnpm".to_string()), + Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => Some("pnpm-legacy".to_string()), + Err(_) => None, }; } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 156f8bf40..96c0bbd40 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -313,41 +313,11 @@ pub(crate) fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: ) } -/// `lockfileVersion` major sniffed from a pnpm-lock.yaml head. pnpm 9-12 -/// emit `lockfileVersion: '9.0'` (single doc, first line); pnpm 8 emits -/// `'6.0'`, pnpm 7 an unquoted `5.4`. `None` when no parseable version line -/// exists — callers treat that as "not trust-policy era" and stay -/// hands-off (fail closed: never write config for a lock we can't read). -pub(crate) fn pnpm_lock_version_major(lock_text: &str) -> Option { - lock_text.lines().find_map(|line| { - let rest = line.strip_prefix("lockfileVersion:")?; - let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); - value.split('.').next()?.parse::().ok() - }) -} - -/// Whether a pnpm lock may belong to pnpm 1–4, which spell the store flag -/// `--store` (pnpm 1–3 can silently ignore `--store-dir`; early pnpm 4 -/// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion -/// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. -pub(crate) fn pnpm_lock_may_need_store_flag(lock_text: &str) -> bool { - lock_text.lines().any(|line| { - if line.starts_with("shrinkwrapVersion:") { - return true; - } - let Some(rest) = line.strip_prefix("lockfileVersion:") else { - return false; - }; - let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); - let mut parts = value.split('.'); - let major = parts.next().and_then(|m| m.parse::().ok()); - let minor = parts - .next() - .and_then(|m| m.parse::().ok()) - .unwrap_or(0); - major == Some(5) && minor <= 2 - }) -} +// The pnpm lock-version sniffs live with the format's model. +pub(crate) use socket_patch_core::formats::pnpm::{ + lock_version_major as pnpm_lock_version_major, + may_need_store_flag as pnpm_lock_may_need_store_flag, +}; /// The planned pnpm-workspace.yaml `trustLockfile: true` edit. pub(crate) enum TrustPlan { diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs new file mode 100644 index 000000000..012d93823 --- /dev/null +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -0,0 +1,122 @@ +//! One model per lockfile format. +//! +//! Each submodule owns everything the modes read from (and plan against) +//! one lock format: the entry grammar, the key rules, the version sniff and +//! the planners that splice it. A model parses a lock text once and answers +//! from that parse: +//! +//! * `entries()` — the registry inventory (`vendor::lock_inventory`'s +//! per-format views wrap it with their I/O); +//! * `wired_refs()` — the entries that name a Socket-hosted or vendored +//! artifact, the raw material of lockfile discovery (`vex::discover`) and +//! `repair`'s trust anchors; +//! * `plan_hosted()` / the vendored planners — the edits `scan --mode +//! hosted` and `vendor` make; +//! * `in_use()` — whether a vendored artifact is still consumed; +//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. +//! +//! Models are PURE: text (or a parsed document) in, answers out. Every read +//! stays with the caller, so the disk engines and the in-memory hosted +//! engine (`MemoryProject`) share one parse per format. An architecture +//! test below enforces it. + +pub mod pnpm; + +/// The default upstream resolution a hosted pin is restored to: the +/// registry artifact of `name@version` as the package manager itself +/// would lock it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamPin<'a> { + pub name: &'a str, + pub version: &'a str, + /// The registry artifact URL, when the format records one. + pub resolved: Option<&'a str>, + /// The format's content verifier for that artifact (an SRI, a hex + /// sha256, …). + pub integrity: Option<&'a str>, +} + +/// What [`LockModel::restore_upstream`] did with a lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreUpstream { + /// The lock text with every named pin restored. + Rewritten(String), + /// No entry of the lock is pinned to any of the named patches. + Unchanged, + /// The format cannot restore the pins in place; `remedy` tells the user + /// how to (`git checkout -- `, a re-lock). + Unsupported { remedy: String }, +} + +/// The contract every per-format model implements. +pub trait LockModel { + /// The lock's canonical file name, for diagnostics. + const FORMAT: &'static str; + + /// Rewrite every hosted pin of the named patches back to its default + /// upstream entry with the same writer the hosted planner uses. The + /// hosted-rollback workstream fills this in per format; until then + /// every format refuses with the checkout remedy. + fn restore_upstream(&self, _pins: &[UpstreamPin<'_>]) -> RestoreUpstream { + RestoreUpstream::Unsupported { + remedy: format!( + "restore {} from version control (`git checkout -- {}`)", + Self::FORMAT, + Self::FORMAT + ), + } + } +} + +/// ARCHITECTURE GUARD (module docs): format models do no I/O and apply no +/// host policy. +#[cfg(test)] +mod architecture_tests { + use std::path::Path; + + const IMPURE: [&str; 9] = [ + "tokio::fs", + "std::fs", + "read_regular_", + "File::open", + "OpenOptions", + "hosted_patch_uuid", + "hosted_patch_url_uuids", + "async fn", + ".await", + ]; + + fn rs_files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read formats dir") { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + + #[test] + fn format_models_are_pure() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/formats"); + let mut files = Vec::new(); + rs_files(&dir, &mut files); + assert!(files.len() >= 4, "only {} format files found", files.len()); + for path in files { + let src = std::fs::read_to_string(&path).expect("read format source"); + let prod = src.split("#[cfg(test)]").next().unwrap_or_default(); + let code: String = prod + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let used: Vec<&str> = IMPURE.iter().copied().filter(|n| code.contains(n)).collect(); + assert!( + used.is_empty(), + "{}: a format model uses {used:?} — models are pure (module docs)", + path.display() + ); + } + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs similarity index 95% rename from crates/socket-patch-core/src/patch/redirect/pnpm.rs rename to crates/socket-patch-core/src/formats/pnpm/grammar.rs index a5fa6a4de..074b61ce5 100644 --- a/crates/socket-patch-core/src/patch/redirect/pnpm.rs +++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs @@ -1,13 +1,16 @@ -//! Read pnpm's package blocks without reserializing unrelated YAML. The old -//! shrinkwrap and lockfile 5.1/5.2 formats use block resolutions; newer locks -//! use flow mappings. Peer suffixes are identities, not part of the version. +//! The `packages:` entry grammar: read pnpm's package blocks without +//! reserializing unrelated YAML. The old shrinkwrap and lockfile 5.1/5.2 +//! formats use block resolutions; newer locks use flow mappings. Peer +//! suffixes are identities, not part of the version. The hosted planner +//! ([`super::hosted`]), the lock inventory and lockfile discovery all read +//! entries through this one walk. use std::ops::Range; /// Early pnpm 1 writes shrinkwrapVersion 3 without a minor version and /// unconditionally drops registry tarball URLs on install. Its frozen flag /// cannot preserve this redirect (verified with pnpm 1.0.0). -pub(super) fn unsupported_early_shrinkwrap(content: &str) -> bool { +pub(crate) fn unsupported_early_shrinkwrap(content: &str) -> bool { let version = content .lines() .find_map(|line| line.strip_prefix("shrinkwrapVersion:")); @@ -100,7 +103,7 @@ pub(crate) fn entry_field<'a>(entry: &Entry<'a>, field: &str) -> Option<&'a str> } /// Loose identity match, also used to refuse unsupported suffixes atomically. -pub(super) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { +pub(crate) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { let key = unquote(key); let key = key.strip_prefix('/').unwrap_or(key); let suffix = key @@ -116,7 +119,7 @@ pub(super) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a Some(suffix) } -pub(super) fn supported_suffix(suffix: &str) -> bool { +pub(crate) fn supported_suffix(suffix: &str) -> bool { if suffix.is_empty() || suffix.starts_with('_') { return true; } diff --git a/crates/socket-patch-core/src/formats/pnpm/hosted.rs b/crates/socket-patch-core/src/formats/pnpm/hosted.rs new file mode 100644 index 000000000..c491bd22c --- /dev/null +++ b/crates/socket-patch-core/src/formats/pnpm/hosted.rs @@ -0,0 +1,426 @@ +//! The hosted planner: repoint every instance of a patched `name@version` +//! across a project's pnpm lock set at its Socket-hosted tarball, as +//! resolution splices over the [`super::grammar`] entries (the redirect +//! rewriter's pnpm leg). + +use std::borrow::Cow; +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::patch::redirect::{full_name, DepOverride, FileEdit, RewriteResult, RewriteWarning}; + +use super::grammar as pnpm; + +/// Whether `entry` resolves to exactly `artifact_url` — the per-instance +/// residual-gate predicate. +fn pnpm_resolves_to(entry: &pnpm::Entry<'_>, artifact_url: &str) -> bool { + pnpm::resolution(entry).is_some_and(|r| r.tarball() == Some(artifact_url)) +} + +/// One pnpm lock under rewrite. `text` is the lock as of the last +/// materialization; `pending` holds the resolution splices committed since, +/// in `text`'s byte coordinates, and `spliced` the entries they touch. +/// +/// The logical (post-splice) lock is `text` with `pending` applied. Parsing +/// once and indexing is sound because a resolution splice never changes the +/// entry structure: the replaced range and its replacement are only +/// resolution-field material (6-space-indented `k: v` child lines of a block +/// resolution, or the `{…}` flow value after ` resolution:`), and no raw +/// newline can enter a value (`Resolution::rewrite` JSON-quotes whitespace). +/// So every column-0 line (the shrinkwrap-version sniff) and every entry +/// boundary line survives unchanged, and an entry no pending splice touched +/// has byte-identical key and body. An entry that WAS touched is re-read +/// only after materializing, so a later dep with the same name@version (a +/// duplicate override) sees the rewritten text exactly as before. +struct PnpmLockState<'f> { + path: &'f String, + text: Cow<'f, str>, + early_shrinkwrap: bool, + /// (key span, body span) per `packages:` entry, in file order. + entries: Vec<(std::ops::Range, std::ops::Range)>, + /// Entry indices sorted by normalized (unquoted, `/`-stripped) key. + sorted: Vec, + pending: Vec<(std::ops::Range, String)>, + spliced: std::collections::HashSet, + changed: bool, +} + +impl<'f> PnpmLockState<'f> { + fn new(path: &'f String, text: &'f str) -> Self { + let mut state = PnpmLockState { + path, + text: Cow::Borrowed(text), + early_shrinkwrap: pnpm::unsupported_early_shrinkwrap(text), + entries: Vec::new(), + sorted: Vec::new(), + pending: Vec::new(), + spliced: Default::default(), + changed: false, + }; + state.reindex(); + state + } + + fn reindex(&mut self) { + let text: &str = &self.text; + let base = text.as_ptr() as usize; + self.entries = pnpm::entries(text) + .iter() + .map(|e| { + let key_start = e.key.as_ptr() as usize - base; + ( + key_start..key_start + e.key.len(), + e.offset..e.offset + e.body.len(), + ) + }) + .collect(); + let mut sorted: Vec = (0..self.entries.len()).collect(); + sorted.sort_by(|&a, &b| self.norm_key(a).cmp(self.norm_key(b)).then(a.cmp(&b))); + self.sorted = sorted; + } + + fn entry(&self, i: usize) -> pnpm::Entry<'_> { + let (key, body) = &self.entries[i]; + pnpm::Entry { + key: &self.text[key.clone()], + body: &self.text[body.clone()], + offset: body.start, + } + } + + /// The key as [`pnpm::suffix`] compares it. + fn norm_key(&self, i: usize) -> &str { + let key = pnpm::unquote(&self.text[self.entries[i].0.clone()]); + key.strip_prefix('/').unwrap_or(key) + } + + /// Entries whose key names `fname@version` (any suffix), in file order — + /// the same set a full [`pnpm::suffix`] scan of the logical lock yields. + fn hits(&mut self, fname: &str, version: &str) -> Vec { + let hits = self.lookup(fname, version); + if hits.iter().any(|i| self.spliced.contains(i)) { + self.materialize(); + return self.lookup(fname, version); + } + hits + } + + fn lookup(&self, fname: &str, version: &str) -> Vec { + let mut out = Vec::new(); + for sep in ['@', '/'] { + let prefix = format!("{fname}{sep}{version}"); + let start = self + .sorted + .partition_point(|&i| self.norm_key(i) < prefix.as_str()); + out.extend( + self.sorted[start..] + .iter() + .take_while(|&&i| self.norm_key(i).starts_with(prefix.as_str())) + .copied(), + ); + } + out.sort_unstable(); + out.dedup(); + out.retain(|&i| pnpm::suffix(self.entry(i).key, fname, version).is_some()); + out + } + + /// Fold `pending` into `text` and re-parse. + fn materialize(&mut self) { + if self.pending.is_empty() { + return; + } + #[cfg(debug_assertions)] + let keys_before: Vec = (0..self.entries.len()) + .map(|i| self.entry(i).key.to_string()) + .collect(); + let mut pending = std::mem::take(&mut self.pending); + pending.sort_by_key(|(range, _)| range.start); + let mut out = String::with_capacity(self.text.len()); + let mut cursor = 0usize; + for (range, replacement) in pending { + out.push_str(&self.text[cursor..range.start]); + out.push_str(&replacement); + cursor = range.end; + } + out.push_str(&self.text[cursor..]); + self.text = Cow::Owned(out); + self.spliced.clear(); + self.reindex(); + #[cfg(debug_assertions)] + debug_assert_eq!( + keys_before, + (0..self.entries.len()) + .map(|i| self.entry(i).key.to_string()) + .collect::>(), + "a resolution splice changed the pnpm entry structure" + ); + } + + fn into_rewritten(mut self) -> Option<(&'f String, String)> { + if !self.changed { + return None; + } + self.materialize(); + Some((self.path, self.text.into_owned())) + } +} + +pub(crate) fn plan_hosted( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); + // A pnpm lock lives at the project root or at any nested path (e.g. Rush + // repos keep them under `common/config/rush/`); every such files-map key + // is rewritten under the same grammar. Deterministic order: BTreeMap + // iterates keys sorted, so goldens are stable across every lock in the set. + let lock_keys: Vec<&String> = files + .keys() + .filter(|k| { + matches!( + k.rsplit('/').next(), + Some("pnpm-lock.yaml" | "shrinkwrap.yaml") + ) + }) + .collect(); + if npm.is_empty() || lock_keys.is_empty() { + return; + } + // Each lock is parsed and indexed ONCE; splices accumulate per lock and + // are applied in one pass at the end (see `PnpmLockState`). + let mut locks: Vec = lock_keys + .iter() + .map(|k| PnpmLockState::new(k, &files[*k])) + .collect(); + for dep in &npm { + let fname = full_name(dep); + let hits: Vec> = locks + .iter_mut() + .map(|lock| lock.hits(&fname, &dep.version)) + .collect(); + let unsafe_locks: Vec<_> = locks + .iter() + .zip(&hits) + .filter(|(lock, hits)| lock.early_shrinkwrap && !hits.is_empty()) + .map(|(lock, _)| lock.path.as_str()) + .collect(); + if !unsafe_locks.is_empty() { + result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_legacy_lockfile_unsupported".into(), + detail: format!("{} uses early pnpm 1 shrinkwrapVersion 3 without a supported minor version. Those installers discard hosted tarball URLs; {fname}@{} was left unchanged in every lock. Upgrade to a tested pnpm release (1.43.1 or newer) and regenerate the lock, or use `scan --mode agent` for installed-file patching.", unsafe_locks.join(", "), dep.version), + }); + continue; + } + let Some(sha512) = dep.integrity.sha512.clone() else { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_missing_sha512".into(), + detail: format!("{fname}@{} has no sha512 integrity", dep.version), + }); + continue; + }; + // Every peer instance must be redirected, including nested peer + // contexts and the block resolutions emitted by pnpm 1–5. + let mut matched_any = false; + // Per-lock rewrites are PLANNED first and committed only after the + // residual gate below proves no instance of this dep escaped the + // splice grammar in ANY lock — committing lock-by-lock as we go + // would ship exactly the partial rewrite the gate exists to refuse. + type Splice = (usize, std::ops::Range, String); + let mut planned: Vec<(usize, Vec, Vec)> = Vec::new(); + let mut residuals: Vec<(&str, Vec)> = Vec::new(); + for (idx, (lock, hits)) in locks.iter().zip(&hits).enumerate() { + // (entry, byte range to replace, replacement text) per instance, + // plus one FileEdit per instance keyed by the canonical instance + // key — per-instance edits keep the revert ledger lossless when + // several instances of one dep live in the same lock. + let mut splices: Vec = Vec::new(); + let mut instance_edits: Vec = Vec::new(); + // Residual gate, judged per instance on its POST-splice body: + // any instance of this exact name@version still resolving + // somewhere other than the hosted artifact — in a spelling the + // splice grammar cannot parse (e.g. an unbalanced peer suffix) — + // makes this a partial rewrite. Shipping it would confirm and + // VEX-attest the dep while dependents through the unmatched + // instance keep installing the unpatched upstream tarball, so + // the dep is refused instead. + let mut leftover: Vec = Vec::new(); + for &i in hits { + let entry = lock.entry(i); + let suffix = pnpm::suffix(entry.key, &fname, &dep.version) + .expect("hits only holds entries naming this dep"); + let resolution = if pnpm::supported_suffix(suffix) { + pnpm::resolution(&entry) + } else { + None + }; + let Some(resolution) = resolution else { + if !pnpm_resolves_to(&entry, &dep.artifact_url) { + leftover.push(entry.key.to_string()); + } + continue; + }; + matched_any = true; + let original = &lock.text[resolution.range.clone()]; + let rebuilt = resolution.rewrite(&sha512, &dep.artifact_url); + let rel = + resolution.range.start - entry.offset..resolution.range.end - entry.offset; + let body = format!( + "{}{rebuilt}{}", + &entry.body[..rel.start], + &entry.body[rel.end..] + ); + let after = pnpm::Entry { + key: entry.key, + body: &body, + offset: 0, + }; + if !pnpm_resolves_to(&after, &dep.artifact_url) { + leftover.push(entry.key.to_string()); + } + if rebuilt == original { + continue; + } + instance_edits.push(FileEdit { + path: lock.path.clone(), + kind: "redirect_pnpm_resolution".into(), + action: "rewritten".into(), + key: Some(format!("{fname}@{}{suffix}", dep.version)), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(rebuilt.clone())), + }); + splices.push((i, resolution.range, rebuilt)); + } + if !leftover.is_empty() { + residuals.push((lock.path.as_str(), leftover)); + continue; + } + if !splices.is_empty() { + planned.push((idx, splices, instance_edits)); + } + } + // ANY residual anywhere refuses the dep across the WHOLE lock set — + // nothing rewritten, nothing recorded, nothing confirmed: a rewrite + // committed in one lock while another still resolves the dep + // upstream would confirm the dep set-wide. + if !residuals.is_empty() { + result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); + for (lock_key, keys) in &residuals { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_unsupported_lock_key".into(), + detail: format!( + "{fname}@{} still resolves through pnpm lock key(s) whose \ + resolution the redirect grammar cannot repoint: {} in \ + {lock_key}; the dep is left unredirected in EVERY lock \ + (nothing rewritten, nothing confirmed) — regenerate the \ + lock with a current pnpm (lockfileVersion 9) and re-run", + dep.version, + keys.join(", ") + ), + }); + } + continue; + } + for (idx, splices, mut instance_edits) in planned { + let lock = &mut locks[idx]; + for (i, range, replacement) in splices { + lock.spliced.insert(i); + lock.pending.push((range, replacement)); + } + lock.changed = true; + result.edits.append(&mut instance_edits); + } + // The entry-not-found warning fires only when the dep matched in NO + // pnpm lock across the whole set, not once per lock. A VENDORED dep + // is named as such: `socket-patch vendor` removes the registry + // resolution this grammar looks for (v9 respells the packages key + // `@file:.socket/vendor/…`; v5/v6 rekey it to a bare `file:` + // key but keep the `@: file:…` overrides line), so + // the generic not-locked wording would send users on a wild-goose + // `pnpm install` when the real path is a mode switch. Fail-closed + // either way: nothing is rewritten for the dep. + if !matched_any { + let v9_vendored_key = format!("{fname}@file:"); + let override_key = format!("{fname}@{}", dep.version); + // Scanned over the post-splice text, so fold pending splices in. + for lock in locks.iter_mut() { + lock.materialize(); + } + let vendored = locks.iter().any(|lock| { + lock.text.lines().any(|line| { + let t = line.trim_start(); + let t = t.strip_prefix('\'').unwrap_or(t); + // v9 packages/snapshots key (leading `/` in v6 spelling). + // The vendor backend always writes the RELATIVE + // `file:.socket/vendor/…` spelling here, so anchoring on + // it keeps a user's own `file:` dep of the same name + // from being misreported as vendored. + let key = t.strip_prefix('/').unwrap_or(t); + if key + .strip_prefix(&v9_vendored_key) + .is_some_and(|rest| rest.starts_with(".socket/vendor/")) + { + return true; + } + // overrides / root-dep line: `@: file:…` + // (pnpm <=8 absolutizes the value, so only the + // `.socket/vendor/` tail is stable enough to match). + t.strip_prefix(&override_key) + .map(|rest| rest.strip_prefix('\'').unwrap_or(rest)) + .and_then(|rest| rest.strip_prefix(':')) + .is_some_and(|rest| { + rest.contains("file:") && rest.contains(".socket/vendor/") + }) + }) + }); + if vendored { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_vendored".into(), + detail: format!( + "{fname}@{} has no registry resolution because it is \ + VENDORED (the lock resolves it to a \ + file:.socket/vendor/… tarball); the hosted redirect \ + does not apply — run `socket-patch vendor --revert` to \ + restore the registry resolution, then re-run `scan \ + --mode hosted`", + dep.version + ), + }); + } else { + result.warnings.push(RewriteWarning { + code: "redirect_pnpm_entry_not_found".into(), + detail: format!("no resolution for {fname}@{}", dep.version), + }); + } + } + } + for lock in locks { + if let Some((key, content)) = lock.into_rewritten() { + result.files.insert(key.clone(), content); + } + } +} + +/// Test-only reference for the residual gate: every instance of this exact +/// name@version in `content` that does not resolve to `artifact_url`. +/// Production judges the same predicate inline, per instance, on each +/// indexed hit's post-splice body in `plan_hosted`; snapshots and +/// other versions do not participate in resolution. +#[cfg(test)] +pub(crate) fn pnpm_unrewritten_instances( + content: &str, + fname: &str, + version: &str, + artifact_url: &str, +) -> Vec { + pnpm::entries(content) + .into_iter() + .filter_map(|entry| { + pnpm::suffix(entry.key, fname, version)?; + (!pnpm_resolves_to(&entry, artifact_url)).then(|| entry.key.to_string()) + }) + .collect() +} diff --git a/crates/socket-patch-core/src/formats/pnpm/lines.rs b/crates/socket-patch-core/src/formats/pnpm/lines.rs new file mode 100644 index 000000000..ce1726f1e --- /dev/null +++ b/crates/socket-patch-core/src/formats/pnpm/lines.rs @@ -0,0 +1,149 @@ +//! The line-block grammar the vendored planners splice with. +//! pnpm-lock.yaml is machine-emitted with a fixed 2/4/6/8-space shape; these +//! helpers find sections and 2-space-keyed blocks and never interpret YAML +//! generically. Lines are split on `\n` only, so a CRLF lock keeps its `\r` +//! on every line (the planners refuse or preserve it explicitly). + +pub(crate) fn split_lines(text: &str) -> Vec { + text.split('\n').map(str::to_string).collect() +} + +/// `(header_idx, end_idx)` of a top-level `name:` section; `end` is the +/// first following column-0 line (exclusive), so trailing blank separator +/// lines belong to the section. +pub(crate) fn section_bounds(lines: &[String], name: &str) -> Option<(usize, usize)> { + let header = format!("{name}:"); + let start = lines.iter().position(|l| l == &header)?; + let end = lines + .iter() + .enumerate() + .skip(start + 1) + .find(|(_, l)| !l.is_empty() && !l.starts_with(' ')) + .map(|(i, _)| i) + .unwrap_or(lines.len()); + Some((start, end)) +} + +/// One 2-space-keyed block inside a section (`[header, end)`; `end` stops at +/// the blank separator / next block header, so the captured fragment is the +/// verbatim entry without surrounding blanks). +pub(crate) struct YamlBlock { + pub(crate) header: usize, + pub(crate) end: usize, + pub(crate) key: String, + /// The key exactly as spelled in the file (incl. quotes) — rekeys + /// preserve the file's quoting style. + pub(crate) repr: String, + /// Inline value after `:` (e.g. `{}` for empty snapshots), `""` if none. + pub(crate) rest: String, +} + +impl YamlBlock { + /// The inline-rest suffix to re-emit after the (re)written key. + pub(crate) fn rest_suffix(&self) -> String { + if self.rest.is_empty() { + String::new() + } else { + format!(" {}", self.rest) + } + } +} + +/// The next block at or after line `i` (within `[i, end)`). +pub(crate) fn next_block(lines: &[String], mut i: usize, end: usize) -> Option { + while i < end { + if let Some((key, repr, rest)) = parse_key_line(&lines[i], 2) { + let mut j = i + 1; + while j < end && !lines[j].is_empty() && indent_of(&lines[j]) >= 4 { + j += 1; + } + return Some(YamlBlock { + header: i, + end: j, + key: key.to_string(), + repr: repr.to_string(), + rest: rest.to_string(), + }); + } + i += 1; + } + None +} + +pub(crate) fn indent_of(line: &str) -> usize { + line.len() - line.trim_start_matches(' ').len() +} + +/// Parse a mapping line at exactly `indent` spaces into +/// `(key, verbatim_key_repr, value_after_colon)`. Accepts pnpm's bare keys +/// and both quote styles (single quotes are what pnpm emits for `@`-leading +/// keys); the value separator is the first `:` followed by a space or EOL +/// (keys themselves contain `:` in `file:` specs). +/// +/// All three are slices of `line`. Every scan below runs this over whole +/// `packages:` / `snapshots:` sections once per vendored package, so owning +/// copies would dominate the surgery's CPU on a multi-megabyte lock. A +/// caller that keeps a piece past the next edit to `lines` copies it itself. +pub(crate) fn parse_key_line(line: &str, indent: usize) -> Option<(&str, &str, &str)> { + if line.len() <= indent || !line.as_bytes()[..indent].iter().all(|&b| b == b' ') { + return None; + } + let s = &line[indent..]; + let c0 = s.as_bytes()[0]; + if c0 == b' ' { + return None; + } + if c0 == b'\'' || c0 == b'"' { + let quote = c0 as char; + let close = s[1..].find(quote)? + 1; + let after = &s[close + 1..]; + let rest = after.strip_prefix(':')?; + let rest = rest.strip_prefix(' ').unwrap_or(rest); + return Some((&s[1..close], &s[..close + 1], rest)); + } + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + if bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') { + if i == 0 { + return None; + } + let rest = if i + 1 < bytes.len() { &s[i + 2..] } else { "" }; + return Some((&s[..i], &s[..i], rest)); + } + } + None +} + +/// Strip one matching pair of surrounding quotes from a mapping VALUE +/// (pnpm quotes values that would misparse as plain YAML scalars, e.g. the +/// default-catalog specifier `'catalog:'`). +pub(crate) fn unquote_value(value: &str) -> &str { + let bytes = value.as_bytes(); + if bytes.len() >= 2 + && (bytes[0] == b'\'' || bytes[0] == b'"') + && bytes[bytes.len() - 1] == bytes[0] + { + &value[1..value.len() - 1] + } else { + value + } +} + +/// pnpm quotes `@`-leading keys with single quotes; everything we write is +/// otherwise bare. +pub(crate) fn yaml_key(key: &str) -> String { + if key.starts_with('@') { + format!("'{key}'") + } else { + key.to_string() + } +} + +/// Re-spell `key` in the same quoting style as the original `repr`. +pub(crate) fn yaml_key_like(key: &str, original_repr: &str) -> String { + match original_repr.as_bytes().first() { + Some(b'\'') => format!("'{key}'"), + Some(b'"') => format!("\"{key}\""), + _ => yaml_key(key), + } +} diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs new file mode 100644 index 000000000..5510ebbaf --- /dev/null +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -0,0 +1,586 @@ +//! `pnpm-lock.yaml` in every generation (pnpm <= 2's `shrinkwrap.yaml`, +//! lockfile 5.x block resolutions, 5.4 / 6.0 / 9.0 flow resolutions) and +//! Rush's nested pnpm locks: the ONE model of the format. +//! +//! [`PnpmLock`] parses a lock text once and answers every question the +//! modes ask of it: +//! +//! * [`PnpmLock::entries`] — the registry inventory (`scan` / `get` +//! lockfile supplements, `vendor`'s pristine fetch); +//! * [`PnpmLock::resolves`] — whether the lock resolves a `name@version` +//! at all (`get`'s installed-version narrowing under pnpm PnP); +//! * [`PnpmLock::wired_refs`] — every entry whose resolution names a +//! tarball, the raw material lockfile discovery (`vex::discover::npm`) +//! classifies as hosted / vendored refs, and [`PnpmLock::wired_integrity`] +//! repair's trust anchor for a vendored artifact; +//! * [`PnpmLock::vendored_in_use`] — whether a vendored artifact is still +//! consumed (both vendored backends' revert guards and ledger liveness); +//! * [`plan_hosted`] — the hosted planner (the redirect rewriter's pnpm +//! leg: resolution splices over the whole lock set); +//! * the vendored planners (`vendor::pnpm_lock` for lockfileVersion 9.0, +//! `vendor::pnpm_lock_legacy` for 5.4 / 6.0) splice with [`lines`] and +//! route on [`sniff_lock_grammar`]; +//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. +//! +//! Everything here is pure (text in, answers out); the callers own the +//! reads. + +pub(crate) mod grammar; +pub(crate) mod hosted; +pub(crate) mod lines; + +pub(crate) use grammar::{entry_field, is_pnpm_lock_text, Entry, Resolution}; +pub(crate) use hosted::plan_hosted; + +use crate::constants::npm_family::PNPM_LOCK; +use crate::utils::digest::is_sri_pin; +use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry}; +use crate::vendor::path::parse_vendor_path; + +use super::LockModel; + +// ── entry model ── + +/// One `packages:` entry of a pnpm lock, read with the entry grammar +/// ([`grammar::entries`] / [`grammar::resolution`]: two-space keys, a flat +/// flow or block `resolution:` map, CRLF included). +pub(crate) struct PnpmPackage<'a> { + /// The packages key, trimmed and unquoted. + pub(crate) key: &'a str, + pub(crate) entry: Entry<'a>, + /// The entry's `resolution:` map; `None` when it has none or the + /// grammar refuses it (duplicate keys, nested values, aliases). + pub(crate) resolution: Option>, +} + +impl<'a> PnpmPackage<'a> { + /// The unquoted tokens of the entry's raw `resolution:` text + /// ([`grammar::resolution_raw_lines`] split on whitespace and flow + /// punctuation) — what a reader inspects when the grammar refused the + /// map (`resolution` is `None`) and it must still tell a Socket-shaped + /// value from anything else. + pub(crate) fn resolution_tokens(&self) -> Vec<&'a str> { + grammar::resolution_raw_lines(&self.entry) + .into_iter() + .flat_map(|text| { + text.split(|c: char| c.is_whitespace() || matches!(c, ',' | '{' | '}' | '[' | ']')) + }) + .map(|token| grammar::unquote(token.trim())) + .filter(|token| !token.is_empty()) + .collect() + } +} + +/// Every `packages:` entry of a pnpm lock text, in lock order — the ONE +/// entry walk the inventory, lockfile discovery and repair share. Every +/// entry is returned (registry, rekeyed vendored, hosted, directory, git); +/// each consumer applies its own key and resolution rules. +pub(crate) fn pnpm_packages(text: &str) -> Vec> { + grammar::entries(text) + .into_iter() + .map(|entry| PnpmPackage { + key: grammar::unquote(entry.key.trim()), + resolution: grammar::resolution(&entry), + entry, + }) + .collect() +} + +/// The `(integrity, tarball)` of one recorded `packages:` block — a +/// vendored planner's wiring fragment (its key line and body, as +/// recorded), each unquoted; `None` unless the fragment is exactly one +/// entry with a flat resolution. +pub(crate) fn fragment_resolution(block: &[String]) -> Option<(Option, Option)> { + let text = format!("packages:\n{}\n", block.join("\n")); + let packages = pnpm_packages(&text); + let [package] = packages.as_slice() else { + return None; + }; + let resolution = package.resolution.as_ref()?; + Some(( + resolution.integrity().map(str::to_string), + resolution.tarball().map(str::to_string), + )) +} + +/// How a pnpm packages key names its package. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PnpmKey<'a> { + /// A registry key in any lock generation ([`pnpm_registry_key`]). + Registry { name: &'a str, version: &'a str }, + /// v9's rekeyed vendored entry `name@file:` (`vendor::pnpm_lock`); + /// `path` is the raw `file:` spec, peer suffix stripped. + V9File { name: &'a str, path: &'a str }, + /// v5.4 / v6.0's rekeyed vendored entry, the bare `file:` key + /// (`vendor::pnpm_lock_legacy`); the name is on the entry's `name:` + /// line. + LegacyFile { path: &'a str }, + /// Anything else (url, git, `link:`, v5 non-default-registry keys). + Other, +} + +/// Classify a packages key, in this order: a legacy `file:` key, a v9 +/// `name@file:` key (the `@` after a scope's leading one), a registry key, +/// else [`PnpmKey::Other`]. The `file:` paths are returned raw — the CALLER +/// anchors them (lockfile discovery with its root-anchored `vendor_ref`, +/// the writers with `parse_vendor_path`). +pub(crate) fn classify_pnpm_key(key: &str) -> PnpmKey<'_> { + let base = strip_pnpm_peer_suffix(key); + if base.starts_with("file:") { + PnpmKey::LegacyFile { path: base } + } else if let Some(at) = base.get(1..).and_then(|rest| rest.find("@file:")) { + PnpmKey::V9File { + name: &base[..at + 1], + path: &base[at + 2..], + } + } else { + match pnpm_registry_key(base) { + Some((name, version)) => PnpmKey::Registry { name, version }, + None => PnpmKey::Other, + } + } +} + +/// The uuid of the `.socket/vendor/npm//` artifact a rekeyed +/// vendored key (v9 `name@file:` or legacy bare `file:`, in a +/// `packages:` or `snapshots:` section) resolves to. +pub(crate) fn vendored_npm_uuid(key: &str) -> Option { + let path = match classify_pnpm_key(key) { + PnpmKey::V9File { path, .. } | PnpmKey::LegacyFile { path } => path, + PnpmKey::Registry { .. } | PnpmKey::Other => return None, + }; + parse_vendor_path(path) + .filter(|parts| parts.eco == "npm") + .map(|parts| parts.uuid) +} + +/// A pnpm packages key without its v6+ peer suffix (`(peer@1.0.0)…`). +pub(crate) fn strip_pnpm_peer_suffix(key: &str) -> &str { + key.find('(').map_or(key, |p| key[..p].trim_end()) +} + +/// `(name, version)` of a REGISTRY-form pnpm packages key, in every lock +/// generation's grammar — v9 `name@version`, v6 (pnpm 8) the same behind a +/// leading `/`, v5.4 (pnpm 7) and shrinkwrap `/name/version`; names may be +/// scoped in all of them. Peer suffixes are stripped: v6/v9 append +/// `(peer@1.2.3)…` after the version, v5 appends `_peer@x` / `_` to +/// the version itself. `None` for anything that is not a plain registry +/// version (digit-first): `file:` / `link:` / url / git keys and v5 +/// non-default-registry keys. The ONE key rule every reader shares, so all +/// of them read a key as the same package. +pub(crate) fn pnpm_registry_key(key: &str) -> Option<(&str, &str)> { + let base = strip_pnpm_peer_suffix(key); + let (base, legacy) = match base.strip_prefix('/') { + Some(stripped) => (stripped, true), + None => (base, false), + }; + let (name, version) = split_pnpm_key(base, legacy)?; + let version = version.split('_').next().unwrap_or(version); + version + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit()) + .then_some((name, version)) +} + +/// Split a peer-paren-stripped, slash-stripped pnpm packages key into +/// `(name, version)`; `None` is skipped by the caller, never guessed. +/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may +/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` +/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: +/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit +/// and never contains `@`, while a v6 scoped key's trailing segment is +/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) +/// carry no leading `/` and fall through to the `@` split, where they are +/// dropped fail-closed downstream. +fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { + if legacy { + if let Some((name, rest)) = base.rsplit_once('/') { + let version = rest.split('_').next().unwrap_or(rest); + if !name.is_empty() + && version.chars().next().is_some_and(|c| c.is_ascii_digit()) + && !version.contains('@') + { + return Some((name, version)); + } + } + } + let at = base.rfind('@').filter(|&p| p > 0)?; + Some((&base[..at], &base[at + 1..])) +} + +// ── lock version ── + +/// Which vendorable pnpm lock grammar a `pnpm-lock.yaml` head declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PnpmLockGrammar { + /// `lockfileVersion: '9.0'` — the `vendor::pnpm_lock` backend. + V9, + /// `lockfileVersion: 5.4` (pnpm 7, bare float spelling). + V54, + /// `lockfileVersion: '6.0'` (pnpm 8). + V60, +} + +impl PnpmLockGrammar { + /// Human name for diagnostics (`pnpm 7 (lockfileVersion 5.4)`). + pub fn describe(self) -> &'static str { + match self { + PnpmLockGrammar::V9 => "pnpm >= 9 (lockfileVersion 9.0)", + PnpmLockGrammar::V54 => "pnpm 7 (lockfileVersion 5.4)", + PnpmLockGrammar::V60 => "pnpm 8 (lockfileVersion 6.0)", + } + } +} + +/// The `lockfileVersion:` a lock head (its first five lines) declares, +/// unquoted. +fn head_lock_version(text: &str) -> Option { + text.lines() + .take(5) + .find_map(|line| line.strip_prefix("lockfileVersion:")) + .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()) +} + +/// The full vendor allowlist sniff (5.4 / 6.0 / 9.0) the flavor router +/// uses; anything else refuses with a version-aware remedy: pre-allowlist +/// versions (pnpm <= 6's 5.x line) are fixed by upgrading pnpm, but a +/// FUTURE version means the user's pnpm already outgrew this build — +/// looping them back to "re-lock with pnpm >= 9" would hand them the lock +/// they have. +pub fn sniff_lock_grammar(text: &str) -> Result { + match head_lock_version(text).as_deref() { + Some("9.0") => Ok(PnpmLockGrammar::V9), + Some("5.4") => Ok(PnpmLockGrammar::V54), + Some("6.0") => Ok(PnpmLockGrammar::V60), + Some(v) => { + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; supported versions are 5.4 \ + (pnpm 7), 6.0 (pnpm 8), and 9.0 (pnpm >= 9) — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersions 5.4, 6.0, and 9.0 — re-lock with a pnpm release that \ + emits one of them, or update socket-patch" + ), + }) + } + None => Err(format!( + "{PNPM_LOCK} has no lockfileVersion in its head; supported versions are 5.4, \ + 6.0, and 9.0 — re-lock with pnpm >= 9" + )), + } +} + +/// The `(major, minor)` of every `lockfileVersion:` line of a lock (the +/// first one decides), unquoted; a missing minor reads as 0. +fn lock_versions(text: &str) -> impl Iterator, u32)> + '_ { + text.lines().filter_map(|line| { + let rest = line.strip_prefix("lockfileVersion:")?; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + let mut parts = value.split('.'); + let major = parts.next().and_then(|m| m.parse::().ok()); + let minor = parts.next().and_then(|m| m.parse::().ok()).unwrap_or(0); + Some((major, minor)) + }) +} + +/// `lockfileVersion` major of a pnpm lock. pnpm 9-12 emit +/// `lockfileVersion: '9.0'` (single doc, first line); pnpm 8 emits `'6.0'`, +/// pnpm 7 an unquoted `5.4`. `None` when no parseable version line exists — +/// the hosted trust-config gate treats that as "not trust-policy era" and +/// stays hands-off (fail closed: never write config for a lock it can't +/// read). +pub fn lock_version_major(text: &str) -> Option { + lock_versions(text).find_map(|(major, _)| major) +} + +/// Whether a pnpm lock may belong to pnpm 1–4, which spell the store flag +/// `--store` (pnpm 1–3 can silently ignore `--store-dir`; early pnpm 4 +/// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion +/// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. +pub fn may_need_store_flag(text: &str) -> bool { + text.lines().any(|line| line.starts_with("shrinkwrapVersion:")) + || lock_versions(text).any(|(major, minor)| major == Some(5) && minor <= 2) +} + +/// The lockfileVersion the v9 vendored planner splices. +const V9_LOCK_VERSION: &str = "9.0"; + +/// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus +/// double-quoted/bare spellings) — the v9 vendored planner's own guard. +/// The flavor router sniffs with [`sniff_lock_grammar`] instead, whose +/// allowlist also routes the legacy 5.4/6.0 grammars to their planner; +/// this check only fires if a non-9.0 lock reaches the v9 planner directly. +pub(crate) fn check_v9_lock_version(text: &str) -> Result<(), String> { + match head_lock_version(text) { + Some(v) if v == V9_LOCK_VERSION => Ok(()), + Some(v) => { + // The remedy must point the right way: 5.x (pnpm 7) / 6.x + // (pnpm 8) locks predate the v9 grammar and upgrading pnpm + // re-locks them, but a HIGHER version means the user's pnpm + // already outgrew this build — telling them "re-lock with + // pnpm >= 9" would loop them back to the lock they have. + let major = v.split('.').next().and_then(|m| m.parse::().ok()); + Err(match major { + Some(m) if m < 9 => format!( + "{PNPM_LOCK} has lockfileVersion {v}; only {V9_LOCK_VERSION} is \ + supported — re-lock with pnpm >= 9" + ), + _ => format!( + "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ + lockfileVersion {V9_LOCK_VERSION} — re-lock with a pnpm release \ + that emits it, or update socket-patch" + ), + }) + } + None => Err(format!( + "{PNPM_LOCK} has no lockfileVersion in its head; only \ + {V9_LOCK_VERSION} is supported — re-lock with pnpm >= 9" + )), + } +} + +// ── the model ── + +/// One pnpm lock, parsed once (see the module docs). +pub struct PnpmLock<'t> { + text: &'t str, + packages: Vec>, +} + +/// One `packages:` entry whose resolution names a tarball — a candidate +/// hosted or vendored ref ([`PnpmLock::wired_refs`]). +pub(crate) struct PnpmTarballRef<'p> { + pub(crate) tarball: &'p str, + /// The resolution's `integrity`, unfiltered (a caller that needs an SRI + /// pin checks it with `is_sri_pin`). + pub(crate) integrity: Option<&'p str>, +} + +impl<'t> PnpmLock<'t> { + /// Parse a lock text (any content: a non-pnpm text has no entries). + pub fn parse(text: &'t str) -> Self { + PnpmLock { + text, + packages: pnpm_packages(text), + } + } + + pub fn text(&self) -> &'t str { + self.text + } + + /// Whether the text is a pnpm lock at all ([`is_pnpm_lock_text`]). + pub fn is_pnpm_lock(&self) -> bool { + is_pnpm_lock_text(self.text) + } + + /// Every `packages:` entry, in lock order. + pub(crate) fn packages(&self) -> &[PnpmPackage<'t>] { + &self.packages + } + + fn has_packages_section(&self) -> bool { + self.text + .lines() + .any(|l| l.trim_end_matches('\r') == "packages:") + } + + /// The registry inventory: one entry per plain-registry packages key + /// (every key generation, [`pnpm_registry_key`]), flow and block + /// resolutions, CRLF included. A resolution the grammar refuses leaves + /// the entry listed without a verifier; our own vendored tarballs are + /// not registry dependencies and are dropped. `None` when the lock has + /// no `packages:` section. + pub fn entries(&self) -> Option> { + if !self.has_packages_section() { + return None; + } + let mut out = Vec::new(); + for package in &self.packages { + // Only plain registry versions: `file:`/`link:`/`https:`/git + // specs are not registry-resolvable. + let Some((name, version)) = pnpm_registry_key(package.key) else { + continue; + }; + let resolution = package.resolution.as_ref(); + let integrity = resolution + .and_then(|r| r.integrity()) + .map(|i| LockIntegrity::Sri(i.to_string())) + .unwrap_or(LockIntegrity::None); + let tarball = resolution.and_then(|r| r.tarball()); + if tarball.is_some_and(|t| parse_vendor_path(t).is_some()) { + continue; + } + out.push(LockfileEntry::npm( + name, + version, + tarball.and_then(http_url), + integrity, + )); + } + Some(out) + } + + /// Whether some packages key resolves exactly `name@version` (any peer + /// suffix, any key generation). + pub fn resolves(&self, name: &str, version: &str) -> bool { + self.packages + .iter() + .any(|p| pnpm_registry_key(p.key) == Some((name, version))) + } + + /// Every packages entry whose flat resolution names a `tarball:`, in + /// lock order. + pub(crate) fn wired_refs(&self) -> impl Iterator> { + self.packages.iter().filter_map(|package| { + let resolution = package.resolution.as_ref()?; + Some(PnpmTarballRef { + tarball: resolution.tarball()?, + integrity: resolution.integrity(), + }) + }) + } + + /// The SRI pin the lock records for the vendored artifact at + /// `artifact_rel` (forward-slashed, no `./`): the `integrity` of the + /// first entry whose tarball is `file:` and carries an SRI pin. + pub fn wired_integrity(&self, artifact_rel: &str) -> Option { + self.wired_refs() + .filter(|r| { + let path = r.tarball.strip_prefix("file:").unwrap_or(r.tarball); + path.trim_start_matches("./") == artifact_rel + }) + .find_map(|r| r.integrity.filter(|sri| is_sri_pin(sri))) + .map(str::to_string) + } + + /// Is the vendored npm artifact of patch `uuid` still consumed by this + /// lock? `Some(true)` when a `packages:` / `snapshots:` block is keyed + /// by it ([`vendored_npm_uuid`] — v9's `name@file:` and legacy's bare + /// `file:` keys alike); `Some(false)` when the lock carries none (the + /// `overrides:` declaration alone never counts: pnpm keeps it mirrored + /// from package.json even when nothing matches it); `None` when + /// undeterminable — a CRLF lock (a Windows autocrlf checkout) defeats + /// the line grammar, so the scan would find nothing and call a lock + /// that still resolves through the artifact provably orphaned. Callers + /// keep the entry on `None`, fail-safe. + pub fn vendored_in_use(&self, uuid: &str) -> Option { + if self.text.contains('\r') { + return None; + } + Some(vendored_in_use_lines(&lines::split_lines(self.text), uuid)) + } +} + +/// [`PnpmLock::vendored_in_use`] over already-split (LF) lines — the +/// per-probe scan the v9 planner's lock index answers for when it has not +/// been built. +pub(crate) fn vendored_in_use_lines(lines: &[String], uuid: &str) -> bool { + for section in ["packages", "snapshots"] { + let Some((start, end)) = lines::section_bounds(lines, section) else { + continue; + }; + let mut i = start + 1; + while let Some(block) = lines::next_block(lines, i, end) { + if vendored_npm_uuid(&block.key).is_some_and(|u| u == uuid) { + return true; + } + i = block.end; + } + } + false +} + +impl LockModel for PnpmLock<'_> { + const FORMAT: &'static str = "pnpm-lock.yaml"; +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "11111111-1111-4111-8111-111111111111"; + + #[test] + fn resolves_reads_every_key_generation_boundary_anchored() { + let lock = |keys: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{keys}"); + let yes = [ + (" left-pad@1.3.0:\n resolution: {integrity: sha512-x}\n", "left-pad", "1.3.0"), + (" /left-pad@1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), + (" /left-pad/1.3.0:\n resolution: {}\n", "left-pad", "1.3.0"), + (" 'left-pad@1.3.0(react@18.0.0)':\n dev: false\n", "left-pad", "1.3.0"), + (" /left-pad/1.3.0_react@18.0.0:\n dev: false\n", "left-pad", "1.3.0"), + (" '@scope/name@1.0.0':\n dev: false\n", "@scope/name", "1.0.0"), + (" /@scope/name@1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), + (" /@scope/name/1.0.0:\n dev: false\n", "@scope/name", "1.0.0"), + ]; + for (keys, name, version) in yes { + assert!(PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + } + let no = [ + (" left-pad@1.3.0-beta.1:\n dev: false\n", "left-pad", "1.3.0"), + (" left-pad@1.3.0:\n dev: false\n", "pad", "1.3.0"), + (" /left-pad/1.3.0:\n dev: false\n", "pad", "1.3.0"), + (" '@scope/name@1.0.0':\n dev: false\n", "name", "1.0.0"), + (" /@scope/name@1.0.0:\n dev: false\n", "name", "1.0.0"), + (" left-pad@1.3.1:\n dev: false\n", "left-pad", "1.3.0"), + ( + &format!(" left-pad@file:.socket/vendor/npm/{UUID}/left-pad-1.3.0.tgz:\n version: 1.3.0\n"), + "left-pad", + "1.3.0", + ), + ]; + for (keys, name, version) in no { + assert!(!PnpmLock::parse(&lock(keys)).resolves(name, version), "{keys}"); + } + // Keys outside `packages:` (importers, overrides) resolve nothing. + let importers = "lockfileVersion: '9.0'\n\nimporters:\n\n left-pad@1.3.0:\n x: y\n"; + assert!(!PnpmLock::parse(importers).resolves("left-pad", "1.3.0")); + } + + #[test] + fn vendored_in_use_reads_v9_and_legacy_keys_and_refuses_crlf() { + let v9 = format!( + "lockfileVersion: '9.0'\n\npackages:\n\n a@file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz:\n resolution: {{integrity: sha512-x, tarball: file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz}}\n version: 1.0.0\n" + ); + let legacy = format!( + "lockfileVersion: 5.4\n\npackages:\n\n file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz:\n resolution: {{integrity: sha512-x, tarball: file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz}}\n name: a\n version: 1.0.0\n" + ); + let snapshot = format!( + "lockfileVersion: '9.0'\n\nsnapshots:\n\n a@file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz: {{}}\n" + ); + for text in [&v9, &legacy, &snapshot] { + assert_eq!(PnpmLock::parse(text).vendored_in_use(UUID), Some(true), "{text}"); + let other = "22222222-2222-4222-8222-222222222222"; + assert_eq!(PnpmLock::parse(text).vendored_in_use(other), Some(false)); + let crlf = text.replace('\n', "\r\n"); + assert_eq!(PnpmLock::parse(&crlf).vendored_in_use(UUID), None); + } + // An overrides declaration alone is not usage. + let overrides = format!( + "lockfileVersion: '9.0'\n\noverrides:\n a@1.0.0: file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz\n" + ); + assert_eq!(PnpmLock::parse(&overrides).vendored_in_use(UUID), Some(false)); + } + + #[test] + fn wired_integrity_reads_the_vendored_entry_pin_only() { + let sri = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="; + let rel = format!(".socket/vendor/npm/{UUID}/a-1.0.0.tgz"); + for text in [ + format!("lockfileVersion: '9.0'\n\npackages:\n\n a@file:{rel}:\n resolution: {{integrity: {sri}, tarball: file:{rel}}}\n version: 1.0.0\n"), + format!("lockfileVersion: 5.4\r\n\r\npackages:\r\n\r\n file:{rel}:\r\n resolution: {{integrity: {sri}, tarball: file:{rel}}}\r\n name: a\r\n"), + ] { + assert_eq!(PnpmLock::parse(&text).wired_integrity(&rel).as_deref(), Some(sri)); + } + // A neighbouring registry entry's pin never leaks into the answer. + let neighbour = format!( + "lockfileVersion: '9.0'\n\npackages:\n\n a@file:{rel}:\n version: 1.0.0\n\n b@1.0.0:\n resolution: {{integrity: {sri}}}\n" + ); + assert_eq!(PnpmLock::parse(&neighbour).wired_integrity(&rel), None); + } +} diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f95..01ed5652c 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod api; pub mod constants; pub mod crawlers; +pub mod formats; pub mod hash; pub mod manifest; pub mod package_json; diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index d6fb3831a..65bfdeb8f 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -23,7 +23,7 @@ fn serial_oracle( let overrides = withhold(&overrides, &result.refused_pipenv_uuids); let overrides: &[DepOverride] = &overrides; rewrite_npm_lock(files, overrides, &mut result); - rewrite_pnpm_lock(files, overrides, &mut result); + plan_hosted(files, overrides, &mut result); rewrite_yarn_classic(files, overrides, &mut result); rewrite_yarn_berry(files, overrides, &mut result); rewrite_bun_lock(files, overrides, &mut result); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 9f03dbaa4..53111f4d3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -45,9 +45,12 @@ pub mod npmrc; mod pdm; mod pipenv; pub mod presence; -// pub(crate): manifest-less VEX discovery (`vex::discover::npm`) reads -// hosted pnpm locks with the SAME grammar this rewriter writes them in. -pub(crate) mod pnpm; +// The pnpm entry grammar and hosted planner live with the format's model. +#[cfg(test)] +use crate::formats::pnpm::grammar as pnpm; +use crate::formats::pnpm::plan_hosted; +#[cfg(test)] +use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; #[cfg(test)] mod pnpm_equivalence_tests; mod poetry; @@ -257,7 +260,7 @@ pub struct RewriteResult { } /// Combined name as it appears in registry coordinates / lock keys. -fn full_name(dep: &DepOverride) -> String { +pub(crate) fn full_name(dep: &DepOverride) -> String { match &dep.namespace { Some(ns) if !ns.is_empty() => format!("{ns}/{}", dep.name), _ => dep.name.clone(), @@ -430,7 +433,7 @@ fn rewriter_groups<'a>( vec![ Box::new(move |result| { rewrite_npm_lock(files, overrides, result); - rewrite_pnpm_lock(files, overrides, result); + plan_hosted(files, overrides, result); rewrite_yarn_classic(files, overrides, result); rewrite_yarn_berry(files, overrides, result); rewrite_bun_lock(files, overrides, result); @@ -3250,421 +3253,6 @@ fn plan_cargo_config( }) } -// ── pnpm-lock.yaml ─────────────────────────────────────────────────────────── - -/// Test-only reference for the residual gate: every instance of this exact -/// name@version in `content` that does not resolve to `artifact_url`. -/// Production judges the same predicate inline, per instance, on each -/// indexed hit's post-splice body in `rewrite_pnpm_lock`; snapshots and -/// other versions do not participate in resolution. -#[cfg(test)] -fn pnpm_unrewritten_instances( - content: &str, - fname: &str, - version: &str, - artifact_url: &str, -) -> Vec { - pnpm::entries(content) - .into_iter() - .filter_map(|entry| { - pnpm::suffix(entry.key, fname, version)?; - (!pnpm_resolves_to(&entry, artifact_url)).then(|| entry.key.to_string()) - }) - .collect() -} - -/// Whether `entry` resolves to exactly `artifact_url` — the per-instance -/// residual-gate predicate. -fn pnpm_resolves_to(entry: &pnpm::Entry<'_>, artifact_url: &str) -> bool { - pnpm::resolution(entry).is_some_and(|r| r.tarball() == Some(artifact_url)) -} - -/// One pnpm lock under rewrite. `text` is the lock as of the last -/// materialization; `pending` holds the resolution splices committed since, -/// in `text`'s byte coordinates, and `spliced` the entries they touch. -/// -/// The logical (post-splice) lock is `text` with `pending` applied. Parsing -/// once and indexing is sound because a resolution splice never changes the -/// entry structure: the replaced range and its replacement are only -/// resolution-field material (6-space-indented `k: v` child lines of a block -/// resolution, or the `{…}` flow value after ` resolution:`), and no raw -/// newline can enter a value (`Resolution::rewrite` JSON-quotes whitespace). -/// So every column-0 line (the shrinkwrap-version sniff) and every entry -/// boundary line survives unchanged, and an entry no pending splice touched -/// has byte-identical key and body. An entry that WAS touched is re-read -/// only after materializing, so a later dep with the same name@version (a -/// duplicate override) sees the rewritten text exactly as before. -struct PnpmLockState<'f> { - path: &'f String, - text: Cow<'f, str>, - early_shrinkwrap: bool, - /// (key span, body span) per `packages:` entry, in file order. - entries: Vec<(std::ops::Range, std::ops::Range)>, - /// Entry indices sorted by normalized (unquoted, `/`-stripped) key. - sorted: Vec, - pending: Vec<(std::ops::Range, String)>, - spliced: std::collections::HashSet, - changed: bool, -} - -impl<'f> PnpmLockState<'f> { - fn new(path: &'f String, text: &'f str) -> Self { - let mut state = PnpmLockState { - path, - text: Cow::Borrowed(text), - early_shrinkwrap: pnpm::unsupported_early_shrinkwrap(text), - entries: Vec::new(), - sorted: Vec::new(), - pending: Vec::new(), - spliced: Default::default(), - changed: false, - }; - state.reindex(); - state - } - - fn reindex(&mut self) { - let text: &str = &self.text; - let base = text.as_ptr() as usize; - self.entries = pnpm::entries(text) - .iter() - .map(|e| { - let key_start = e.key.as_ptr() as usize - base; - ( - key_start..key_start + e.key.len(), - e.offset..e.offset + e.body.len(), - ) - }) - .collect(); - let mut sorted: Vec = (0..self.entries.len()).collect(); - sorted.sort_by(|&a, &b| self.norm_key(a).cmp(self.norm_key(b)).then(a.cmp(&b))); - self.sorted = sorted; - } - - fn entry(&self, i: usize) -> pnpm::Entry<'_> { - let (key, body) = &self.entries[i]; - pnpm::Entry { - key: &self.text[key.clone()], - body: &self.text[body.clone()], - offset: body.start, - } - } - - /// The key as [`pnpm::suffix`] compares it. - fn norm_key(&self, i: usize) -> &str { - let key = pnpm::unquote(&self.text[self.entries[i].0.clone()]); - key.strip_prefix('/').unwrap_or(key) - } - - /// Entries whose key names `fname@version` (any suffix), in file order — - /// the same set a full [`pnpm::suffix`] scan of the logical lock yields. - fn hits(&mut self, fname: &str, version: &str) -> Vec { - let hits = self.lookup(fname, version); - if hits.iter().any(|i| self.spliced.contains(i)) { - self.materialize(); - return self.lookup(fname, version); - } - hits - } - - fn lookup(&self, fname: &str, version: &str) -> Vec { - let mut out = Vec::new(); - for sep in ['@', '/'] { - let prefix = format!("{fname}{sep}{version}"); - let start = self - .sorted - .partition_point(|&i| self.norm_key(i) < prefix.as_str()); - out.extend( - self.sorted[start..] - .iter() - .take_while(|&&i| self.norm_key(i).starts_with(prefix.as_str())) - .copied(), - ); - } - out.sort_unstable(); - out.dedup(); - out.retain(|&i| pnpm::suffix(self.entry(i).key, fname, version).is_some()); - out - } - - /// Fold `pending` into `text` and re-parse. - fn materialize(&mut self) { - if self.pending.is_empty() { - return; - } - #[cfg(debug_assertions)] - let keys_before: Vec = (0..self.entries.len()) - .map(|i| self.entry(i).key.to_string()) - .collect(); - let mut pending = std::mem::take(&mut self.pending); - pending.sort_by_key(|(range, _)| range.start); - let mut out = String::with_capacity(self.text.len()); - let mut cursor = 0usize; - for (range, replacement) in pending { - out.push_str(&self.text[cursor..range.start]); - out.push_str(&replacement); - cursor = range.end; - } - out.push_str(&self.text[cursor..]); - self.text = Cow::Owned(out); - self.spliced.clear(); - self.reindex(); - #[cfg(debug_assertions)] - debug_assert_eq!( - keys_before, - (0..self.entries.len()) - .map(|i| self.entry(i).key.to_string()) - .collect::>(), - "a resolution splice changed the pnpm entry structure" - ); - } - - fn into_rewritten(mut self) -> Option<(&'f String, String)> { - if !self.changed { - return None; - } - self.materialize(); - Some((self.path, self.text.into_owned())) - } -} - -fn rewrite_pnpm_lock( - files: &BTreeMap, - overrides: &[DepOverride], - result: &mut RewriteResult, -) { - let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); - // A pnpm lock lives at the project root or at any nested path (e.g. Rush - // repos keep them under `common/config/rush/`); every such files-map key - // is rewritten under the same grammar. Deterministic order: BTreeMap - // iterates keys sorted, so goldens are stable across every lock in the set. - let lock_keys: Vec<&String> = files - .keys() - .filter(|k| { - matches!( - k.rsplit('/').next(), - Some("pnpm-lock.yaml" | "shrinkwrap.yaml") - ) - }) - .collect(); - if npm.is_empty() || lock_keys.is_empty() { - return; - } - // Each lock is parsed and indexed ONCE; splices accumulate per lock and - // are applied in one pass at the end (see `PnpmLockState`). - let mut locks: Vec = lock_keys - .iter() - .map(|k| PnpmLockState::new(k, &files[*k])) - .collect(); - for dep in &npm { - let fname = full_name(dep); - let hits: Vec> = locks - .iter_mut() - .map(|lock| lock.hits(&fname, &dep.version)) - .collect(); - let unsafe_locks: Vec<_> = locks - .iter() - .zip(&hits) - .filter(|(lock, hits)| lock.early_shrinkwrap && !hits.is_empty()) - .map(|(lock, _)| lock.path.as_str()) - .collect(); - if !unsafe_locks.is_empty() { - result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_legacy_lockfile_unsupported".into(), - detail: format!("{} uses early pnpm 1 shrinkwrapVersion 3 without a supported minor version. Those installers discard hosted tarball URLs; {fname}@{} was left unchanged in every lock. Upgrade to a tested pnpm release (1.43.1 or newer) and regenerate the lock, or use `scan --mode agent` for installed-file patching.", unsafe_locks.join(", "), dep.version), - }); - continue; - } - let Some(sha512) = dep.integrity.sha512.clone() else { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_missing_sha512".into(), - detail: format!("{fname}@{} has no sha512 integrity", dep.version), - }); - continue; - }; - // Every peer instance must be redirected, including nested peer - // contexts and the block resolutions emitted by pnpm 1–5. - let mut matched_any = false; - // Per-lock rewrites are PLANNED first and committed only after the - // residual gate below proves no instance of this dep escaped the - // splice grammar in ANY lock — committing lock-by-lock as we go - // would ship exactly the partial rewrite the gate exists to refuse. - type Splice = (usize, std::ops::Range, String); - let mut planned: Vec<(usize, Vec, Vec)> = Vec::new(); - let mut residuals: Vec<(&str, Vec)> = Vec::new(); - for (idx, (lock, hits)) in locks.iter().zip(&hits).enumerate() { - // (entry, byte range to replace, replacement text) per instance, - // plus one FileEdit per instance keyed by the canonical instance - // key — per-instance edits keep the revert ledger lossless when - // several instances of one dep live in the same lock. - let mut splices: Vec = Vec::new(); - let mut instance_edits: Vec = Vec::new(); - // Residual gate, judged per instance on its POST-splice body: - // any instance of this exact name@version still resolving - // somewhere other than the hosted artifact — in a spelling the - // splice grammar cannot parse (e.g. an unbalanced peer suffix) — - // makes this a partial rewrite. Shipping it would confirm and - // VEX-attest the dep while dependents through the unmatched - // instance keep installing the unpatched upstream tarball, so - // the dep is refused instead. - let mut leftover: Vec = Vec::new(); - for &i in hits { - let entry = lock.entry(i); - let suffix = pnpm::suffix(entry.key, &fname, &dep.version) - .expect("hits only holds entries naming this dep"); - let resolution = if pnpm::supported_suffix(suffix) { - pnpm::resolution(&entry) - } else { - None - }; - let Some(resolution) = resolution else { - if !pnpm_resolves_to(&entry, &dep.artifact_url) { - leftover.push(entry.key.to_string()); - } - continue; - }; - matched_any = true; - let original = &lock.text[resolution.range.clone()]; - let rebuilt = resolution.rewrite(&sha512, &dep.artifact_url); - let rel = - resolution.range.start - entry.offset..resolution.range.end - entry.offset; - let body = format!( - "{}{rebuilt}{}", - &entry.body[..rel.start], - &entry.body[rel.end..] - ); - let after = pnpm::Entry { - key: entry.key, - body: &body, - offset: 0, - }; - if !pnpm_resolves_to(&after, &dep.artifact_url) { - leftover.push(entry.key.to_string()); - } - if rebuilt == original { - continue; - } - instance_edits.push(FileEdit { - path: lock.path.clone(), - kind: "redirect_pnpm_resolution".into(), - action: "rewritten".into(), - key: Some(format!("{fname}@{}{suffix}", dep.version)), - original: Some(Value::String(original.to_string())), - new: Some(Value::String(rebuilt.clone())), - }); - splices.push((i, resolution.range, rebuilt)); - } - if !leftover.is_empty() { - residuals.push((lock.path.as_str(), leftover)); - continue; - } - if !splices.is_empty() { - planned.push((idx, splices, instance_edits)); - } - } - // ANY residual anywhere refuses the dep across the WHOLE lock set — - // nothing rewritten, nothing recorded, nothing confirmed: a rewrite - // committed in one lock while another still resolves the dep - // upstream would confirm the dep set-wide. - if !residuals.is_empty() { - result.refused_pnpm_uuids.insert(dep.patch_uuid.clone()); - for (lock_key, keys) in &residuals { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_unsupported_lock_key".into(), - detail: format!( - "{fname}@{} still resolves through pnpm lock key(s) whose \ - resolution the redirect grammar cannot repoint: {} in \ - {lock_key}; the dep is left unredirected in EVERY lock \ - (nothing rewritten, nothing confirmed) — regenerate the \ - lock with a current pnpm (lockfileVersion 9) and re-run", - dep.version, - keys.join(", ") - ), - }); - } - continue; - } - for (idx, splices, mut instance_edits) in planned { - let lock = &mut locks[idx]; - for (i, range, replacement) in splices { - lock.spliced.insert(i); - lock.pending.push((range, replacement)); - } - lock.changed = true; - result.edits.append(&mut instance_edits); - } - // The entry-not-found warning fires only when the dep matched in NO - // pnpm lock across the whole set, not once per lock. A VENDORED dep - // is named as such: `socket-patch vendor` removes the registry - // resolution this grammar looks for (v9 respells the packages key - // `@file:.socket/vendor/…`; v5/v6 rekey it to a bare `file:` - // key but keep the `@: file:…` overrides line), so - // the generic not-locked wording would send users on a wild-goose - // `pnpm install` when the real path is a mode switch. Fail-closed - // either way: nothing is rewritten for the dep. - if !matched_any { - let v9_vendored_key = format!("{fname}@file:"); - let override_key = format!("{fname}@{}", dep.version); - // Scanned over the post-splice text, so fold pending splices in. - for lock in locks.iter_mut() { - lock.materialize(); - } - let vendored = locks.iter().any(|lock| { - lock.text.lines().any(|line| { - let t = line.trim_start(); - let t = t.strip_prefix('\'').unwrap_or(t); - // v9 packages/snapshots key (leading `/` in v6 spelling). - // The vendor backend always writes the RELATIVE - // `file:.socket/vendor/…` spelling here, so anchoring on - // it keeps a user's own `file:` dep of the same name - // from being misreported as vendored. - let key = t.strip_prefix('/').unwrap_or(t); - if key - .strip_prefix(&v9_vendored_key) - .is_some_and(|rest| rest.starts_with(".socket/vendor/")) - { - return true; - } - // overrides / root-dep line: `@: file:…` - // (pnpm <=8 absolutizes the value, so only the - // `.socket/vendor/` tail is stable enough to match). - t.strip_prefix(&override_key) - .map(|rest| rest.strip_prefix('\'').unwrap_or(rest)) - .and_then(|rest| rest.strip_prefix(':')) - .is_some_and(|rest| { - rest.contains("file:") && rest.contains(".socket/vendor/") - }) - }) - }); - if vendored { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_entry_vendored".into(), - detail: format!( - "{fname}@{} has no registry resolution because it is \ - VENDORED (the lock resolves it to a \ - file:.socket/vendor/… tarball); the hosted redirect \ - does not apply — run `socket-patch vendor --revert` to \ - restore the registry resolution, then re-run `scan \ - --mode hosted`", - dep.version - ), - }); - } else { - result.warnings.push(RewriteWarning { - code: "redirect_pnpm_entry_not_found".into(), - detail: format!("no resolution for {fname}@{}", dep.version), - }); - } - } - } - for lock in locks { - if let Some((key, content)) = lock.into_rewritten() { - result.files.insert(key.clone(), content); - } - } -} - // ── yarn.lock (classic) ────────────────────────────────────────────────────── fn rewrite_yarn_classic( files: &BTreeMap, @@ -13856,7 +13444,7 @@ packages: } /// The same boundaries, judged by the PRODUCTION inline residual gate - /// (`rewrite_pnpm_lock` over indexed hits), not the reference probe: an + /// (`plan_hosted` over indexed hits), not the reference probe: an /// instance already on the hosted artifact, a longer version sharing /// the prefix, a different quoted scoped package and resolution-less /// `snapshots:` keys never count as residuals, v6 nested-paren and v5 @@ -14972,7 +14560,7 @@ packages: pnpm_v9_lock("left-pad", "1.3.0").replace('\n', "\r\n"), ); let mut r = RewriteResult::default(); - rewrite_pnpm_lock(&files, std::slice::from_ref(&ovr), &mut r); + plan_hosted(&files, std::slice::from_ref(&ovr), &mut r); let out = &r.files["pnpm-lock.yaml"]; assert!(out.contains("tarball: http://patch.test/left-pad-1.3.0.tgz")); assert!(!out.replace("\r\n", "").contains('\n')); diff --git a/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs index 60bd85161..906427f0d 100644 --- a/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/pnpm_equivalence_tests.rs @@ -1,7 +1,7 @@ //! Equivalence oracle for the indexed pnpm hosted rewriter: the previous //! implementation (re-parse every lock per dep, splice per dep) is kept here //! verbatim as `rewrite_pnpm_lock_oracle`, and the production -//! `rewrite_pnpm_lock` must produce the identical `RewriteResult` — output +//! `plan_hosted` must produce the identical `RewriteResult` — output //! bytes, the FileEdit list (order and `original` fragments), warnings and //! refusals — on depscan-sized synthetic locks and on randomized mixes of //! every lock flavor the grammar handles. @@ -14,7 +14,7 @@ fn assert_equivalent(files: &BTreeMap, overrides: &[DepOverride] let mut want = RewriteResult::default(); rewrite_pnpm_lock_oracle(files, overrides, &mut want); let mut got = RewriteResult::default(); - rewrite_pnpm_lock(files, overrides, &mut got); + plan_hosted(files, overrides, &mut got); assert_same(&want, &got, "pnpm"); got } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 15330b99d..8f0e187d9 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -74,7 +74,6 @@ pub(crate) use self::composer::{composer_lock_packages, ComposerLockPackage}; pub(crate) use self::npm::{npm_lock_nodes, NpmLockNode}; #[cfg(test)] pub(crate) use self::npm_family::inventory_npm_lock; -pub(crate) use self::pnpm::pnpm_registry_key; pub(crate) use self::pypi::pipfile_lock_entries; pub use self::recover::recover_lock_entry; pub use self::view::{MemoryEntry, MemoryProject, ProjectView}; @@ -173,7 +172,7 @@ pub struct LockfileEntry { } impl LockfileEntry { - fn npm( + pub(crate) fn npm( name: impl Into, version: impl Into, resolved: Option, @@ -364,7 +363,7 @@ fn dedup_prefer_integrity(raw: Vec) -> Vec { /// (drops `git+…`, `file:…`, `link:…` — content the registry conventions /// cannot reproduce; such entries stay listed for discovery but the fetch /// layer's integrity rule decides fetchability). -fn http_url(raw: &str) -> Option { +pub(crate) fn http_url(raw: &str) -> Option { (raw.starts_with("https://") || raw.starts_with("http://")).then(|| raw.to_string()) } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs index 9d78c288a..27aefcefc 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs @@ -1,160 +1,16 @@ //! `pnpm-lock.yaml` (every generation, pnpm <= 2's `shrinkwrap.yaml`) and -//! Rush's pnpm locks: the entry model lockfile discovery shares -//! ([`pnpm_packages`], [`classify_pnpm_key`]), Rush's lock enumeration and -//! the registry view. +//! Rush's pnpm locks: Rush's lock enumeration and the registry view over the +//! format's model ([`crate::formats::pnpm::PnpmLock`]). use std::path::Path; use crate::constants::npm_family::{PNPM_LOCK, RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR}; +use crate::formats::pnpm::PnpmLock; use crate::patch::path_safety::is_safe_single_segment; -use crate::patch::redirect::pnpm; use crate::utils::fs::read_regular_to_string; -use crate::vendor::path::parse_vendor_path; use super::view::ProjectView; -use super::{http_url, LockIntegrity, LockfileEntry}; - -// ── entry model ── - -/// One `packages:` entry of a pnpm lock, read with the hosted rewriter's -/// own grammar ([`pnpm::entries`] / [`pnpm::resolution`]: two-space keys, a -/// flat flow or block `resolution:` map, CRLF included). -pub(crate) struct PnpmPackage<'a> { - /// The packages key, trimmed and unquoted. - pub(crate) key: &'a str, - pub(crate) entry: pnpm::Entry<'a>, - /// The entry's `resolution:` map; `None` when it has none or the - /// grammar refuses it (duplicate keys, nested values, aliases). - pub(crate) resolution: Option>, -} - -impl<'a> PnpmPackage<'a> { - /// The unquoted tokens of the entry's raw `resolution:` text - /// ([`pnpm::resolution_raw_lines`] split on whitespace and flow - /// punctuation) — what a reader inspects when the grammar refused the - /// map (`resolution` is `None`) and it must still tell a Socket-shaped - /// value from anything else. - pub(crate) fn resolution_tokens(&self) -> Vec<&'a str> { - pnpm::resolution_raw_lines(&self.entry) - .into_iter() - .flat_map(|text| { - text.split(|c: char| c.is_whitespace() || matches!(c, ',' | '{' | '}' | '[' | ']')) - }) - .map(|token| pnpm::unquote(token.trim())) - .filter(|token| !token.is_empty()) - .collect() - } -} - -/// Every `packages:` entry of a pnpm lock text, in lock order — the ONE -/// entry walk the lock inventory and lockfile discovery -/// (`vex::discover::npm`) share. Every entry is returned (registry, rekeyed -/// vendored, hosted, directory, git); each consumer applies its own key and -/// resolution rules. -pub(crate) fn pnpm_packages(text: &str) -> Vec> { - pnpm::entries(text) - .into_iter() - .map(|entry| PnpmPackage { - key: pnpm::unquote(entry.key.trim()), - resolution: pnpm::resolution(&entry), - entry, - }) - .collect() -} - -/// How a pnpm packages key names its package. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PnpmKey<'a> { - /// A registry key in any lock generation ([`pnpm_registry_key`]). - Registry { name: &'a str, version: &'a str }, - /// v9's rekeyed vendored entry `name@file:` (`vendor::pnpm_lock`); - /// `path` is the raw `file:` spec, peer suffix stripped. - V9File { name: &'a str, path: &'a str }, - /// v5.4 / v6.0's rekeyed vendored entry, the bare `file:` key - /// (`vendor::pnpm_lock_legacy`); the name is on the entry's `name:` - /// line. - LegacyFile { path: &'a str }, - /// Anything else (url, git, `link:`, v5 non-default-registry keys). - Other, -} - -/// Classify a packages key, in this order: a legacy `file:` key, a v9 -/// `name@file:` key (the `@` after a scope's leading one), a registry key, -/// else [`PnpmKey::Other`]. The `file:` paths are returned raw — the CALLER -/// anchors them (lockfile discovery with its root-anchored `vendor_ref`, -/// the writers with `parse_vendor_path`). -pub(crate) fn classify_pnpm_key(key: &str) -> PnpmKey<'_> { - let base = strip_pnpm_peer_suffix(key); - if base.starts_with("file:") { - PnpmKey::LegacyFile { path: base } - } else if let Some(at) = base.get(1..).and_then(|rest| rest.find("@file:")) { - PnpmKey::V9File { - name: &base[..at + 1], - path: &base[at + 2..], - } - } else { - match pnpm_registry_key(base) { - Some((name, version)) => PnpmKey::Registry { name, version }, - None => PnpmKey::Other, - } - } -} - -/// A pnpm packages key without its v6+ peer suffix (`(peer@1.0.0)…`). -pub(crate) fn strip_pnpm_peer_suffix(key: &str) -> &str { - key.find('(').map_or(key, |p| key[..p].trim_end()) -} - -/// `(name, version)` of a REGISTRY-form pnpm packages key, in every lock -/// generation's grammar — v9 `name@version`, v6 (pnpm 8) the same behind a -/// leading `/`, v5.4 (pnpm 7) and shrinkwrap `/name/version`; names may be -/// scoped in all of them. Peer suffixes are stripped: v6/v9 append -/// `(peer@1.2.3)…` after the version, v5 appends `_peer@x` / `_` to -/// the version itself. `None` for anything that is not a plain registry -/// version (digit-first): `file:` / `link:` / url / git keys and v5 -/// non-default-registry keys. The ONE key rule the lock inventory and -/// lockfile discovery (`vex::discover::npm`) share, so both read a key as -/// the same package. -pub(crate) fn pnpm_registry_key(key: &str) -> Option<(&str, &str)> { - let base = strip_pnpm_peer_suffix(key); - let (base, legacy) = match base.strip_prefix('/') { - Some(stripped) => (stripped, true), - None => (base, false), - }; - let (name, version) = split_pnpm_key(base, legacy)?; - let version = version.split('_').next().unwrap_or(version); - version - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit()) - .then_some((name, version)) -} - -/// Split a peer-paren-stripped, slash-stripped pnpm packages key into -/// `(name, version)`; `None` is skipped by the caller, never guessed. -/// `legacy` marks a key that carried the v5/v6 leading `/` — only those may -/// use the v5 `name/version` grammar. What tells v5 `/@scope/name/1.2.3` -/// apart from v6 `/@scope/name@1.2.3` is the segment after the last `/`: -/// a v5 version (its `_peer`/`_hash` suffix dropped) starts with a digit -/// and never contains `@`, while a v6 scoped key's trailing segment is -/// `name@version`. v5 non-default-registry keys (`example.com/name/1.2.3`) -/// carry no leading `/` and fall through to the `@` split, where they are -/// dropped fail-closed downstream. -fn split_pnpm_key(base: &str, legacy: bool) -> Option<(&str, &str)> { - if legacy { - if let Some((name, rest)) = base.rsplit_once('/') { - let version = rest.split('_').next().unwrap_or(rest); - if !name.is_empty() - && version.chars().next().is_some_and(|c| c.is_ascii_digit()) - && !version.contains('@') - { - return Some((name, version)); - } - } - } - let at = base.rfind('@').filter(|&p| p > 0)?; - Some((&base[..at], &base[at + 1..])) -} +use super::LockfileEntry; // ── file selection ── @@ -210,50 +66,15 @@ pub(super) async fn inventory_pnpm_lock_rel_in( } /// Inventory a specific `pnpm-lock.yaml` (path given explicitly so the Rush -/// fallback can point it at `common/config/rush/…` and subspace locks). -/// Entries come from the shared entry model ([`pnpm_packages`] — the walk -/// lockfile discovery uses too): flow and block `resolution:` maps, every -/// key generation ([`pnpm_registry_key`]), CRLF included. A resolution the -/// grammar refuses leaves the entry listed without a verifier. `None` when -/// the lock has no `packages:` section. +/// fallback can point it at `common/config/rush/…` and subspace locks): +/// [`PnpmLock::entries`], `None` when the lock has no `packages:` section. pub(super) async fn inventory_pnpm_lock_at(lock_path: &Path) -> Option> { let text = read_regular_to_string(lock_path).await.ok()?; pnpm_lock_text_inventory(&text) } fn pnpm_lock_text_inventory(text: &str) -> Option> { - if !text - .lines() - .any(|l| l.trim_end_matches('\r') == "packages:") - { - return None; - } - let mut out = Vec::new(); - for package in pnpm_packages(text) { - // Only plain registry versions: `file:`/`link:`/`https:`/git specs - // are not registry-resolvable. - let Some((name, version)) = pnpm_registry_key(package.key) else { - continue; - }; - let resolution = package.resolution; - let integrity = resolution - .as_ref() - .and_then(|r| r.integrity()) - .map(|i| LockIntegrity::Sri(i.to_string())) - .unwrap_or(LockIntegrity::None); - let tarball = resolution.as_ref().and_then(|r| r.tarball()); - // Our own vendored spec: not a registry dependency. - if tarball.is_some_and(|t| parse_vendor_path(t).is_some()) { - continue; - } - out.push(LockfileEntry::npm( - name, - version, - tarball.and_then(http_url), - integrity, - )); - } - Some(out) + PnpmLock::parse(text).entries() } /// Inventory a Rush monorepo's pnpm locks. Rush keeps a single 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 0e58a9a81..ac64c5923 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -320,22 +320,17 @@ fn recover_npm_fragment( return Ok(mk(resolved, LockIntegrity::Sri(sri.to_string()))); } } - // pnpm: the original is the packages block's lines; pull - // `resolution: {integrity: …, tarball: …}`. + // pnpm: the original is the packages block's lines, read with the + // format model's entry grammar. if let Some(lines) = wiring_original(entry, &["pnpm_lock_package"]).and_then(lines_of) { - let mut sri = None; - let mut tarball = None; - for line in &lines { - if let Some(v) = inline_yaml_field(line, "integrity:") { - sri = sri.or(Some(v)); - } - if let Some(v) = inline_yaml_field(line, "tarball:") { - tarball = tarball.or(http_url(&v)); + if let Some((Some(sri), tarball)) = crate::formats::pnpm::fragment_resolution(&lines) { + if is_sri_pin(&sri) { + return Ok(mk( + tarball.as_deref().and_then(http_url), + LockIntegrity::Sri(sri), + )); } } - if let Some(sri) = sri.filter(|s| is_sri_pin(s)) { - return Ok(mk(tarball, LockIntegrity::Sri(sri))); - } } // yarn classic: block lines carry `integrity ` (preferred) and/or // `resolved "#"`. 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 e6f31357b..238877169 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2419,7 +2419,7 @@ fn pnpm_resolution_tokens_cover_maps_the_grammar_refuses() { let lock = format!( "lockfileVersion: '9.0'\n\npackages:\n\n x@1.0.0:\n resolution:\n tarball: {url}\n nested:\n a: b\n\n y@1.0.0:\n resolution: {{integrity: sha512-a}}\n resolution: {{tarball: '{url}'}}\n\n z@1.0.0:\n resolution: {{integrity: sha512-z,\n tarball: \"{url}\"}}\n\n ok@1.0.0:\n resolution: {{integrity: sha512-ok}}\n" ); - let packages = super::pnpm::pnpm_packages(&lock); + let packages = crate::formats::pnpm::pnpm_packages(&lock); let by_key = |key: &str| packages.iter().find(|p| p.key == key).unwrap(); for key in ["x@1.0.0", "y@1.0.0", "z@1.0.0"] { let package = by_key(key); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 4f603f1df..106c85c77 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -16,7 +16,7 @@ use crate::utils::fs::{ read_regular_to_bytes, read_regular_to_string, read_regular_to_string_sync, }; use crate::vendor::npm_flavor::NpmLockFlavor; -use crate::vendor::pnpm_lock_legacy::{sniff_lock_grammar, PnpmLockGrammar}; +use crate::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; use crate::vendor::VendorWarning; /// One in-memory file. 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 35c2a2f33..50b57d816 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -6,6 +6,7 @@ use std::path::Path; 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::utils::digest::is_sri_pin; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use crate::utils::python_lock::{ @@ -23,9 +24,9 @@ use super::LockIntegrity; /// anchor for repair's no-ledger reconstruction: a rebuilt tarball that /// matches it is exactly what the package manager would have installed. /// -/// package-lock/shrinkwrap are parsed as JSON; the text formats (pnpm, -/// yarn classic/berry, bun) are scanned with a bounded forward window from -/// each reference line. vlt yields `None`: its `file` nodes pin no +/// package-lock/shrinkwrap are parsed as JSON, pnpm through its format +/// model; the other text formats (yarn classic/berry, bun) are scanned with +/// a bounded forward window from each reference line. vlt yields `None`: its `file` nodes pin no /// integrity (slot [2] is `null`), and `vlt-lock.json` is never scanned, /// because the forward window would pick up a neighbouring node's sha512. pub async fn wired_vendor_integrity( @@ -130,9 +131,16 @@ pub async fn wired_vendor_integrity( } } - // Text locks: any line referencing the artifact path, integrity within - // a short forward window (the same block). - for lock in [PNPM_LOCK, "yarn.lock", BUN_LOCK] { + // pnpm: the format model's vendored entry (every key generation). + if let Ok(text) = read_regular_to_string(&project_root.join(PNPM_LOCK)).await { + if let Some(sri) = PnpmLock::parse(&text).wired_integrity(rel) { + return Some(LockIntegrity::Sri(sri)); + } + } + + // yarn / bun text locks: any line referencing the artifact path, + // integrity within a short forward window (the same block). + for lock in ["yarn.lock", BUN_LOCK] { let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else { continue; }; @@ -142,8 +150,7 @@ pub async fn wired_vendor_integrity( continue; } for probe in lines.iter().take((i + 6).min(lines.len())).skip(i) { - // pnpm `resolution: {integrity: …}` / classic `integrity …` - // / bun tuple `"sha512-…"`. + // classic `integrity …` / bun tuple `"sha512-…"`. if let Some(v) = inline_yaml_field(probe, "integrity:") { if is_sri_pin(&v) { return Some(LockIntegrity::Sri(v)); diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 25a976a0e..b019ce143 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -25,7 +25,7 @@ use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; -use super::pnpm_lock_legacy::PnpmLockGrammar; +use crate::formats::pnpm::PnpmLockGrammar; use super::source::PackageSource; use super::state::VendorEntry; use super::{ @@ -215,7 +215,7 @@ pub(crate) async fn detect_npm_lock_flavor( // anything else refuses with the sniff's version-aware remedy. if exists(PNPM_LOCK).await { let text = read_lock(project_root, PNPM_LOCK).await?; - match pnpm_lock_legacy::sniff_lock_grammar(&text) { + match crate::formats::pnpm::sniff_lock_grammar(&text) { Ok(PnpmLockGrammar::V9) => break 'flavor NpmLockFlavor::Pnpm, Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => { break 'flavor NpmLockFlavor::PnpmLegacy diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index f62b4ae68..498bb1fc9 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -72,6 +72,13 @@ use super::state::{ }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::constants::npm_family::PNPM_LOCK; +use crate::formats::pnpm::{ + check_v9_lock_version as check_lock_version, vendored_in_use_lines, vendored_npm_uuid, +}; +use crate::formats::pnpm::lines::{ + indent_of, next_block, parse_key_line, section_bounds, split_lines, unquote_value, yaml_key, + yaml_key_like, YamlBlock, +}; const PACKAGE_JSON: &str = "package.json"; const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; @@ -83,10 +90,6 @@ const PNPM_WORKSPACE: &str = "pnpm-workspace.yaml"; /// workspace the way `packages/*` would. const WS_SCAFFOLD_PACKAGES: [&str; 2] = ["packages:", " - '.'"]; -/// The only lockfileVersion the surgery has byte-exact fixtures for (both -/// pnpm 9 and 10 emit it). -const SUPPORTED_LOCK_VERSION: &str = "9.0"; - /// Wiring kinds (the `WiringRecord.kind` discriminators this backend owns). pub(super) const KIND_PKG_OVERRIDE: &str = "pnpm_pkg_override"; const KIND_WS_OVERRIDE: &str = "pnpm_ws_override"; @@ -559,50 +562,22 @@ pub async fn pnpm_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Opti if check_lock_version(&text).is_err() { return None; } - // CRLF (a Windows autocrlf checkout) breaks every structural probe - // below: the scan would find nothing and call a lock that still - // resolves through the artifact "provably orphaned" — undeterminable, - // keep (the unwired-revert guard then refuses, fail-closed). + // CRLF is undeterminable (see `PnpmLock::vendored_in_use`). if text.contains('\r') { return None; } // Every `packages:`/`snapshots:` block key resolving into // `.socket/vendor/npm//`, collected once per lock bytes (see // [`LockIndex`]) once these bytes are probed again; the first probe - // runs [`pnpm_entry_in_use_scan`], the per-call scan it answers for. + // runs the model's per-call scan ([`vendored_in_use_lines`]). let doc = LOCK_MEMO.parse_infallible(text.as_bytes(), || LockDoc::new(split_lines(&text))); doc.note_probe(); Some(match doc.index() { Some(index) => index.vendored_npm_uuids.contains(&entry.uuid), - None => pnpm_entry_in_use_scan(&entry.uuid, &doc.lines), + None => vendored_in_use_lines(&doc.lines, &entry.uuid), }) } -/// The pre-index [`pnpm_entry_in_use`] body over already-split lines: the -/// answer for a lock probed once, and the equivalence oracle for the -/// indexed answer. -fn pnpm_entry_in_use_scan(uuid: &str, lines: &[String]) -> bool { - for section in ["packages", "snapshots"] { - let Some((start, end)) = section_bounds(lines, section) else { - continue; - }; - let mut i = start + 1; - while let Some(block) = next_block(lines, i, end) { - let resolved_to_ours = block - .key - .find("@file:") - .map(|at| &block.key[at + 1..]) - .and_then(parse_vendor_path) - .is_some_and(|p| p.eco == "npm" && p.uuid == uuid); - if resolved_to_ours { - return true; - } - i = block.end; - } - } - false -} - /// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, /// shared by both pnpm backends. /// @@ -1120,46 +1095,6 @@ impl EditCtx<'_> { // ─────────────────────────── pre-flight checks ─────────────────────────── -/// `lockfileVersion: '9.0'` head check (accept pnpm's single quotes plus -/// double-quoted/bare spellings) — the v9 BACKEND's own guard. The flavor -/// router sniffs with [`super::pnpm_lock_legacy::sniff_lock_grammar`] -/// instead, whose allowlist also routes the legacy 5.4/6.0 grammars to -/// their backend; this check only fires if a non-9.0 lock reaches -/// `vendor_pnpm` directly. -pub(super) fn check_lock_version(text: &str) -> Result<(), String> { - let version = text - .lines() - .take(5) - .find_map(|line| line.strip_prefix("lockfileVersion:")) - .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); - match version { - Some(v) if v == SUPPORTED_LOCK_VERSION => Ok(()), - Some(v) => { - // The remedy must point the right way: 5.x (pnpm 7) / 6.x - // (pnpm 8) locks predate the v9 grammar and upgrading pnpm - // re-locks them, but a HIGHER version means the user's pnpm - // already outgrew this build — telling them "re-lock with - // pnpm >= 9" would loop them back to the lock they have. - let major = v.split('.').next().and_then(|m| m.parse::().ok()); - Err(match major { - Some(m) if m < 9 => format!( - "{PNPM_LOCK} has lockfileVersion {v}; only {SUPPORTED_LOCK_VERSION} is \ - supported — re-lock with pnpm >= 9" - ), - _ => format!( - "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ - lockfileVersion {SUPPORTED_LOCK_VERSION} — re-lock with a pnpm release \ - that emits it, or update socket-patch" - ), - }) - } - None => Err(format!( - "{PNPM_LOCK} has no lockfileVersion in its head; only \ - {SUPPORTED_LOCK_VERSION} is supported — re-lock with pnpm >= 9" - )), - } -} - /// The package-name component of a pnpm override key /// (`[@scope/]name[@range]`, possibly behind a `parent>child` selector /// chain — the override targets the LAST segment). @@ -2661,14 +2596,8 @@ impl LockIndex { for section in [&index.packages, &index.snapshots] { for block in §ion.blocks { - if let Some(parts) = block - .key - .find("@file:") - .map(|at| &block.key[at + 1..]) - .and_then(parse_vendor_path) - .filter(|p| p.eco == "npm") - { - index.vendored_npm_uuids.insert(parts.uuid); + if let Some(uuid) = vendored_npm_uuid(&block.key) { + index.vendored_npm_uuids.insert(uuid); } } } @@ -3245,155 +3174,7 @@ async fn unwind_override_surfaces( } } -// ───────────────────────────── guarded reads ────────────────────────────── - -// ─────────────────────── yaml-ish line-block helpers ────────────────────── -// pnpm-lock.yaml is machine-emitted with a fixed 2/4/6/8-space shape; these -// helpers splice line blocks and never interpret YAML generically. - -pub(super) fn split_lines(text: &str) -> Vec { - text.split('\n').map(str::to_string).collect() -} - -/// `(header_idx, end_idx)` of a top-level `name:` section; `end` is the -/// first following column-0 line (exclusive), so trailing blank separator -/// lines belong to the section. -pub(super) fn section_bounds(lines: &[String], name: &str) -> Option<(usize, usize)> { - let header = format!("{name}:"); - let start = lines.iter().position(|l| l == &header)?; - let end = lines - .iter() - .enumerate() - .skip(start + 1) - .find(|(_, l)| !l.is_empty() && !l.starts_with(' ')) - .map(|(i, _)| i) - .unwrap_or(lines.len()); - Some((start, end)) -} - -/// One 2-space-keyed block inside a section (`[header, end)`; `end` stops at -/// the blank separator / next block header, so the captured fragment is the -/// verbatim entry without surrounding blanks). -pub(super) struct YamlBlock { - pub(super) header: usize, - pub(super) end: usize, - pub(super) key: String, - /// The key exactly as spelled in the file (incl. quotes) — rekeys - /// preserve the file's quoting style. - repr: String, - /// Inline value after `:` (e.g. `{}` for empty snapshots), `""` if none. - rest: String, -} - -impl YamlBlock { - /// The inline-rest suffix to re-emit after the (re)written key. - fn rest_suffix(&self) -> String { - if self.rest.is_empty() { - String::new() - } else { - format!(" {}", self.rest) - } - } -} - -/// The next block at or after line `i` (within `[i, end)`). -pub(super) fn next_block(lines: &[String], mut i: usize, end: usize) -> Option { - while i < end { - if let Some((key, repr, rest)) = parse_key_line(&lines[i], 2) { - let mut j = i + 1; - while j < end && !lines[j].is_empty() && indent_of(&lines[j]) >= 4 { - j += 1; - } - return Some(YamlBlock { - header: i, - end: j, - key: key.to_string(), - repr: repr.to_string(), - rest: rest.to_string(), - }); - } - i += 1; - } - None -} - -pub(super) fn indent_of(line: &str) -> usize { - line.len() - line.trim_start_matches(' ').len() -} - -/// Parse a mapping line at exactly `indent` spaces into -/// `(key, verbatim_key_repr, value_after_colon)`. Accepts pnpm's bare keys -/// and both quote styles (single quotes are what pnpm emits for `@`-leading -/// keys); the value separator is the first `:` followed by a space or EOL -/// (keys themselves contain `:` in `file:` specs). -/// -/// All three are slices of `line`. Every scan below runs this over whole -/// `packages:` / `snapshots:` sections once per vendored package, so owning -/// copies would dominate the surgery's CPU on a multi-megabyte lock. A -/// caller that keeps a piece past the next edit to `lines` copies it itself. -pub(super) fn parse_key_line(line: &str, indent: usize) -> Option<(&str, &str, &str)> { - if line.len() <= indent || !line.as_bytes()[..indent].iter().all(|&b| b == b' ') { - return None; - } - let s = &line[indent..]; - let c0 = s.as_bytes()[0]; - if c0 == b' ' { - return None; - } - if c0 == b'\'' || c0 == b'"' { - let quote = c0 as char; - let close = s[1..].find(quote)? + 1; - let after = &s[close + 1..]; - let rest = after.strip_prefix(':')?; - let rest = rest.strip_prefix(' ').unwrap_or(rest); - return Some((&s[1..close], &s[..close + 1], rest)); - } - let bytes = s.as_bytes(); - for i in 0..bytes.len() { - if bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') { - if i == 0 { - return None; - } - let rest = if i + 1 < bytes.len() { &s[i + 2..] } else { "" }; - return Some((&s[..i], &s[..i], rest)); - } - } - None -} - -/// Strip one matching pair of surrounding quotes from a mapping VALUE -/// (pnpm quotes values that would misparse as plain YAML scalars, e.g. the -/// default-catalog specifier `'catalog:'`). -fn unquote_value(value: &str) -> &str { - let bytes = value.as_bytes(); - if bytes.len() >= 2 - && (bytes[0] == b'\'' || bytes[0] == b'"') - && bytes[bytes.len() - 1] == bytes[0] - { - &value[1..value.len() - 1] - } else { - value - } -} - -/// pnpm quotes `@`-leading keys with single quotes; everything we write is -/// otherwise bare. -pub(super) fn yaml_key(key: &str) -> String { - if key.starts_with('@') { - format!("'{key}'") - } else { - key.to_string() - } -} - -/// Re-spell `key` in the same quoting style as the original `repr`. -pub(super) fn yaml_key_like(key: &str, original_repr: &str) -> String { - match original_repr.as_bytes().first() { - Some(b'\'') => format!("'{key}'"), - Some(b'"') => format!("\"{key}\""), - _ => yaml_key(key), - } -} +// ─────────────────────────── wiring record helpers ────────────────────────── pub(super) fn lines_value(lines: &[String]) -> Value { Value::Array(lines.iter().map(|l| Value::String(l.clone())).collect()) @@ -5285,7 +5066,7 @@ snapshots: /// pnpm >= 9" would loop those users back to the lock they have. #[test] fn lock_version_remedy_is_version_aware() { - use super::super::pnpm_lock_legacy::{sniff_lock_grammar, PnpmLockGrammar}; + use crate::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; assert!(check_lock_version("lockfileVersion: '9.0'\n").is_ok()); assert_eq!( @@ -8577,7 +8358,7 @@ snapshots: for uuid in [UUID, OTHER_UUID] { assert_eq!( index.vendored_npm_uuids.contains(uuid), - pnpm_entry_in_use_scan(uuid, &lines), + vendored_in_use_lines(&lines, uuid), "seed {seed} in-use {uuid}" ); } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index f82b45676..b9d0b06c1 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -73,9 +73,12 @@ use super::npm_common::{ use super::path::parse_vendor_path; use super::pnpm_lock::{ apply_pkg_override, check_lock_override, classify_pkg_override, commit_surfaces, drifted, - guard_unwired_revert, lines_value, next_block, overrides_record, parse_key_line, - revert_overrides_line, revert_pkg_record, section_bounds, split_lines, value_lines, - vendor_value_is_for, yaml_key, yaml_key_like, KIND_LOCK_OVERRIDES, + guard_unwired_revert, lines_value, overrides_record, revert_overrides_line, + revert_pkg_record, value_lines, vendor_value_is_for, KIND_LOCK_OVERRIDES, +}; +use crate::formats::pnpm::{sniff_lock_grammar, PnpmLock, PnpmLockGrammar}; +use crate::formats::pnpm::lines::{ + next_block, parse_key_line, section_bounds, split_lines, yaml_key, yaml_key_like, }; use super::source::PackageSource; use super::state::{ @@ -164,67 +167,6 @@ pub fn normalize_canonical_root(path: &str) -> String { } } -// ───────────────────────────── grammar sniff ────────────────────────────── - -/// Which pnpm lock grammar a `pnpm-lock.yaml` head declares. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum PnpmLockGrammar { - /// `lockfileVersion: '9.0'` — the [`super::pnpm_lock`] backend. - V9, - /// `lockfileVersion: 5.4` (pnpm 7, bare float spelling). - V54, - /// `lockfileVersion: '6.0'` (pnpm 8). - V60, -} - -/// The full vendor allowlist sniff (5.4 / 6.0 / 9.0) the flavor router -/// uses; anything else refuses with a version-aware remedy: pre-allowlist -/// versions (pnpm <= 6's 5.x line) are fixed by upgrading pnpm, but a -/// FUTURE version means the user's pnpm already outgrew this build — -/// looping them back to "re-lock with pnpm >= 9" would hand them the lock -/// they have. -pub(crate) fn sniff_lock_grammar(text: &str) -> Result { - let version = text - .lines() - .take(5) - .find_map(|line| line.strip_prefix("lockfileVersion:")) - .map(|rest| rest.trim().trim_matches(['\'', '"']).to_string()); - match version.as_deref() { - Some("9.0") => Ok(PnpmLockGrammar::V9), - Some("5.4") => Ok(PnpmLockGrammar::V54), - Some("6.0") => Ok(PnpmLockGrammar::V60), - Some(v) => { - let major = v.split('.').next().and_then(|m| m.parse::().ok()); - Err(match major { - Some(m) if m < 9 => format!( - "{PNPM_LOCK} has lockfileVersion {v}; supported versions are 5.4 \ - (pnpm 7), 6.0 (pnpm 8), and 9.0 (pnpm >= 9) — re-lock with pnpm >= 9" - ), - _ => format!( - "{PNPM_LOCK} has lockfileVersion {v}; this socket-patch build supports \ - lockfileVersions 5.4, 6.0, and 9.0 — re-lock with a pnpm release that \ - emits one of them, or update socket-patch" - ), - }) - } - None => Err(format!( - "{PNPM_LOCK} has no lockfileVersion in its head; supported versions are 5.4, \ - 6.0, and 9.0 — re-lock with pnpm >= 9" - )), - } -} - -impl PnpmLockGrammar { - /// Human name for diagnostics (`pnpm 7 (lockfileVersion 5.4)`). - fn describe(self) -> &'static str { - match self { - PnpmLockGrammar::V9 => "pnpm >= 9 (lockfileVersion 9.0)", - PnpmLockGrammar::V54 => "pnpm 7 (lockfileVersion 5.4)", - PnpmLockGrammar::V60 => "pnpm 8 (lockfileVersion 6.0)", - } - } -} - // ───────────────────────────── edit context ────────────────────────────── struct Ctx<'a> { @@ -795,27 +737,7 @@ pub async fn pnpm_legacy_entry_in_use(entry: &VendorEntry, project_root: &Path) Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => {} _ => return None, } - // CRLF (a Windows autocrlf checkout) breaks every structural probe - // below: the scan would find nothing and call a lock that still - // resolves through the artifact "provably orphaned" — undeterminable, - // keep (the unwired-revert guard then refuses, fail-closed). - if text.contains('\r') { - return None; - } - let lines = split_lines(&text); - let Some((start, end)) = section_bounds(&lines, "packages") else { - return Some(false); - }; - let mut i = start + 1; - while let Some(block) = next_block(&lines, i, end) { - let ours = - parse_vendor_path(&block.key).is_some_and(|p| p.eco == "npm" && p.uuid == entry.uuid); - if ours { - return Some(true); - } - i = block.end; - } - Some(false) + PnpmLock::parse(&text).vendored_in_use(&entry.uuid) } // ─────────────────────────── pre-flight checks ─────────────────────────── diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index a9ddfe3a7..3dbfcc559 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -47,14 +47,12 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{NPM_LOCKS, PNPM_LOCK, PNPM_SHRINKWRAP_LEGACY}; -use crate::patch::redirect::pnpm::{entry_field, is_pnpm_lock_text}; -use crate::utils::digest::is_sri_pin; -use crate::vendor::lock_inventory::pnpm::{ - classify_pnpm_key, pnpm_packages, rush_lock_rels, PnpmKey, PnpmPackage, -}; -use crate::vendor::lock_inventory::{ - npm_lock_nodes, pnpm_registry_key, LockIntegrity, NpmLockNode, +use crate::formats::pnpm::{ + classify_pnpm_key, entry_field, pnpm_registry_key, PnpmKey, PnpmLock, PnpmPackage, }; +use crate::utils::digest::is_sri_pin; +use crate::vendor::lock_inventory::pnpm::rush_lock_rels; +use crate::vendor::lock_inventory::{npm_lock_nodes, LockIntegrity, NpmLockNode}; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let mut locks: Vec = Vec::new(); @@ -267,14 +265,14 @@ fn entry_ref( /// `pnpm-workspace.yaml`) is configuration that routes nothing once no /// dependency in the graph matches it (rule 10: pins, not definitions). The /// entries come from the entry model the lock inventory shares -/// ([`pnpm_packages`]), which reads the hosted rewriter's own block grammar +/// ([`PnpmLock::packages`]), which reads the hosted rewriter's own block grammar /// (two-space keys, a flat flow or block `resolution:` map), so every shape /// it writes is read back identically, CRLF included; keys are classified /// by [`classify_pnpm_key`]. An entry is a ref when its `resolution` /// `tarball:` is /// /// * a Socket-HOSTED url ([`DiscoverCtx::hosted_uuid`]) → -/// [`WiringMode::Hosted`]. The hosted rewriter (`rewrite_pnpm_lock`) keeps +/// [`WiringMode::Hosted`]. The hosted rewriter (`formats::pnpm::plan_hosted`) keeps /// the registry KEY and replaces only the resolution with `{integrity: /// sha512-…, tarball: }` (or the block-map spelling in pnpm <= 5 /// locks), so name@version come from the key in each generation's grammar @@ -326,7 +324,8 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover let Some(text) = ctx.read_text(file, out).await else { return; }; - if !is_pnpm_lock_text(&text) { + let lock = PnpmLock::parse(&text); + if !lock.is_pnpm_lock() { out.diag( DIAG_LOCKFILE_UNPARSEABLE, file, @@ -334,8 +333,8 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover ); return; } - for package in pnpm_packages(&text) { - pnpm_entry_ref(ctx, file, &package, out); + for package in lock.packages() { + pnpm_entry_ref(ctx, file, package, out); } } From 86efc8d21b9a87c8d69422b6191f0480552bdcbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:23:42 +0000 Subject: [PATCH 03/13] Derive every wiring-file list from formats::registry() The hosted candidate list, repair's vendored-reference search space, lockfile discovery's vendored-liveness probe, the in-memory engine's root markers and file ecosystems, the npm flavor probe guard and pnpm detection now filter one table instead of keeping five parallel lists. The hosted candidate order is pinned by value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/commands/repair_vendor.rs | 42 +--- .../src/commands/scan/hosted.rs | 141 +++++------ .../src/hosted_memory/redirect.rs | 35 +-- .../src/hosted_memory/roots.rs | 37 +-- crates/socket-patch-core/src/constants.rs | 120 +--------- crates/socket-patch-core/src/formats/mod.rs | 6 + .../socket-patch-core/src/formats/registry.rs | 223 ++++++++++++++++++ .../src/package_json/find.rs | 16 +- .../src/vendor/npm_flavor.rs | 8 +- .../socket-patch-core/src/vex/discover/mod.rs | 26 +- 10 files changed, 325 insertions(+), 329 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/registry.rs diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 0217047fe..71454dfea 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -53,6 +53,8 @@ use std::path::{Path, PathBuf}; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::constants::SOCKET_DIR; +use socket_patch_core::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; +use socket_patch_core::formats::registry; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::utils::fs::read_regular_to_string; @@ -98,36 +100,6 @@ struct Candidate { soft: bool, } -/// Files the vendor backends rewire — the search space for -/// `.socket/vendor///` references when the ledger is gone. -/// The Python locks the root LISTS (`pylock*.toml`, `*.py.lock` + script) -/// and the requirements `-r` include tree are appended at scan time. -const WIRING_FILES: &[&str] = &[ - "vlt-lock.json", - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - "yarn.lock", - "bun.lock", - "package.json", - "Cargo.toml", - "Cargo.lock", - // Pre-v5 vendored cargo wiring (migrated into Cargo.toml on re-run). - ".cargo/config.toml", - ".cargo/config", - "go.mod", - "composer.json", - "composer.lock", - "Gemfile", - "Gemfile.lock", - "uv.lock", - "pyproject.toml", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "requirements.txt", -]; - /// Scan the wiring-bearing files for vendored-artifact references, /// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure /// text scan plus native binary Bun resolution records and the canonical @@ -151,9 +123,12 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, } } - let mut files: Vec = WIRING_FILES - .iter() - .map(|file| (*file).to_string()) + // The files the vendor backends rewire; the Python locks the root + // LISTS (`pylock*.toml`, `*.py.lock` + script) and the requirements + // `-r` include tree are appended below. + let mut files: Vec = registry::paths_with(registry::VENDORED) + .into_iter() + .map(str::to_string) .collect(); files.extend(vendor::vlt_lock::vlt_importer_package_jsons(project_root).await); if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) { @@ -305,7 +280,6 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> if let Some(text) = read("pnpm-lock.yaml").await { if text.contains(&needle) { // The format model's vendor allowlist sniff. - use socket_patch_core::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; return match sniff_lock_grammar(&text) { Ok(PnpmLockGrammar::V9) => Some("pnpm".to_string()), Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => Some("pnpm-legacy".to_string()), diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 96c0bbd40..bd2871558 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -9,6 +9,7 @@ use std::time::Duration; use futures_util::StreamExt; use socket_patch_core::api::client::hold_back_debug; use socket_patch_core::api::types::BatchPackagePatches; +use socket_patch_core::formats::registry; use socket_patch_core::patch::apply_lock::LockGuard; use socket_patch_core::patch::redirect::DepOverride; use socket_patch_core::utils::concurrent::{ @@ -36,71 +37,11 @@ pub(crate) const REBASE_KINDS: &[&str] = &[ ]; /// Candidate lockfiles / registry configs the redirect rewriters may touch — -/// read from the project when present and handed to `rewrite_registry_redirect`. -pub(crate) const REDIRECT_CANDIDATE_FILES: &[&str] = &[ - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - // pnpm <=2 uses the same package identities under the old filename. - "shrinkwrap.yaml", - "node_modules/.modules.yaml", - "yarn.lock", - // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is - // `bun.lock`; binary locks are read separately below. - ".yarnrc.yml", - "bun.lock", - "bun.lockb", - // vlt: the lock is rewritten, vlt.json is read-only (the old-lockfile - // advisory), and the hidden lock is only stat'ed as the install-state - // sentinel. - "vlt-lock.json", - "vlt.json", - "node_modules/.vlt-lock.json", - "requirements.txt", - "uv.lock", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "pyproject.toml", - "hatch.toml", - "Cargo.toml", - "Cargo.lock", - ".cargo/config.toml", - // The LEGACY extensionless spelling: cargo reads `.cargo/config` in - // preference to `config.toml` when both exist, so the rewriter must see - // it (it wires the managed registry into whichever one is present) — - // otherwise the `[registries.…]` block lands in a file cargo ignores. - ".cargo/config", - "composer.lock", - "nuget.config", - "packages.lock.json", - "Gemfile", - "Gemfile.lock", - // Bundler's modern manifest spelling — preferred over Gemfile when both - // exist (the gem rewriter picks the pair bundler reads and fails closed - // on diverging spellings). - "gems.rb", - "gems.locked", - // The golang rewriter edits the main module's go.mod (fork-style - // `replace`) and go.sum (the socket module's two h1: lines). go.sum may - // legitimately be absent — the rewriter creates it in that case. - "go.mod", - "go.sum", - "pom.xml", - // Maven Trusted Checksums files the fail-closed maven rewriter merges into - // (read so an existing user config / checksum set is preserved, not - // clobbered). - ".mvn/maven.config", - ".mvn/checksums/checksums.sha256", - // Gradle build scripts are never edited — their presence only feeds the - // maven rewriter's paste-able `exclusiveContent` snippet warning. - "settings.gradle", - "settings.gradle.kts", - "build.gradle", - "build.gradle.kts", - // deno.lock is deliberately absent: no redirect rewriter edits its - // integrity entries. -]; +/// read from the project when present and handed to +/// `rewrite_registry_redirect`: the [`registry::HOSTED`] rows of the format +/// registry, in its read order. +pub(crate) static REDIRECT_CANDIDATE_FILES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| registry::paths_with(registry::HOSTED)); /// Most hosted wheel-metadata downloads in flight at once, below the patch /// API's own in-flight cap: each one buffers a whole wheel (up to @@ -1856,7 +1797,7 @@ pub(crate) async fn run_redirect_selected( let mut rush_warnings: Vec = Vec::new(); let mut rush_lock_keys: Vec = Vec::new(); if !candidates.is_empty() || !dry_run_takeover_urls.is_empty() { - for name in REDIRECT_CANDIDATE_FILES { + for name in REDIRECT_CANDIDATE_FILES.iter() { if *name == "bun.lockb" { continue; } @@ -3761,7 +3702,6 @@ mod tests { }; use super::{rebase_vlt_edits, REBASE_KINDS}; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; - use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::{DepOverride, FileEdit}; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; @@ -4984,25 +4924,54 @@ mod tests { } #[test] - fn redirect_candidates_match_the_shared_npm_family_table() { - // Drift guard, both directions, without classifying the non-npm - // rows: every table row flagged redirect_candidate must be in the - // candidate list, and no npm-family row NOT so flagged may appear - // (binary candidates are read separately). - for name in npm_family::names_with(|r| r.redirect_candidate) { - assert!( - REDIRECT_CANDIDATE_FILES.contains(&name), - "{name} is flagged redirect_candidate but missing from \ - REDIRECT_CANDIDATE_FILES" - ); - } - for name in npm_family::names_with(|r| !r.redirect_candidate) { - assert!( - !REDIRECT_CANDIDATE_FILES.contains(&name), - "{name} is deliberately NOT a redirect candidate (see the \ - npm_family table) but appears in REDIRECT_CANDIDATE_FILES" - ); - } + fn redirect_candidates_are_pinned_by_value() { + // Hardcoded on purpose: the candidate list is derived from the + // format registry, so a row dropped (or a HOSTED flag lost) there + // must fail here instead of silently shrinking what hosted reads. + assert_eq!( + *REDIRECT_CANDIDATE_FILES, + [ + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + "shrinkwrap.yaml", + "node_modules/.modules.yaml", + "yarn.lock", + ".yarnrc.yml", + "bun.lock", + "bun.lockb", + "vlt-lock.json", + "vlt.json", + "node_modules/.vlt-lock.json", + "requirements.txt", + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "pyproject.toml", + "hatch.toml", + "Cargo.toml", + "Cargo.lock", + ".cargo/config.toml", + ".cargo/config", + "composer.lock", + "nuget.config", + "packages.lock.json", + "Gemfile", + "Gemfile.lock", + "gems.rb", + "gems.locked", + "go.mod", + "go.sum", + "pom.xml", + ".mvn/maven.config", + ".mvn/checksums/checksums.sha256", + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + ] + ); } // ── Human-output formatting ──────────────────────────────────────────── diff --git a/crates/socket-patch-cli/src/hosted_memory/redirect.rs b/crates/socket-patch-cli/src/hosted_memory/redirect.rs index 7848425cc..06af3a675 100644 --- a/crates/socket-patch-cli/src/hosted_memory/redirect.rs +++ b/crates/socket-patch-cli/src/hosted_memory/redirect.rs @@ -160,35 +160,12 @@ pub(crate) fn build_candidates( /// The ecosystem a candidate file's rewriter belongs to (`None` for files /// no rewriter edits), for the symlinked-read refusal. fn file_ecosystem(rel: &str) -> Option<&'static str> { + if let Some(eco) = socket_patch_core::formats::registry::hosted_file_ecosystem(rel) { + return Some(eco); + } let base = rel.rsplit('/').next().unwrap_or(rel); - Some(match base { - "package-lock.json" - | "npm-shrinkwrap.json" - | "pnpm-lock.yaml" - | "shrinkwrap.yaml" - | ".modules.yaml" - | "yarn.lock" - | ".yarnrc.yml" - | "bun.lock" - | "bun.lockb" - | "vlt-lock.json" - | "vlt.json" - | ".vlt-lock.json" => "npm", - "requirements.txt" | "uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock" - | "pyproject.toml" | "hatch.toml" => "pypi", - "Cargo.toml" | "Cargo.lock" | "config.toml" | "config" => "cargo", - "composer.lock" => "composer", - "nuget.config" | "packages.lock.json" => "nuget", - "Gemfile" | "Gemfile.lock" | "gems.rb" | "gems.locked" => "gem", - "go.mod" | "go.sum" => "golang", - "pom.xml" | "maven.config" | "checksums.sha256" => "maven", - _ if socket_patch_core::utils::python_lock::is_python_lock_name(base) - || base.ends_with(".py") => - { - "pypi" - } - _ => return None, - }) + (socket_patch_core::utils::python_lock::is_python_lock_name(base) || base.ends_with(".py")) + .then_some("pypi") } /// A project's state between the reference grants and the wheel-metadata @@ -484,7 +461,7 @@ pub(crate) fn plan( None => false, } }; - for name in REDIRECT_CANDIDATE_FILES { + for name in REDIRECT_CANDIDATE_FILES.iter() { if *name == "bun.lockb" { continue; } diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-cli/src/hosted_memory/roots.rs index 002cac154..053cf8010 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-cli/src/hosted_memory/roots.rs @@ -7,34 +7,11 @@ use std::collections::{BTreeMap, BTreeSet}; +use socket_patch_core::formats::registry; use socket_patch_core::utils::python_lock::is_python_lock_name; use super::types::IgnoredPath; -/// Lock markers that make their directory a project root, with the -/// ecosystem each belongs to. -pub(crate) const ROOT_LOCK_MARKERS: [(&str, &str); 19] = [ - ("package-lock.json", "npm"), - ("npm-shrinkwrap.json", "npm"), - ("pnpm-lock.yaml", "npm"), - ("yarn.lock", "npm"), - ("bun.lock", "npm"), - ("bun.lockb", "npm"), - ("vlt-lock.json", "npm"), - ("rush.json", "npm"), - ("uv.lock", "pypi"), - ("poetry.lock", "pypi"), - ("pdm.lock", "pypi"), - ("Pipfile.lock", "pypi"), - ("requirements.txt", "pypi"), - ("Cargo.lock", "cargo"), - ("go.mod", "golang"), - ("go.sum", "golang"), - ("composer.lock", "composer"), - ("Gemfile.lock", "gem"), - ("gems.locked", "gem"), -]; - /// Marker files of the ecosystems the in-memory engine cannot inventory /// (disk discovers them only through installed-tree crawlers). pub(crate) const UNSUPPORTED_MARKERS: [(&str, &[&str]); 2] = [ @@ -66,10 +43,11 @@ pub(crate) const EXCLUDED_ROOT_SEGMENTS: [&str; 10] = [ "testdata", ]; -/// The ecosystem a root marker basename belongs to. +/// The ecosystem a root marker basename belongs to: a [`registry::ROOT`] +/// row (manifests alone never make a root) or a PEP 751 / PEP 723 lock. pub(crate) fn marker_ecosystem(base: &str) -> Option<&'static str> { - if let Some((_, eco)) = ROOT_LOCK_MARKERS.iter().find(|(name, _)| *name == base) { - return Some(eco); + if let Some(row) = registry::root_marker(base) { + return Some(row.ecosystem); } is_python_lock_name(base).then_some("pypi") } @@ -133,10 +111,7 @@ pub(crate) fn detect_roots<'a>( ignore("ecosystem_filtered", &mut ignored); continue; } - let key: &'static str = ROOT_LOCK_MARKERS - .iter() - .find(|(name, _)| *name == base) - .map_or("python-lock", |(name, _)| name); + let key: &'static str = registry::root_marker(base).map_or("python-lock", |row| row.path); markers.entry(dir.to_string()).or_default().insert(key); marker_paths .entry(dir.to_string()) diff --git a/crates/socket-patch-core/src/constants.rs b/crates/socket-patch-core/src/constants.rs index 5a6fefeb0..18287b85b 100644 --- a/crates/socket-patch-core/src/constants.rs +++ b/crates/socket-patch-core/src/constants.rs @@ -65,123 +65,11 @@ mod tests { } } -/// The npm-family package managers' shared file-name knowledge. -/// -/// npm, pnpm, yarn (classic and berry) and bun spell their lockfiles and -/// layout markers across several subsystems — the vendor flavor probe -/// (`vendor::npm_flavor`), the hosted-redirect candidate list (the CLI's -/// `scan::hosted`), the crawler layout probe (`crawlers::pkg_managers`) and -/// setup's PM detection (`package_json::find`). Those sites accept -/// INTENTIONALLY divergent subsets: binary locks have a native byte reader, and the -/// `pnpm-lock.yml` spelling is accepted only by setup detection. This table -/// encodes each divergence once, visibly, instead of homogenizing them. -/// -/// What is actually guard-tested (equality against the flagged rows): -/// `vendor::npm_flavor`'s wiring-family list, `scan::hosted`'s -/// REDIRECT_CANDIDATE_FILES npm subset, and `package_json::find`'s pnpm -/// markers (plus a hardcoded pin so the table and its consumers cannot -/// shrink together). NOT table-guarded: `crawlers::pkg_managers`' own -/// bun/yarn lockfile literals and `npm_flavor`'s probe decision literals — -/// those are pinned behaviorally by their unit tests instead; only -/// PNP_MARKERS is shared with the crawler. +/// The npm-family package managers' shared file names. Which subsystem +/// accepts which of them (the intentionally divergent subsets: binary locks +/// have a native byte reader, `pnpm-lock.yml` is only a pnpm marker) is +/// one flag on one row of [`crate::formats::registry()`]. pub mod npm_family { - /// One file-name row and the roles in which consumers accept it. - pub struct FileRow { - pub name: &'static str, - /// `vendor::npm_flavor`'s probe recognizes it (wiring family member). - pub vendor_probe: bool, - /// `scan::hosted` hands it to `rewrite_registry_redirect`. - pub redirect_candidate: bool, - /// `package_json::find::detect_package_manager` treats it as a pnpm - /// marker. - pub detects_pnpm: bool, - } - - pub const FILES: &[FileRow] = &[ - FileRow { - name: "package-lock.json", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - FileRow { - name: "npm-shrinkwrap.json", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - FileRow { - name: "pnpm-lock.yaml", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: true, - }, - // Setup-detection-only spellings: the vendor probe and redirect - // rewriters have never accepted these, and widening them there is a - // behavior change to make deliberately, not by table accident. - FileRow { - name: "pnpm-lock.yml", - vendor_probe: false, - redirect_candidate: false, - detects_pnpm: true, - }, - FileRow { - name: "pnpm-workspace.yaml", - vendor_probe: false, - redirect_candidate: false, - detects_pnpm: true, - }, - FileRow { - name: "yarn.lock", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - // Berry's cache-config gate: read by the redirect rewriters only. - FileRow { - name: ".yarnrc.yml", - vendor_probe: false, - redirect_candidate: true, - detects_pnpm: false, - }, - FileRow { - name: "bun.lock", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - // Binary Bun locks are read and rewritten natively. - FileRow { - name: "bun.lockb", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - FileRow { - name: "vlt-lock.json", - vendor_probe: true, - redirect_candidate: true, - detects_pnpm: false, - }, - // vlt's config: a read-only redirect input, never wired. - FileRow { - name: "vlt.json", - vendor_probe: false, - redirect_candidate: true, - detects_pnpm: false, - }, - // deno.lock is deliberately absent: deno is its own ecosystem - // (JSR-crawled); no npm-family vendor/redirect/detection path treats - // deno.lock as an npm lock today. Adding it here is a feature - // decision, not a spelling fix. - ]; - - /// The names of every row `pick` flags — consumer guard tests compare - /// their local lists against this. - pub fn names_with(pick: impl Fn(&FileRow) -> bool) -> Vec<&'static str> { - FILES.iter().filter(|r| pick(r)).map(|r| r.name).collect() - } - /// Yarn Plug'n'Play loader files — any one present means "packages are /// not on disk" (crawler must refuse; vendor probe refuses). Yarn 3+ /// emits `.pnp.cjs`, Yarn 2.x emitted `.pnp.js`, newer installs may add diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 012d93823..5b3b5683d 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -19,8 +19,14 @@ //! stays with the caller, so the disk engines and the in-memory hosted //! engine (`MemoryProject`) share one parse per format. An architecture //! test below enforces it. +//! +//! [`registry()`] is the one table of which project files carry a lock or +//! its wiring, and in which roles. pub mod pnpm; +pub mod registry; + +pub use registry::registry; /// The default upstream resolution a hosted pin is restored to: the /// registry artifact of `name@version` as the package manager itself diff --git a/crates/socket-patch-core/src/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs new file mode 100644 index 000000000..dd89a8627 --- /dev/null +++ b/crates/socket-patch-core/src/formats/registry.rs @@ -0,0 +1,223 @@ +//! Which project files carry a lock or its wiring, per ecosystem, and in +//! which roles — the ONE table the hosted planners' candidate reads, the +//! vendored planners' wiring search, lockfile discovery's vendored-liveness +//! probe, the in-memory engine's root detection and the npm-family flavor +//! probes all filter. +//! +//! The roles intentionally diverge per file (a binary Bun lock has a native +//! reader and is never text-scanned for wiring; `pnpm-lock.yml` is only a +//! package-manager marker; Gradle scripts are read by the hosted Maven +//! planner for their presence only); each divergence is one flag on one +//! row. Paths are root-relative with `/` separators. Dynamic sets — PEP 751 +//! / PEP 723 Python locks, vlt importer manifests, requirements `-r` +//! includes, Rush's nested pnpm locks — are enumerated by their callers. + +/// Read by the hosted planners (`scan --mode hosted`, the in-memory +/// engine's candidate reads). +pub const HOSTED: u8 = 1 << 0; +/// Rewired by a vendored planner: the search space for +/// `.socket/vendor///` references when the vendor ledger +/// is gone (`repair`). +pub const VENDORED: u8 = 1 << 1; +/// A lock (or wiring config) a vendored artifact is consumed through — the +/// liveness probe of a ledger entry whose recorded wiring files are gone +/// (`vex::discover`), and the npm-family flavor probe's lock family. +pub const PROBE: u8 = 1 << 2; +/// Makes its directory a project root (the in-memory hosted engine). +pub const ROOT: u8 = 1 << 3; +/// Marks a pnpm project for package-manager detection. +pub const PNPM_MARKER: u8 = 1 << 4; +/// Read by the hosted planners for its presence (or as advisory input) +/// only: no hosted rewriter edits it, so it names no ecosystem for the +/// symlinked-read refusal. +pub const PRESENCE_ONLY: u8 = 1 << 5; + +/// One row of the [`registry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FormatFile { + /// Root-relative path. + pub path: &'static str, + /// Vendor-ecosystem tag (`npm`, `pypi`, `cargo`, …). + pub ecosystem: &'static str, + /// The roles, a mask of [`HOSTED`], [`VENDORED`], [`PROBE`], [`ROOT`], + /// [`PNPM_MARKER`] and [`PRESENCE_ONLY`]. + pub roles: u8, +} + +impl FormatFile { + pub fn has(&self, role: u8) -> bool { + self.roles & role != 0 + } + + /// The path's last segment. + pub fn basename(&self) -> &'static str { + self.path.rsplit('/').next().unwrap_or(self.path) + } +} + +const fn row(path: &'static str, ecosystem: &'static str, roles: u8) -> FormatFile { + FormatFile { + path, + ecosystem, + roles, + } +} + +/// In the hosted planners' read order. +const REGISTRY: &[FormatFile] = &[ + // ── npm family ── + row("package-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + row("npm-shrinkwrap.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + row( + "pnpm-lock.yaml", + "npm", + HOSTED | VENDORED | PROBE | ROOT | PNPM_MARKER, + ), + // Package-manager detection only: the vendor probe and the hosted + // planners have never accepted these spellings. + row("pnpm-lock.yml", "npm", PNPM_MARKER), + row("pnpm-workspace.yaml", "npm", PNPM_MARKER), + // pnpm <= 2 uses the same package identities under the old filename. + row("shrinkwrap.yaml", "npm", HOSTED), + row("node_modules/.modules.yaml", "npm", HOSTED), + row("yarn.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), + // A berry lock's cache-config gate: read by the hosted planners only. + row(".yarnrc.yml", "npm", HOSTED), + row("bun.lock", "npm", HOSTED | VENDORED | PROBE | ROOT), + // Binary Bun locks are read and rewritten natively, never text-scanned + // for wiring. + row("bun.lockb", "npm", HOSTED | PROBE | ROOT), + row("vlt-lock.json", "npm", HOSTED | VENDORED | PROBE | ROOT), + // vlt's config: a read-only hosted input (the old-lockfile advisory). + row("vlt.json", "npm", HOSTED), + // The hidden lock is only stat'ed as the install-state sentinel. + row("node_modules/.vlt-lock.json", "npm", HOSTED), + // The vendored planners' override surface; manifests never make a root. + row("package.json", "npm", VENDORED), + row("rush.json", "npm", ROOT), + // ── pypi ── + row("requirements.txt", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("uv.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("poetry.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("pdm.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("Pipfile.lock", "pypi", HOSTED | VENDORED | PROBE | ROOT), + row("pyproject.toml", "pypi", HOSTED | VENDORED | PROBE), + row("hatch.toml", "pypi", HOSTED | PROBE), + // ── cargo ── + row("Cargo.toml", "cargo", HOSTED | VENDORED | PROBE), + row("Cargo.lock", "cargo", HOSTED | VENDORED | ROOT), + row(".cargo/config.toml", "cargo", HOSTED | VENDORED | PROBE), + // The LEGACY extensionless spelling: cargo reads `.cargo/config` in + // preference to `config.toml` when both exist, so the hosted planner + // must see it (it wires the managed registry into whichever one is + // present); vendored wiring before v5 lived there too. + row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE), + // ── composer ── + row("composer.json", "composer", VENDORED), + row("composer.lock", "composer", HOSTED | VENDORED | PROBE | ROOT), + // ── nuget ── + row("nuget.config", "nuget", HOSTED | PROBE), + row("NuGet.config", "nuget", PROBE), + row("NuGet.Config", "nuget", PROBE), + row("packages.lock.json", "nuget", HOSTED), + // ── gem ── + row("Gemfile", "gem", HOSTED | VENDORED), + row("Gemfile.lock", "gem", HOSTED | VENDORED | PROBE | ROOT), + // Bundler's modern manifest spelling — preferred over Gemfile when both + // exist (the gem planner picks the pair bundler reads and fails closed + // on diverging spellings). + row("gems.rb", "gem", HOSTED), + row("gems.locked", "gem", HOSTED | ROOT), + // ── golang ── + // The hosted planner edits the main module's go.mod (fork-style + // `replace`) and go.sum (the socket module's two h1: lines); go.sum may + // legitimately be absent — the planner creates it then. + row("go.mod", "golang", HOSTED | VENDORED | PROBE | ROOT), + row("go.sum", "golang", HOSTED | ROOT), + // ── maven ── + row("pom.xml", "maven", HOSTED | PROBE), + // Maven Trusted Checksums files the fail-closed maven planner merges + // into (read so an existing user config / checksum set is preserved). + row(".mvn/maven.config", "maven", HOSTED), + row(".mvn/checksums/checksums.sha256", "maven", HOSTED), + // Gradle build scripts are never edited — their presence only feeds the + // maven planner's paste-able `exclusiveContent` snippet warning. + row("settings.gradle", "maven", HOSTED | PRESENCE_ONLY), + row("settings.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + row("build.gradle", "maven", HOSTED | PRESENCE_ONLY), + row("build.gradle.kts", "maven", HOSTED | PRESENCE_ONLY), + // deno.lock is deliberately absent: deno is its own ecosystem + // (JSR-crawled) and no planner edits its integrity entries. +]; + +/// Every row, in the hosted planners' read order. +pub fn registry() -> &'static [FormatFile] { + REGISTRY +} + +/// The paths of every row carrying `role`, in registry order. +pub fn paths_with(role: u8) -> Vec<&'static str> { + REGISTRY + .iter() + .filter(|f| f.has(role)) + .map(|f| f.path) + .collect() +} + +/// The [`PROBE`] paths of `ecosystem`, in registry order. +pub fn probe_paths(ecosystem: &str) -> Vec<&'static str> { + REGISTRY + .iter() + .filter(|f| f.ecosystem == ecosystem && f.has(PROBE)) + .map(|f| f.path) + .collect() +} + +/// The ecosystem whose hosted planner edits a candidate file, by basename +/// (`rel` may be nested, e.g. a Rush lock or a workspace member's +/// `Cargo.toml`); `None` for files no hosted rewriter edits. +pub fn hosted_file_ecosystem(rel: &str) -> Option<&'static str> { + let base = rel.rsplit('/').next().unwrap_or(rel); + REGISTRY + .iter() + .find(|f| f.has(HOSTED) && !f.has(PRESENCE_ONLY) && f.basename() == base) + .map(|f| f.ecosystem) +} + +/// The [`ROOT`] row a basename names. +pub fn root_marker(base: &str) -> Option<&'static FormatFile> { + REGISTRY.iter().find(|f| f.has(ROOT) && f.path == base) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_are_unique_and_root_markers_are_root_level() { + let mut paths: Vec<&str> = REGISTRY.iter().map(|f| f.path).collect(); + paths.sort_unstable(); + let before = paths.len(); + paths.dedup(); + assert_eq!(before, paths.len(), "duplicate registry path"); + for f in REGISTRY.iter().filter(|f| f.has(ROOT)) { + assert!(!f.path.contains('/'), "{}: a root marker is a basename", f.path); + } + } + + #[test] + fn hosted_file_ecosystem_matches_basenames_of_edited_files_only() { + assert_eq!(hosted_file_ecosystem("package-lock.json"), Some("npm")); + assert_eq!( + hosted_file_ecosystem("common/config/rush/pnpm-lock.yaml"), + Some("npm") + ); + assert_eq!(hosted_file_ecosystem(".modules.yaml"), Some("npm")); + assert_eq!(hosted_file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); + assert_eq!(hosted_file_ecosystem(".cargo/config"), Some("cargo")); + assert_eq!(hosted_file_ecosystem("checksums.sha256"), Some("maven")); + assert_eq!(hosted_file_ecosystem("build.gradle"), None); + assert_eq!(hosted_file_ecosystem("package.json"), None); + assert_eq!(hosted_file_ecosystem("NuGet.Config"), None); + } +} diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 7912e1538..34c648add 100644 --- a/crates/socket-patch-core/src/package_json/find.rs +++ b/crates/socket-patch-core/src/package_json/find.rs @@ -3,6 +3,7 @@ use tokio::fs; use super::detect::{strip_bom, PackageManager}; use crate::constants::npm_family; +use crate::formats::registry; use crate::utils::fs::{entry_file_type, is_dir, list_dir_entries, read_regular_to_string}; use crate::vendor::vlt_lock_text::{sniff_lock, LockSniff}; @@ -11,7 +12,8 @@ use crate::vendor::vlt_lock_text::{sniff_lock, LockSniff}; /// over pnpm's, and only the start directory is consulted: an ancestor /// `vlt.json` does not make a nested package a vlt project. The accepted /// pnpm marker spellings (including the `pnpm-lock.yml` variant no other -/// subsystem accepts) live in the shared [`npm_family`] table. +/// subsystem accepts) are the format registry's +/// [`PNPM_MARKER`](registry::PNPM_MARKER) rows. pub async fn detect_package_manager(start_path: &Path) -> PackageManager { for name in npm_family::VLT_SETUP_MARKERS { let path = start_path.join(name); @@ -24,7 +26,7 @@ pub async fn detect_package_manager(start_path: &Path) -> PackageManager { return PackageManager::Vlt; } } - for name in npm_family::names_with(|r| r.detects_pnpm) { + for name in registry::paths_with(registry::PNPM_MARKER) { if fs::metadata(start_path.join(name)).await.is_ok() { return PackageManager::Pnpm; } @@ -753,11 +755,11 @@ mod tests { #[tokio::test] async fn detect_package_manager_accepts_every_table_flagged_pnpm_marker() { - // Behavioral pin on the shared npm_family table wiring: every row - // flagged detects_pnpm (including the `pnpm-lock.yml` spelling no + // Behavioral pin on the format registry wiring: every row + // flagged PNPM_MARKER (including the `pnpm-lock.yml` spelling no // other subsystem accepts) flips detection to Pnpm; an empty root // stays Npm. - for name in crate::constants::npm_family::names_with(|r| r.detects_pnpm) { + for name in registry::paths_with(registry::PNPM_MARKER) { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(name), "").await.unwrap(); assert!( @@ -778,11 +780,11 @@ mod tests { #[test] fn pnpm_marker_spellings_are_pinned_by_value() { // Hardcoded on purpose, breaking the self-reference: production code - // iterates the same names_with(detects_pnpm) expression the guard + // iterates the same registry PNPM_MARKER expression the guard // test above does, so a row deleted from the table would shrink code // and guard together while `.yml` detection silently vanished. This // list cannot shrink with them. - let mut spellings = crate::constants::npm_family::names_with(|r| r.detects_pnpm); + let mut spellings = registry::paths_with(registry::PNPM_MARKER); spellings.sort_unstable(); assert_eq!( spellings, diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index b019ce143..2e6f6b219 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -852,9 +852,9 @@ mod lock_text_refusal_tests { #[cfg(test)] mod tests { #[test] - fn probe_lockfile_names_match_the_shared_npm_family_table() { - // Drift guard: the probe's wiring families and the shared - // constants::npm_family table must agree on which file names the + fn probe_lockfile_names_match_the_format_registry() { + // Drift guard: the probe's wiring families and the format + // registry's npm PROBE rows must agree on which file names the // vendor probe recognizes. A new lockfile spelling added in one // place must show up in the other (and in every other consumer's // guard test) instead of drifting silently. @@ -863,7 +863,7 @@ mod tests { .flat_map(|(_, names)| names.iter().copied()) .collect(); from_families.sort_unstable(); - let mut from_table = crate::constants::npm_family::names_with(|r| r.vendor_probe); + let mut from_table = crate::formats::registry::probe_paths("npm"); from_table.sort_unstable(); assert_eq!(from_families, from_table); } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 34b3c3efc..ec555401d 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -1723,33 +1723,15 @@ pub async fn vendored_wiring_live(root: &Path, recorded: &[&str], eco: &str, uui } /// The root files a vendored `eco` artifact can be wired from — the vendor -/// backends' lockfile / wiring config for that ecosystem (npm: every -/// npm-family lock the `vendor_probe` table flags, `vlt-lock.json` -/// included; cargo: the root `Cargo.toml` `[patch.crates-io]` table and the +/// backends' lockfile / wiring config for that ecosystem, the format +/// registry's [`crate::formats::registry::PROBE`] rows (npm: every +/// npm-family lock, `vlt-lock.json` included; cargo: the root `Cargo.toml` `[patch.crates-io]` table and the /// pre-v5 `.cargo/config.toml` / `.cargo/config` spellings; maven / /// nuget: the repository / source that serves the vendored dir). Manifests /// such as package.json are deliberately absent: the lock is what the /// install consumes. pub fn vendored_wiring_probe_files(root: &Path, eco: &str) -> Vec { - let fixed: Vec<&str> = match eco { - "npm" => crate::constants::npm_family::names_with(|r| r.vendor_probe), - "pypi" => vec![ - "uv.lock", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "requirements.txt", - "pyproject.toml", - "hatch.toml", - ], - "cargo" => vec!["Cargo.toml", ".cargo/config.toml", ".cargo/config"], - "golang" => vec!["go.mod"], - "gem" => vec!["Gemfile.lock"], - "composer" => vec!["composer.lock"], - "maven" => vec!["pom.xml"], - "nuget" => crate::vendor::nuget_config::CONFIG_NAMES.to_vec(), - _ => Vec::new(), - }; + let fixed = crate::formats::registry::probe_paths(eco); let mut files: Vec = fixed.into_iter().map(str::to_string).collect(); if eco == "pypi" { // pylock.toml / pylock..toml / *.py.lock. From 1c849f39ea8dfde48ab106ba1d72958c48fda7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:54:46 +0000 Subject: [PATCH 04/13] Read Cargo.lock through one format model formats::cargo owns the lock read model (LockedPackage, v1 [metadata] checksums, [[patch.unused]], dependency references) behind CargoLock: entries() for the inventory, packages() for lockfile discovery and the vendor probes, dependents() for the hosted planner's unpinnable-dependents refusal (which re-parsed the lock with its own walker), vendored_in_use() for the vendored-copy claim, and the restore_upstream() hook. The hosted planner's Cargo.lock splice moves to formats::cargo::hosted with the line-grammar probes the rewriter reads with (is_locked, locked_versions), replacing three copies of the header probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/cargo/hosted.rs | 325 +++++++++++++++++ .../src/formats/cargo/mod.rs | 329 +++++++++++++++++ crates/socket-patch-core/src/formats/mod.rs | 1 + .../redirect/cargo_lock_equivalence_tests.rs | 2 +- .../src/patch/redirect/mod.rs | 338 +----------------- .../src/vendor/cargo_lock.rs | 182 +--------- .../src/vendor/lock_inventory/cargo.rs | 43 +-- .../src/vex/discover/cargo.rs | 26 +- 8 files changed, 686 insertions(+), 560 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/cargo/hosted.rs create mode 100644 crates/socket-patch-core/src/formats/cargo/mod.rs diff --git a/crates/socket-patch-core/src/formats/cargo/hosted.rs b/crates/socket-patch-core/src/formats/cargo/hosted.rs new file mode 100644 index 000000000..0c8fbc382 --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/hosted.rs @@ -0,0 +1,325 @@ +//! The hosted planner's `Cargo.lock` leg: repoint a crate's `[[package]]` +//! block (and a v1 lock's `[metadata]` checksum and full-id references) at +//! the Socket-hosted index, as text splices over cargo's own lock layout +//! (`[[package]]\nname = "…"\nversion = "…"\n`). The line grammar here is +//! the one the hosted rewriter's lock probes ([`is_locked`], +//! [`locked_versions`]) read with, so a probe and the splice always agree +//! on which blocks exist. + +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::Value; + +use crate::patch::redirect::FileEdit; + +/// The line-anchored header cargo writes for `name`@`version`. +fn package_head(name: &str, version: &str) -> String { + format!("[[package]]\nname = \"{name}\"\nversion = \"{version}\"\n") +} + +/// Every offset of `needle` in `content` that starts a line. +fn line_anchored<'c>(content: &'c str, needle: &'c str) -> impl Iterator + 'c { + content + .match_indices(needle) + .map(|(at, _)| at) + .filter(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n') +} + +/// Whether the lock holds a `[[package]]` block for `name`@`version`. +pub(crate) fn is_locked(lock: &str, name: &str, version: &str) -> bool { + line_anchored(lock, &package_head(name, version)) + .next() + .is_some() +} + +/// Every version of `name` the lock holds a `[[package]]` block for, sorted +/// and deduplicated. +pub(crate) fn locked_versions(lock: &str, name: &str) -> Vec { + let head = format!("[[package]]\nname = \"{name}\"\nversion = \""); + let mut versions: Vec = line_anchored(lock, &head) + .filter_map(|at| { + let rest = &lock[at + head.len()..]; + rest.split_once('"').map(|(v, _)| v.to_string()) + }) + .collect(); + versions.sort(); + versions.dedup(); + versions +} + +/// The Cargo.lock edit kind for dependents' full-id references: `original` +/// / `new` are the quoted `" ()"` ids, keyed +/// `@`, and the inverse replaces EVERY occurrence of `new`. +pub(crate) const CARGO_LOCK_REFERENCE_KIND: &str = "redirect_cargo_lock_reference"; + +static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") +}); +static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") +}); +// `$` (not `\n`) so it also anchors a source line that ENDS the block: the +// trailing newline sits outside the block region. +static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^(source = "[^"]*")$"#).expect("static source-line anchor regex is valid") +}); + +/// Repoint the crate's `[[package]]` at the hosted index with the patched +/// `.crate`'s checksum, in whichever Cargo.lock format the file is: +/// +/// * v2–v4: `source` + an inline `checksum` in the entry; +/// * v1 (cargo < 1.41, still read by every cargo): the entry carries only +/// `source`; the checksum lives in the trailing `[metadata]` table under +/// `"checksum ()"`, and every dependent names the +/// crate by its FULL package id `" ()"`. Both are +/// keyed by the source, so both must follow it — a v1 lock with only the +/// entry repointed names a package that no longer exists (cargo discards +/// the lock and re-resolves; `--locked` fails) and pins nothing. +/// +/// Full-id references are rewritten in any format (v2+ spells them that way +/// when a name + version is ambiguous). Each changed fragment is its own +/// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is +/// unambiguous) — the entry and the `[metadata]` line — and the dependents' +/// references are one `redirect_cargo_lock_reference` edit holding the +/// quoted full id, reverted at every occurrence. +pub(crate) fn plan_cargo_lock( + content: &str, + crate_name: &str, + version: &str, + index_url: &str, + cksum: &str, +) -> CargoLockPlan { + // Rust's regex has NO lookahead, so bound the [[package]] block by string + // search (see [`lock_block_end`]): from its header to the next block or + // trailing table (or EOF), so the bytes after the block (incl. the final + // newline) are preserved. + let head = package_head(crate_name, version); + // Every line-anchored header for this name@version. A Cargo.lock may + // legitimately hold TWO blocks for one name@version from different + // sources — after a redirect, a transitive crates.io copy resolves beside + // the socket-registry copy, and cargo sorts the crates.io block FIRST — + // so the first hit alone would repoint the wrong twin. + let heads: Vec = line_anchored(content, &head).collect(); + let block_start = match heads.as_slice() { + [] => return CargoLockPlan::NotFound, + [only] => *only, + twins => { + // Exactly one twin already at the target index is OURS (a re-run + // over a redirected lock); anything else cannot be attributed and + // the dep is skipped transactionally. + let target_source = format!("source = \"{index_url}\""); + let mut ours = twins.iter().copied().filter(|&at| { + let body_start = at + head.len(); + content[body_start..lock_block_end(content, body_start)] + .lines() + .any(|line| line == target_source) + }); + match (ours.next(), ours.next()) { + (Some(at), None) => at, + _ => return CargoLockPlan::Ambiguous, + } + } + }; + let body_start = block_start + head.len(); + let block_end = lock_block_end(content, body_start); + let original = content[block_start..block_end].to_string(); + let mut body = content[body_start..block_end].to_string(); + let old_source = CARGO_LOCK_SOURCE_LINE_RE + .captures(&body) + .map(|c| c[1].to_string()); + if old_source.is_some() { + body = CARGO_LOCK_SOURCE_LINE_RE + .replace(&body, format!("source = \"{index_url}\"").as_str()) + .to_string(); + } else { + body = format!("source = \"{index_url}\"\n{body}"); + } + // A v1 lock keeps the checksum in `[metadata]`, keyed by the package id + // — the chosen block's OWN source when it has one, so a multi-source + // twin's line is never taken for ours. + let metadata_source = old_source + .as_deref() + .map_or_else(|| r#"[^)"]*"#.to_string(), regex::escape); + let metadata_re = Regex::new(&format!( + r#"(?m)^"checksum {} {} \({metadata_source}\)" = "[^"]*"$"#, + regex::escape(crate_name), + regex::escape(version) + )) + .expect("escaped lock metadata-line regex is valid"); + let metadata_line = metadata_re.find(content).map(|m| m.as_str().to_string()); + if metadata_line.is_none() { + if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { + body = CARGO_LOCK_CHECKSUM_LINE_RE + .replace(&body, format!("checksum = \"{cksum}\"").as_str()) + .to_string(); + } else { + body = CARGO_LOCK_AFTER_SOURCE_RE + .replace(&body, format!("${{1}}\nchecksum = \"{cksum}\"").as_str()) + .to_string(); + } + } + let rebuilt = format!("{head}{body}"); + let key = format!("{crate_name}@{version}"); + let edit = |original: &str, new: &str| FileEdit { + path: "Cargo.lock".into(), + kind: "redirect_cargo_lock_entry".into(), + action: "rewritten".into(), + key: Some(key.clone()), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(new.to_string())), + }; + let mut edits = Vec::new(); + let mut new_content = content.to_string(); + if rebuilt != original { + new_content.replace_range(block_start..block_end, &rebuilt); + edits.push(edit(&original, &rebuilt)); + } + if let Some(line) = metadata_line { + let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); + if line != pinned { + new_content = new_content.replacen(&line, &pinned, 1); + edits.push(edit(&line, &pinned)); + } + } + // Dependents' full-id references to the OLD source, recorded as ONE + // `redirect_cargo_lock_reference` edit holding just the quoted id — + // never a dependent's whole block: a block referencing two patched + // packages (the root of a v1 lock) would hold two overlapping block + // edits, and reverting the first-applied one alone would find neither of + // its fragments. The id names this name + version + source exactly, so its + // inverse puts back EVERY occurrence, independently of any other + // package's edits and in any removal order. + if let Some(old) = old_source.filter(|old| old != index_url) { + let from = format!("\"{crate_name} {version} ({old})\""); + let to = format!("\"{crate_name} {version} ({index_url})\""); + let mut repointed_any = false; + // The oldest v1 locks keep the ROOT package in a standalone `[root]` + // table instead of the `[[package]]` array, with its own full-id + // `dependencies`. It precedes the array, so the block walk below + // never reaches it and the lock would keep naming a package it no + // longer contains (`--locked` fails; an unlocked build silently + // re-resolves). + if let Some((start, end)) = lock_root_table(&new_content) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + } + } + let mut cursor = 0; + while let Some((start, end)) = next_lock_block(&new_content, cursor) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + cursor = start + repointed.len(); + } else { + cursor = end; + } + } + if repointed_any { + edits.push(FileEdit { + kind: CARGO_LOCK_REFERENCE_KIND.into(), + ..edit(&from, &to) + }); + } + } + // Already redirected (re-run): every fragment is at the target values; a + // recorded edit would have original == new and grow the ledger forever. + if edits.is_empty() { + return CargoLockPlan::AlreadyRedirected; + } + CargoLockPlan::Rewritten { + content: new_content, + edits, + } +} + +/// The v1 `[root]` table's span, when the lock has one: cargo before the +/// `[root]` removal recorded the root package there rather than in the +/// `[[package]]` array, and its `dependencies` spell full package ids the +/// same way. Bounded by [`lock_block_end`], like a package block. +fn lock_root_table(content: &str) -> Option<(usize, usize)> { + const HEADER: &str = "[root]\n"; + let at = content + .match_indices(HEADER) + .map(|(at, _)| at) + .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; + Some((at, lock_block_end(content, at + HEADER.len()))) +} + +/// The next `[[package]]` block starting at or after `from`, as +/// [`lock_block_end`] bounds it. +pub(crate) fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { + let rel = content.get(from..)?.find("[[package]]\n")?; + let start = from + rel; + if start != 0 && content.as_bytes()[start - 1] != b'\n' { + return next_lock_block(content, start + 1); + } + Some(( + start, + lock_block_end(content, start + "[[package]]\n".len()), + )) +} + +/// End of the `[[package]]` block whose body starts at `body_start`, +/// excluding the newline(s) before the next block / trailing table / EOF (so +/// a recorded original/new stops after the block's last content byte — the +/// TS rewriter's `(?=\n*$)` lookahead — while the file keeps its newlines). +pub(crate) fn lock_block_end(content: &str, body_start: usize) -> usize { + // The next block, or the `[metadata]` / `[[patch.unused]]` tables that + // trail the packages. The trailing tables are searched only up to the + // next block: they sit after every `[[package]]` (absent entirely from + // v3/v4 locks), and an unbounded search per block scanned to EOF for + // every block of every dep. Each marker holds its only `\n` at offset 0, + // so a hit starting before the next block also ends by it — the bounded + // minimum is the unbounded one. + let rest = &content[body_start..]; + let next_block = rest.find("\n[[package]]").unwrap_or(rest.len()); + let mut end = ["\n[metadata]", "\n[[patch.unused]]", "\n[patch"] + .iter() + .filter_map(|marker| rest[..next_block].find(marker)) + .min() + .map_or(body_start + next_block, |rel| body_start + rel); + while end > body_start && content.as_bytes()[end - 1] == b'\n' { + end -= 1; + } + end +} + +/// The previous, unbounded [`lock_block_end`], kept as the equivalence +/// oracle. +#[cfg(test)] +pub(crate) fn lock_block_end_unbounded(content: &str, body_start: usize) -> usize { + let mut end = [ + "\n[[package]]", + "\n[metadata]", + "\n[[patch.unused]]", + "\n[patch", + ] + .iter() + .filter_map(|marker| content[body_start..].find(marker)) + .min() + .map_or(content.len(), |rel| body_start + rel); + while end > body_start && content.as_bytes()[end - 1] == b'\n' { + end -= 1; + } + end +} + +/// Outcome of the Cargo.lock `[[package]]` plan — distinguishes a re-run +/// over an already-redirected block (no edit, no warning) from a genuinely +/// missing package (the caller warns AND skips the dep entirely). +pub(crate) enum CargoLockPlan { + Rewritten { + content: String, + edits: Vec, + }, + AlreadyRedirected, + NotFound, + /// Several `[[package]]` blocks for the name@version (multi-source twins) + /// and not exactly one of them at the target index — which twin is ours + /// cannot be decided, so the caller warns AND skips the dep entirely. + Ambiguous, +} diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs new file mode 100644 index 000000000..9b57cce6e --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -0,0 +1,329 @@ +//! `Cargo.lock` (formats v1–v4) and its hosted splices: the ONE model of +//! the format. +//! +//! [`CargoLock`] parses a lock once (`toml_edit`) and answers what the modes +//! ask of it: +//! +//! * [`CargoLock::entries`] — the registry inventory (`scan` / `get` +//! lockfile supplements, `vendor`'s pristine fetch); +//! * [`CargoLock::packages`] — every `[[package]]` as cargo resolves it +//! ([`LockedPackage`], a v1 lock's `[metadata]` checksums included), the +//! raw material lockfile discovery (`vex::discover::cargo`) classifies as +//! hosted / vendored refs and the vendor probes rank; +//! * [`CargoLock::dependents`] — the packages whose `dependencies` name a +//! crate (the hosted planner's unpinnable-dependents refusal); +//! * [`CargoLock::vendored_in_use`] — whether the lock builds a vendored +//! `[patch]` copy ([`CopyClaim`]); +//! * [`hosted::plan_cargo_lock`] — the hosted planner's lock splice, and the +//! line-grammar probes the hosted rewriter reads with; +//! * the vendored planner (`vendor::cargo_lock`) edits the same document +//! with `toml_edit`; +//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. +//! +//! Everything here is pure; the callers own the reads. + +pub(crate) mod hosted; + +use toml_edit::{DocumentMut, Item}; + +use crate::utils::digest::is_hex; +use crate::utils::purl::simple_purl; +use crate::vendor::cargo_tag; +use crate::vendor::lock_inventory::{LockIntegrity, LockfileEntry, SourceKind}; + +use super::LockModel; + +// ── entry model ── + +/// The `[metadata]` key a v1 lock files `name`+`version`'s checksum under. +pub(crate) fn metadata_checksum_key(name: &str, version: &str, source: &str) -> String { + format!("checksum {name} {version} ({source})") +} + +/// One `[[package]]` of a parsed `Cargo.lock`, as cargo resolves it — the +/// read model every Cargo.lock reader shares (the lock inventory, the vendor +/// probes, lockfile discovery, the hosted planner's dependents check), so a v1 lock's `[metadata]` checksums +/// and a missing `source` read the same everywhere. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LockedPackage { + pub(crate) name: String, + pub(crate) version: String, + /// `None` for a workspace member, a path dependency, or a `[patch]` path + /// copy (the vendored "detached" shape). + pub(crate) source: Option, + /// The inline `checksum` (v2+), else a v1 lock's `[metadata]` + /// `"checksum ()"` entry — the same pin. + pub(crate) checksum: Option, + /// The `dependencies` references as spelled (`"name"`, `"name + /// version"`, `"name version (source)"`; see [`parse_ref`]). + pub(crate) dependencies: Vec, +} + +/// Every `[[package]]` of `doc` (lock formats v1–v4), in lock order; an +/// entry without a string `name` and `version` is skipped. A lock with no +/// packages has no `package` key and yields nothing. +pub(crate) fn locked_packages(doc: &DocumentMut) -> Vec { + let metadata = doc.get("metadata").and_then(Item::as_table_like); + let metadata_checksum = |name: &str, version: &str, source: Option<&str>| { + let key = metadata_checksum_key(name, version, source?); + metadata?.get(&key)?.as_str().map(str::to_string) + }; + doc.get("package") + .and_then(Item::as_array_of_tables) + .map(|pkgs| { + pkgs.iter() + .filter_map(|t| { + let name = t.get("name")?.as_str()?.to_string(); + let version = t.get("version")?.as_str()?.to_string(); + let source = t.get("source").and_then(Item::as_str).map(str::to_string); + let checksum = t + .get("checksum") + .and_then(Item::as_str) + .map(str::to_string) + .or_else(|| metadata_checksum(&name, &version, source.as_deref())); + let dependencies = t + .get("dependencies") + .and_then(Item::as_array) + .map(|deps| { + deps.iter() + .filter_map(|d| d.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(LockedPackage { + name, + version, + source, + checksum, + dependencies, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// `(name, version)` of every `[[patch.unused]]` entry: a `[patch]` cargo +/// resolved and then did NOT use in the crate graph — the lock's own record +/// that a patch (e.g. a vendored copy) is not what builds. +pub(crate) fn unused_patches(doc: &DocumentMut) -> Vec<(String, String)> { + doc.get("patch") + .and_then(|patch| patch.get("unused")) + .and_then(Item::as_array_of_tables) + .map(|entries| { + entries + .iter() + .filter_map(|t| { + Some(( + t.get("name")?.as_str()?.to_string(), + t.get("version")?.as_str()?.to_string(), + )) + }) + .collect() + }) + .unwrap_or_default() +} + +/// How `Cargo.lock` relates to the `[patch]` path copy of `name`@`version` +/// vendored for patch `uuid` ([`vendored_copy_claim`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CopyClaim<'a> { + /// The lock builds this copy. + Consumed, + /// The lock builds the copy tagged for ANOTHER patch uuid (and none + /// tagged for this one): a stale lock, or a `[patch]` override + /// elsewhere. + OtherTag(&'a str), + /// The copy is tagged, but the lock holds only UNTAGGED sourceless + /// entries: cargo built some other untagged crate (a config-level + /// override, a user path dependency), never this copy. + UntaggedOverride, + /// No sourceless entry for it, or cargo recorded the patch as + /// `[[patch.unused]]`. + NotConsumed, +} + +/// Whether the lock BUILDS the `[patch]` path copy of `name`@`version` +/// vendored for patch `uuid`. `copy_tagged`: the copy's own `Cargo.toml` +/// carries a Socket version tag (every copy vendored since tagged +/// versions; `false` for a copy vendored before them, or none on disk). +/// +/// * a SOURCELESS entry at the tagged version `+socket.` +/// (and no `[[patch.unused]]` for it) is this copy — whatever untagged +/// sourceless siblings exist (real cargo 1.97 locks a member's own path +/// dependency on a same-version fork beside the tagged copy); +/// * otherwise a sourceless entry tagged for ANOTHER uuid is another +/// copy's resolution → [`CopyClaim::OtherTag`], even beside an untagged +/// sibling; +/// * an UNTAGGED sourceless entry is the pre-tag legacy shape only while +/// the copy is untagged too: cargo locks a tagged copy at its tagged +/// version, so for a tagged copy the untagged entry is something else +/// cargo built → [`CopyClaim::UntaggedOverride`]. +/// +/// A sourceless entry alone does not prove the copy builds — a path +/// dependency on the user's own checkout of the crate is sourceless too, +/// and cargo records the `[patch]` it resolved but left out of the graph as +/// `[[patch.unused]]` (real cargo 1.97: `serde = { path = "my-serde" }` +/// beside a stale `[patch]` locks a sourceless serde AND +/// `[[patch.unused]] serde`). +pub(crate) fn vendored_copy_claim<'a>( + pkgs: &'a [LockedPackage], + unused: &[(String, String)], + name: &str, + version: &str, + uuid: &str, + copy_tagged: bool, +) -> CopyClaim<'a> { + let mut own = false; + let mut other: Option<&'a str> = None; + let mut untagged = false; + for p in pkgs + .iter() + .filter(|p| p.name == name && p.source.is_none() && cargo_tag::denotes(&p.version, version)) + { + match cargo_tag::tag_uuid(&p.version) { + Some(tag) if tag == uuid => own = true, + Some(tag) => { + other.get_or_insert(tag); + } + None => untagged = true, + } + } + let unused_hit = unused + .iter() + .any(|(n, v)| n == name && cargo_tag::denotes(v, version)); + if own { + return if unused_hit { + CopyClaim::NotConsumed + } else { + CopyClaim::Consumed + }; + } + if let Some(tag) = other { + return CopyClaim::OtherTag(tag); + } + if !untagged || unused_hit { + return CopyClaim::NotConsumed; + } + if copy_tagged { + CopyClaim::UntaggedOverride + } else { + CopyClaim::Consumed + } +} + +/// `(name, version, source)` of a dependency reference string +/// (`"name"`, `"name version"`, `"name version (source)"`). +pub(crate) fn parse_ref(spelled: &str) -> (&str, Option<&str>, Option<&str>) { + let mut parts = spelled.splitn(3, ' '); + let name = parts.next().unwrap_or_default(); + let version = parts.next(); + let source = parts + .next() + .and_then(|s| s.strip_prefix('(')) + .and_then(|s| s.strip_suffix(')')); + (name, version, source) +} + + +// ── the model ── + +/// One `Cargo.lock`, parsed once (see the module docs). +#[derive(Debug)] +pub struct CargoLock { + packages: Vec, + unused: Vec<(String, String)>, +} + +impl CargoLock { + /// The model of an already-parsed lock document. + pub fn from_doc(doc: &DocumentMut) -> Self { + CargoLock { + packages: locked_packages(doc), + unused: unused_patches(doc), + } + } + + /// Parse a lock text; `Err` when it is not TOML (cargo itself refuses + /// to build from it). + pub fn parse(text: &str) -> Result { + Ok(Self::from_doc(&text.parse::()?)) + } + + /// Every `[[package]]`, in lock order. + pub(crate) fn packages(&self) -> &[LockedPackage] { + &self.packages + } + + /// The registry inventory: one entry per SOURCED `[[package]]` + /// (workspace members and vendored copies have none), under its purl + /// identity (a Socket tag stripped). Only a crates.io entry whose + /// checksum is a 64-hex `.crate` sha256 — and whose version is not + /// tagged — carries a verifier; git / custom-registry sources stay + /// listed for discovery without one. + pub fn entries(&self) -> Vec { + let mut out = Vec::new(); + for pkg in &self.packages { + let Some(source) = &pkg.source else { + continue; // workspace member + }; + let version = cargo_tag::strip_tag(&pkg.version).to_string(); + let tagged = version != pkg.version; + let Some(purl) = simple_purl("cargo", &pkg.name, &version) else { + continue; + }; + let crates_io = source.contains("github.com/rust-lang/crates.io-index") + || source.contains("index.crates.io"); + // The crates.io provenance is recorded exactly where the checksum + // is kept as the `.crate`'s sha256. + let (integrity, source_kind) = match &pkg.checksum { + Some(c) if crates_io && !tagged && is_hex(c, 64) => { + (LockIntegrity::Sha256Hex(c.clone()), SourceKind::CratesIo) + } + _ => (LockIntegrity::None, SourceKind::Unspecified), + }; + out.push(LockfileEntry { + ecosystem: "cargo", + source_kind, + purl, + name: pkg.name.clone(), + version, + resolved: None, + integrity, + }); + } + out + } + + /// Every package whose `dependencies` reference `name` at `version` (or + /// by plain name, which cargo writes while the name is unambiguous), in + /// lock order. + pub(crate) fn dependents<'a>( + &'a self, + name: &'a str, + version: &'a str, + ) -> impl Iterator + 'a { + self.packages.iter().filter(move |p| { + p.dependencies.iter().any(|d| { + let (n, v, _) = parse_ref(d); + n == name && v.is_none_or(|v| v == version) + }) + }) + } + + /// How the lock relates to the vendored `[patch]` copy of + /// `name`@`version` for patch `uuid` ([`vendored_copy_claim`]). + pub(crate) fn vendored_in_use( + &self, + name: &str, + version: &str, + uuid: &str, + copy_tagged: bool, + ) -> CopyClaim<'_> { + vendored_copy_claim(&self.packages, &self.unused, name, version, uuid, copy_tagged) + } +} + +impl LockModel for CargoLock { + const FORMAT: &'static str = "Cargo.lock"; +} diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 5b3b5683d..8f7b9e782 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -23,6 +23,7 @@ //! [`registry()`] is the one table of which project files carry a lock or //! its wiring, and in which roles. +pub mod cargo; pub mod pnpm; pub mod registry; diff --git a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs index ad4e20a0c..0c5d76e2b 100644 --- a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs @@ -5,7 +5,7 @@ //! `next_lock_block`) is a function of the bound alone, so equality at every //! body offset of every lock shape is equality of the rewriter. -use super::*; +use crate::formats::cargo::hosted::{lock_block_end, lock_block_end_unbounded, next_lock_block}; /// Deterministic xorshift64* — no `rand` dev-dependency. struct Rng(u64); diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 53111f4d3..9a3c4bd25 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -49,6 +49,9 @@ pub mod presence; #[cfg(test)] use crate::formats::pnpm::grammar as pnpm; use crate::formats::pnpm::plan_hosted; +use crate::formats::cargo::CargoLock; +use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; +pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; #[cfg(test)] @@ -1488,11 +1491,7 @@ fn cargo_not_declared_detail( } else { "Cargo.toml".to_string() }; - let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); - let transitive = lock.is_some_and(|lock| { - lock.match_indices(head.as_str()) - .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') - }); + let transitive = lock.is_some_and(|lock| cargo_lock::is_locked(lock, crate_name, version)); if transitive { format!( "{crate_name}@{version} is a transitive-only dependency (Cargo.lock resolves it, \ @@ -1522,11 +1521,7 @@ fn cargo_requirement_excludes_detail( .map(|(path, req)| format!("\"{req}\" in {path}")) .collect::>() .join(", "); - let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); - let locked = lock.is_some_and(|lock| { - lock.match_indices(head.as_str()) - .any(|(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') - }); + let locked = lock.is_some_and(|lock| cargo_lock::is_locked(lock, crate_name, version)); let remedy = if locked { "; Cargo.lock resolves it for another package, which a pin cannot reach — patch it \ with `socket-patch scan --mode vendored`" @@ -1593,34 +1588,13 @@ fn cargo_unpinnable_dependents( version: &str, pinned_packages: &std::collections::BTreeSet<(&str, &str)>, ) -> Vec { - let Ok(doc) = lock.parse::() else { + let Ok(lock) = CargoLock::parse(lock) else { return vec!["Cargo.lock (it does not parse as TOML)".to_string()]; }; - let Some(packages) = doc - .get("package") - .and_then(toml_edit::Item::as_array_of_tables) - else { - return Vec::new(); - }; let mut out = Vec::new(); - for package in packages.iter() { - let field = |key: &str| package.get(key).and_then(toml_edit::Item::as_str); - let (Some(name), Some(pkg_version)) = (field("name"), field("version")) else { - continue; - }; - let depends = package - .get("dependencies") - .and_then(toml_edit::Item::as_array) - .is_some_and(|deps| { - deps.iter().filter_map(|d| d.as_str()).any(|d| { - let mut parts = d.splitn(3, ' '); - parts.next() == Some(crate_name) && parts.next().is_none_or(|v| v == version) - }) - }); - if !depends { - continue; - } - match field("source") { + for package in lock.dependents(crate_name, version) { + let (name, pkg_version) = (package.name.as_str(), package.version.as_str()); + match &package.source { Some(source) => { let kind = if source.starts_with("git+") { "git" @@ -2412,18 +2386,8 @@ fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str let Some(lock) = lock else { return Vec::new(); }; - let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \""); - let mut versions: Vec = lock - .match_indices(head.as_str()) - .filter(|&(at, _)| at == 0 || lock.as_bytes()[at - 1] == b'\n') - .filter_map(|(at, _)| { - let rest = &lock[at + head.len()..]; - rest.split_once('"').map(|(v, _)| v.to_string()) - }) - .filter(|v| v != version) - .collect(); - versions.sort(); - versions.dedup(); + let mut versions = cargo_lock::locked_versions(lock, crate_name); + versions.retain(|v| v != version); versions } @@ -2871,286 +2835,6 @@ fn plan_cargo_toml( }) } -/// The Cargo.lock edit kind for dependents' full-id references: `original` -/// / `new` are the quoted `" ()"` ids, keyed -/// `@`, and the inverse replaces EVERY occurrence of `new`. -pub(crate) const CARGO_LOCK_REFERENCE_KIND: &str = "redirect_cargo_lock_reference"; - -static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") -}); -static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") -}); -// `$` (not `\n`) so it also anchors a source line that ENDS the block: the -// trailing newline sits outside the block region. -static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^(source = "[^"]*")$"#).expect("static source-line anchor regex is valid") -}); - -/// Repoint the crate's `[[package]]` at the hosted index with the patched -/// `.crate`'s checksum, in whichever Cargo.lock format the file is: -/// -/// * v2–v4: `source` + an inline `checksum` in the entry; -/// * v1 (cargo < 1.41, still read by every cargo): the entry carries only -/// `source`; the checksum lives in the trailing `[metadata]` table under -/// `"checksum ()"`, and every dependent names the -/// crate by its FULL package id `" ()"`. Both are -/// keyed by the source, so both must follow it — a v1 lock with only the -/// entry repointed names a package that no longer exists (cargo discards -/// the lock and re-resolves; `--locked` fails) and pins nothing. -/// -/// Full-id references are rewritten in any format (v2+ spells them that way -/// when a name + version is ambiguous). Each changed fragment is its own -/// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is -/// unambiguous) — the entry and the `[metadata]` line — and the dependents' -/// references are one `redirect_cargo_lock_reference` edit holding the -/// quoted full id, reverted at every occurrence. -fn plan_cargo_lock( - content: &str, - crate_name: &str, - version: &str, - index_url: &str, - cksum: &str, -) -> CargoLockPlan { - // Rust's regex has NO lookahead, so bound the [[package]] block by string - // search (see [`lock_block_end`]): from its header to the next block or - // trailing table (or EOF), so the bytes after the block (incl. the final - // newline) are preserved. - let head = format!("[[package]]\nname = \"{crate_name}\"\nversion = \"{version}\"\n"); - // Every line-anchored header for this name@version. A Cargo.lock may - // legitimately hold TWO blocks for one name@version from different - // sources — after a redirect, a transitive crates.io copy resolves beside - // the socket-registry copy, and cargo sorts the crates.io block FIRST — - // so the first hit alone would repoint the wrong twin. - let heads: Vec = content - .match_indices(head.as_str()) - .map(|(at, _)| at) - .filter(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n') - .collect(); - let block_start = match heads.as_slice() { - [] => return CargoLockPlan::NotFound, - [only] => *only, - twins => { - // Exactly one twin already at the target index is OURS (a re-run - // over a redirected lock); anything else cannot be attributed and - // the dep is skipped transactionally. - let target_source = format!("source = \"{index_url}\""); - let mut ours = twins.iter().copied().filter(|&at| { - let body_start = at + head.len(); - content[body_start..lock_block_end(content, body_start)] - .lines() - .any(|line| line == target_source) - }); - match (ours.next(), ours.next()) { - (Some(at), None) => at, - _ => return CargoLockPlan::Ambiguous, - } - } - }; - let body_start = block_start + head.len(); - let block_end = lock_block_end(content, body_start); - let original = content[block_start..block_end].to_string(); - let mut body = content[body_start..block_end].to_string(); - let old_source = CARGO_LOCK_SOURCE_LINE_RE - .captures(&body) - .map(|c| c[1].to_string()); - if old_source.is_some() { - body = CARGO_LOCK_SOURCE_LINE_RE - .replace(&body, format!("source = \"{index_url}\"").as_str()) - .to_string(); - } else { - body = format!("source = \"{index_url}\"\n{body}"); - } - // A v1 lock keeps the checksum in `[metadata]`, keyed by the package id - // — the chosen block's OWN source when it has one, so a multi-source - // twin's line is never taken for ours. - let metadata_source = old_source - .as_deref() - .map_or_else(|| r#"[^)"]*"#.to_string(), regex::escape); - let metadata_re = Regex::new(&format!( - r#"(?m)^"checksum {} {} \({metadata_source}\)" = "[^"]*"$"#, - regex::escape(crate_name), - regex::escape(version) - )) - .expect("escaped lock metadata-line regex is valid"); - let metadata_line = metadata_re.find(content).map(|m| m.as_str().to_string()); - if metadata_line.is_none() { - if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { - body = CARGO_LOCK_CHECKSUM_LINE_RE - .replace(&body, format!("checksum = \"{cksum}\"").as_str()) - .to_string(); - } else { - body = CARGO_LOCK_AFTER_SOURCE_RE - .replace(&body, format!("${{1}}\nchecksum = \"{cksum}\"").as_str()) - .to_string(); - } - } - let rebuilt = format!("{head}{body}"); - let key = format!("{crate_name}@{version}"); - let edit = |original: &str, new: &str| FileEdit { - path: "Cargo.lock".into(), - kind: "redirect_cargo_lock_entry".into(), - action: "rewritten".into(), - key: Some(key.clone()), - original: Some(Value::String(original.to_string())), - new: Some(Value::String(new.to_string())), - }; - let mut edits = Vec::new(); - let mut new_content = content.to_string(); - if rebuilt != original { - new_content.replace_range(block_start..block_end, &rebuilt); - edits.push(edit(&original, &rebuilt)); - } - if let Some(line) = metadata_line { - let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); - if line != pinned { - new_content = new_content.replacen(&line, &pinned, 1); - edits.push(edit(&line, &pinned)); - } - } - // Dependents' full-id references to the OLD source, recorded as ONE - // `redirect_cargo_lock_reference` edit holding just the quoted id — - // never a dependent's whole block: a block referencing two patched - // packages (the root of a v1 lock) would hold two overlapping block - // edits, and reverting the first-applied one alone would find neither of - // its fragments. The id names this name + version + source exactly, so its - // inverse puts back EVERY occurrence, independently of any other - // package's edits and in any removal order. - if let Some(old) = old_source.filter(|old| old != index_url) { - let from = format!("\"{crate_name} {version} ({old})\""); - let to = format!("\"{crate_name} {version} ({index_url})\""); - let mut repointed_any = false; - // The oldest v1 locks keep the ROOT package in a standalone `[root]` - // table instead of the `[[package]]` array, with its own full-id - // `dependencies`. It precedes the array, so the block walk below - // never reaches it and the lock would keep naming a package it no - // longer contains (`--locked` fails; an unlocked build silently - // re-resolves). - if let Some((start, end)) = lock_root_table(&new_content) { - if new_content[start..end].contains(&from) { - let repointed = new_content[start..end].replace(&from, &to); - new_content.replace_range(start..end, &repointed); - repointed_any = true; - } - } - let mut cursor = 0; - while let Some((start, end)) = next_lock_block(&new_content, cursor) { - if new_content[start..end].contains(&from) { - let repointed = new_content[start..end].replace(&from, &to); - new_content.replace_range(start..end, &repointed); - repointed_any = true; - cursor = start + repointed.len(); - } else { - cursor = end; - } - } - if repointed_any { - edits.push(FileEdit { - kind: CARGO_LOCK_REFERENCE_KIND.into(), - ..edit(&from, &to) - }); - } - } - // Already redirected (re-run): every fragment is at the target values; a - // recorded edit would have original == new and grow the ledger forever. - if edits.is_empty() { - return CargoLockPlan::AlreadyRedirected; - } - CargoLockPlan::Rewritten { - content: new_content, - edits, - } -} - -/// The v1 `[root]` table's span, when the lock has one: cargo before the -/// `[root]` removal recorded the root package there rather than in the -/// `[[package]]` array, and its `dependencies` spell full package ids the -/// same way. Bounded by [`lock_block_end`], like a package block. -fn lock_root_table(content: &str) -> Option<(usize, usize)> { - const HEADER: &str = "[root]\n"; - let at = content - .match_indices(HEADER) - .map(|(at, _)| at) - .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; - Some((at, lock_block_end(content, at + HEADER.len()))) -} - -/// The next `[[package]]` block starting at or after `from`, as -/// [`lock_block_end`] bounds it. -fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { - let rel = content.get(from..)?.find("[[package]]\n")?; - let start = from + rel; - if start != 0 && content.as_bytes()[start - 1] != b'\n' { - return next_lock_block(content, start + 1); - } - Some(( - start, - lock_block_end(content, start + "[[package]]\n".len()), - )) -} - -/// End of the `[[package]]` block whose body starts at `body_start`, -/// excluding the newline(s) before the next block / trailing table / EOF (so -/// a recorded original/new stops after the block's last content byte — the -/// TS rewriter's `(?=\n*$)` lookahead — while the file keeps its newlines). -fn lock_block_end(content: &str, body_start: usize) -> usize { - // The next block, or the `[metadata]` / `[[patch.unused]]` tables that - // trail the packages. The trailing tables are searched only up to the - // next block: they sit after every `[[package]]` (absent entirely from - // v3/v4 locks), and an unbounded search per block scanned to EOF for - // every block of every dep. Each marker holds its only `\n` at offset 0, - // so a hit starting before the next block also ends by it — the bounded - // minimum is the unbounded one. - let rest = &content[body_start..]; - let next_block = rest.find("\n[[package]]").unwrap_or(rest.len()); - let mut end = ["\n[metadata]", "\n[[patch.unused]]", "\n[patch"] - .iter() - .filter_map(|marker| rest[..next_block].find(marker)) - .min() - .map_or(body_start + next_block, |rel| body_start + rel); - while end > body_start && content.as_bytes()[end - 1] == b'\n' { - end -= 1; - } - end -} - -/// The previous, unbounded [`lock_block_end`], kept as the equivalence -/// oracle. -#[cfg(test)] -fn lock_block_end_unbounded(content: &str, body_start: usize) -> usize { - let mut end = [ - "\n[[package]]", - "\n[metadata]", - "\n[[patch.unused]]", - "\n[patch", - ] - .iter() - .filter_map(|marker| content[body_start..].find(marker)) - .min() - .map_or(content.len(), |rel| body_start + rel); - while end > body_start && content.as_bytes()[end - 1] == b'\n' { - end -= 1; - } - end -} - -/// Outcome of the Cargo.lock `[[package]]` plan — distinguishes a re-run -/// over an already-redirected block (no edit, no warning) from a genuinely -/// missing package (the caller warns AND skips the dep entirely). -enum CargoLockPlan { - Rewritten { - content: String, - edits: Vec, - }, - AlreadyRedirected, - NotFound, - /// Several `[[package]]` blocks for the name@version (multi-source twins) - /// and not exactly one of them at the target index — which twin is ours - /// cannot be decided, so the caller warns AND skips the dep entirely. - Ambiguous, -} - struct CargoConfigPlan { content: String, edit: FileEdit, diff --git a/crates/socket-patch-core/src/vendor/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 2a800d93e..7e50bccaf 100644 --- a/crates/socket-patch-core/src/vendor/cargo_lock.rs +++ b/crates/socket-patch-core/src/vendor/cargo_lock.rs @@ -64,6 +64,9 @@ use std::sync::Arc; use toml_edit::{DocumentMut, Item, Table}; use super::cargo_tag; +use crate::formats::cargo::{ + locked_packages, metadata_checksum_key, parse_ref, LockedPackage, +}; use super::parse_memo::ParseMemo; use super::state::CargoLockOriginal; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; @@ -211,170 +214,6 @@ fn set_version(table: &mut Table, version: &str) { } } -/// The `[metadata]` key a v1 lock files `name`+`version`'s checksum under. -fn metadata_checksum_key(name: &str, version: &str, source: &str) -> String { - format!("checksum {name} {version} ({source})") -} - -/// One `[[package]]` of a parsed `Cargo.lock`, as cargo resolves it — the -/// read model every Cargo.lock reader shares (the lock inventory, the vendor -/// probes below, lockfile discovery), so a v1 lock's `[metadata]` checksums -/// and a missing `source` read the same everywhere. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct LockedPackage { - pub(crate) name: String, - pub(crate) version: String, - /// `None` for a workspace member, a path dependency, or a `[patch]` path - /// copy (the vendored "detached" shape). - pub(crate) source: Option, - /// The inline `checksum` (v2+), else a v1 lock's `[metadata]` - /// `"checksum ()"` entry — the same pin. - pub(crate) checksum: Option, -} - -/// Every `[[package]]` of `doc` (lock formats v1–v4), in lock order; an -/// entry without a string `name` and `version` is skipped. A lock with no -/// packages has no `package` key and yields nothing. -pub(crate) fn locked_packages(doc: &DocumentMut) -> Vec { - let metadata = doc.get("metadata").and_then(Item::as_table_like); - let metadata_checksum = |name: &str, version: &str, source: Option<&str>| { - let key = metadata_checksum_key(name, version, source?); - metadata?.get(&key)?.as_str().map(str::to_string) - }; - doc.get("package") - .and_then(Item::as_array_of_tables) - .map(|pkgs| { - pkgs.iter() - .filter_map(|t| { - let name = t.get("name")?.as_str()?.to_string(); - let version = t.get("version")?.as_str()?.to_string(); - let source = t.get("source").and_then(Item::as_str).map(str::to_string); - let checksum = t - .get("checksum") - .and_then(Item::as_str) - .map(str::to_string) - .or_else(|| metadata_checksum(&name, &version, source.as_deref())); - Some(LockedPackage { - name, - version, - source, - checksum, - }) - }) - .collect() - }) - .unwrap_or_default() -} - -/// `(name, version)` of every `[[patch.unused]]` entry: a `[patch]` cargo -/// resolved and then did NOT use in the crate graph — the lock's own record -/// that a patch (e.g. a vendored copy) is not what builds. -pub(crate) fn unused_patches(doc: &DocumentMut) -> Vec<(String, String)> { - doc.get("patch") - .and_then(|patch| patch.get("unused")) - .and_then(Item::as_array_of_tables) - .map(|entries| { - entries - .iter() - .filter_map(|t| { - Some(( - t.get("name")?.as_str()?.to_string(), - t.get("version")?.as_str()?.to_string(), - )) - }) - .collect() - }) - .unwrap_or_default() -} - -/// How `Cargo.lock` relates to the `[patch]` path copy of `name`@`version` -/// vendored for patch `uuid` ([`vendored_copy_claim`]). -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum CopyClaim<'a> { - /// The lock builds this copy. - Consumed, - /// The lock builds the copy tagged for ANOTHER patch uuid (and none - /// tagged for this one): a stale lock, or a `[patch]` override - /// elsewhere. - OtherTag(&'a str), - /// The copy is tagged, but the lock holds only UNTAGGED sourceless - /// entries: cargo built some other untagged crate (a config-level - /// override, a user path dependency), never this copy. - UntaggedOverride, - /// No sourceless entry for it, or cargo recorded the patch as - /// `[[patch.unused]]`. - NotConsumed, -} - -/// Whether the lock BUILDS the `[patch]` path copy of `name`@`version` -/// vendored for patch `uuid`. `copy_tagged`: the copy's own `Cargo.toml` -/// carries a Socket version tag (every copy vendored since tagged -/// versions; `false` for a copy vendored before them, or none on disk). -/// -/// * a SOURCELESS entry at the tagged version `+socket.` -/// (and no `[[patch.unused]]` for it) is this copy — whatever untagged -/// sourceless siblings exist (real cargo 1.97 locks a member's own path -/// dependency on a same-version fork beside the tagged copy); -/// * otherwise a sourceless entry tagged for ANOTHER uuid is another -/// copy's resolution → [`CopyClaim::OtherTag`], even beside an untagged -/// sibling; -/// * an UNTAGGED sourceless entry is the pre-tag legacy shape only while -/// the copy is untagged too: cargo locks a tagged copy at its tagged -/// version, so for a tagged copy the untagged entry is something else -/// cargo built → [`CopyClaim::UntaggedOverride`]. -/// -/// A sourceless entry alone does not prove the copy builds — a path -/// dependency on the user's own checkout of the crate is sourceless too, -/// and cargo records the `[patch]` it resolved but left out of the graph as -/// `[[patch.unused]]` (real cargo 1.97: `serde = { path = "my-serde" }` -/// beside a stale `[patch]` locks a sourceless serde AND -/// `[[patch.unused]] serde`). -pub(crate) fn vendored_copy_claim<'a>( - pkgs: &'a [LockedPackage], - unused: &[(String, String)], - name: &str, - version: &str, - uuid: &str, - copy_tagged: bool, -) -> CopyClaim<'a> { - let mut own = false; - let mut other: Option<&'a str> = None; - let mut untagged = false; - for p in pkgs - .iter() - .filter(|p| p.name == name && p.source.is_none() && cargo_tag::denotes(&p.version, version)) - { - match cargo_tag::tag_uuid(&p.version) { - Some(tag) if tag == uuid => own = true, - Some(tag) => { - other.get_or_insert(tag); - } - None => untagged = true, - } - } - let unused_hit = unused - .iter() - .any(|(n, v)| n == name && cargo_tag::denotes(v, version)); - if own { - return if unused_hit { - CopyClaim::NotConsumed - } else { - CopyClaim::Consumed - }; - } - if let Some(tag) = other { - return CopyClaim::OtherTag(tag); - } - if !untagged || unused_hit { - return CopyClaim::NotConsumed; - } - if copy_tagged { - CopyClaim::UntaggedOverride - } else { - CopyClaim::Consumed - } -} - /// A v1 lock: no top-level `version` key and a `[metadata]` table (kept, /// even emptied, by [`detach_lock_entry`] — so a detached v1 lock still /// reads as v1 on restore). @@ -406,19 +245,6 @@ fn dependency_tables_mut(doc: &mut DocumentMut) -> Vec<&mut Table> { out } -/// `(name, version, source)` of a dependency reference string -/// (`"name"`, `"name version"`, `"name version (source)"`). -fn parse_ref(spelled: &str) -> (&str, Option<&str>, Option<&str>) { - let mut parts = spelled.splitn(3, ' '); - let name = parts.next().unwrap_or_default(); - let version = parts.next(); - let source = parts - .next() - .and_then(|s| s.strip_prefix('(')) - .and_then(|s| s.strip_suffix(')')); - (name, version, source) -} - /// Rewrite every dependency reference to `name` at exactly `version` — /// `"name version"`, or `"name version (source)"` when `source` is given — /// to `to`, keeping each entry's formatting. @@ -949,6 +775,7 @@ async fn count_lock_entries_unmemoized(project_root: &Path, name: &str, version: #[cfg(test)] mod tests { use super::*; + use crate::formats::cargo::{vendored_copy_claim, CopyClaim}; const SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; const CHECKSUM: &str = "9d8f4e3bd2c8f1f5d1a3f5e7c9b1d3f5e7a9b1c3d5f7e9a1b3c5d7e9f1a3b5c7"; @@ -2065,6 +1892,7 @@ mod tests { version: version.into(), source: source.map(str::to_string), checksum: None, + dependencies: Vec::new(), }; let tagged = format!("1.0.4+socket.{UUID}"); let other = format!("1.0.4+socket.{UUID2}"); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs index f334b46ea..37caa5035 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs @@ -3,17 +3,16 @@ #[cfg(test)] use std::path::Path; -use crate::utils::digest::is_hex; -use crate::utils::purl::simple_purl; +use crate::formats::cargo::CargoLock; use super::view::ProjectView; -use super::{dedup_prefer_integrity, LockIntegrity, LockfileEntry, SourceKind}; +use super::{dedup_prefer_integrity, LockfileEntry}; // ── registry view ── -/// Inventory `Cargo.lock` `[[package]]` entries, read through the vendor -/// backend's lock model ([`crate::vendor::cargo_lock::locked_packages`]; a v1 lock's -/// `[metadata]` checksums included). Only crates.io-sourced entries are +/// Inventory `Cargo.lock` `[[package]]` entries, read through the +/// format's model ([`CargoLock::entries`]; a v1 lock's `[metadata]` +/// checksums included). Only crates.io-sourced entries are /// fetchable (their `checksum` is the sha256 of the `.crate` file); /// workspace members and vendored copies (no `source`; a vendored copy's /// version carries the `+socket.` tag, see `vendor::cargo_tag`) are @@ -52,35 +51,5 @@ pub(super) async fn inventory_cargo_lock_raw_in( std::sync::Arc::new(view.read_text("Cargo.lock").await.ok()?.parse().ok()?) } }; - let mut out = Vec::new(); - for pkg in crate::vendor::cargo_lock::locked_packages(&doc) { - let Some(source) = pkg.source else { - continue; // workspace member - }; - let version = crate::vendor::cargo_tag::strip_tag(&pkg.version).to_string(); - let tagged = version != pkg.version; - let Some(purl) = simple_purl("cargo", &pkg.name, &version) else { - continue; - }; - let crates_io = source.contains("github.com/rust-lang/crates.io-index") - || source.contains("index.crates.io"); - // The crates.io provenance is recorded exactly where the checksum is - // kept as the `.crate`'s sha256. - let (integrity, source_kind) = match pkg.checksum { - Some(c) if crates_io && !tagged && is_hex(&c, 64) => { - (LockIntegrity::Sha256Hex(c), SourceKind::CratesIo) - } - _ => (LockIntegrity::None, SourceKind::Unspecified), - }; - out.push(LockfileEntry { - ecosystem: "cargo", - source_kind, - purl, - name: pkg.name, - version, - resolved: None, - integrity, - }); - } - Some(out) + Some(CargoLock::from_doc(&doc).entries()) } diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 8c537427e..86aab22de 100644 --- a/crates/socket-patch-core/src/vex/discover/cargo.rs +++ b/crates/socket-patch-core/src/vex/discover/cargo.rs @@ -118,14 +118,12 @@ use super::{ Discovery, PatchedRef, TomlDiag, UnlockedPin, VendorRef, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; +use crate::formats::cargo::{CargoLock, CopyClaim, LockedPackage}; use crate::utils::digest::is_hex64_lower; use crate::vendor::cargo_config::{ effective_config_rel, patch_entries, registry_definitions, CargoPatchEntry, CONFIG_LEGACY, CONFIG_TOML, SOCKET_REGISTRY_PREFIX, }; -use crate::vendor::cargo_lock::{ - locked_packages, unused_patches, vendored_copy_claim, CopyClaim, LockedPackage, -}; use crate::vendor::cargo_manifest::{crates_io_url_alias_tables, is_crates_io_source}; use crate::vendor::cargo_tag; use crate::vendor::lock_inventory::LockIntegrity; @@ -269,26 +267,21 @@ fn unattributed_tags(lock: &Lock, wired: &[VendorRef], out: &mut Discovery) { // ── reads ──────────────────────────────────────────────────────────────── -/// The state of the root `Cargo.lock`, read through the vendor backend's -/// own lock model ([`locked_packages`] / [`unused_patches`]). +/// The state of the root `Cargo.lock`, read through the format's model +/// ([`CargoLock`]). #[derive(Debug)] enum Lock { /// No lock (or unreadable — diagnosed by the read). Absent, /// Present but not TOML (diagnosed). Unparseable, - Parsed { - pkgs: Vec, - /// `(name, version)` of every `[[patch.unused]]` entry: a `[patch]` - /// cargo resolved and then did NOT use in the crate graph. - unused: Vec<(String, String)>, - }, + Parsed(CargoLock), } impl Lock { fn packages(&self) -> &[LockedPackage] { match self { - Lock::Parsed { pkgs, .. } => pkgs, + Lock::Parsed(lock) => lock.packages(), Lock::Absent | Lock::Unparseable => &[], } } @@ -305,10 +298,7 @@ async fn load_lock(ctx: &DiscoverCtx<'_>, out: &mut Discovery) -> Lock { // checksums read as the same pin the inline v2+ `checksum` is — the // hosted rewriter writes it there for a v1 lock); `[[patch.unused]]` is // never a package — it is the "not wired" shape, kept apart. - Lock::Parsed { - pkgs: locked_packages(&doc), - unused: unused_patches(&doc), - } + Lock::Parsed(CargoLock::from_doc(&doc)) } /// Guarded read + parse of a TOML file (`None`: missing, unreadable, or @@ -747,9 +737,9 @@ async fn vendored_from_patches( } } let copy_tagged = matches!(tag, CopyTag::Tagged(_) | CopyTag::Unreadable); - if let Lock::Parsed { pkgs, unused } = lock { + if let Lock::Parsed(lock) = lock { let why = - match vendored_copy_claim(pkgs, unused, name, version, &vref.uuid, copy_tagged) { + match lock.vendored_in_use(name, version, &vref.uuid, copy_tagged) { CopyClaim::Consumed => None, CopyClaim::OtherTag(other) => Some(format!( "{CARGO_LOCK} builds the copy tagged for patch {other} ({name} {})", From 055f6e1f85f7ca379504bb3f39ac918755ba52f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:17:19 +0000 Subject: [PATCH 05/13] Read composer.lock through one format model formats::composer owns the entry walk (ComposerLockPackage, now with its ownership gate wired_to) behind ComposerLock: entries() for the inventory and packages() for lockfile discovery and the vendored backend. The vendored backend's entry_is_wired and repair's recorded-fragment reader, which read dist fields straight off the JSON, go through the entry model. The hosted planner's byte scanner moves verbatim to formats::composer::hosted; its equivalence oracle stays green. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/composer/hosted.rs | 317 ++++++++++++++++++ .../src/formats/composer/mod.rs | 181 ++++++++++ crates/socket-patch-core/src/formats/mod.rs | 1 + .../redirect/composer_equivalence_tests.rs | 2 + .../src/patch/redirect/mod.rs | 302 +---------------- .../src/vendor/composer_lock.rs | 20 +- .../src/vendor/lock_inventory/composer.rs | 119 +------ .../src/vendor/lock_inventory/mod.rs | 1 - .../src/vendor/lock_inventory/recover.rs | 28 +- .../src/vex/discover/composer.rs | 6 +- 10 files changed, 529 insertions(+), 448 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/composer/hosted.rs create mode 100644 crates/socket-patch-core/src/formats/composer/mod.rs diff --git a/crates/socket-patch-core/src/formats/composer/hosted.rs b/crates/socket-patch-core/src/formats/composer/hosted.rs new file mode 100644 index 000000000..0ab5dae33 --- /dev/null +++ b/crates/socket-patch-core/src/formats/composer/hosted.rs @@ -0,0 +1,317 @@ +//! The hosted planner's `composer.lock` leg: repoint a package's `dist` at +//! its Socket-hosted archive (url + `shasum`, dropping the `source` block +//! composer would otherwise prefer) as a byte splice over composer's own +//! pretty-printed JSON, so every untouched byte — key order, escapes, line +//! endings — survives. A serde round trip cannot give those offsets, which +//! is why this scanner is separate from the read model in the parent. + +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::Value; + +use crate::crawlers::composer_crawler::normalize_version; +use crate::patch::redirect::{ + artifact_url_present, full_name, DepOverride, FileEdit, RewriteResult, RewriteWarning, +}; + +/// Byte offset of the `}` closing the JSON object that CONTAINS `from`, which +/// must be a position inside that object. Brace counting skips string literals, +/// so a brace inside a description or URL cannot move the boundary. +/// +/// Walks bytes, not chars: every byte it acts on is ASCII, and no byte of a +/// multi-byte UTF-8 sequence is, so the offsets are the char walk's (an +/// escaped multi-byte char clears `escaped` on its lead byte). +pub(crate) fn json_object_end_from(text: &str, from: usize) -> Option { + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (offset, &byte) in text.as_bytes()[from..].iter().enumerate() { + if in_string { + match byte { + _ if escaped => escaped = false, + b'\\' => escaped = true, + b'"' => in_string = false, + _ => {} + } + continue; + } + match byte { + b'"' => in_string = true, + b'{' => depth += 1, + b'}' if depth == 0 => return Some(from + offset), + b'}' => depth -= 1, + _ => {} + } + } + None +} + +/// Value of the first `"": ""` pair in `text` (composer writes its +/// lock with exactly one space after the colon, the same shape the surgical +/// `dist` regexes below assume). +pub(crate) fn json_string_field<'a>(text: &'a str, key: &str) -> Option<&'a str> { + let pattern = format!("\"{key}\": \""); + let start = text.find(&pattern)? + pattern.len(); + let end = text[start..].find('"')? + start; + Some(&text[start..end]) +} + +/// Outcome of locating a package entry in a composer.lock. +pub(crate) enum ComposerEntry { + /// Inclusive byte range from the entry's `"name"` key to the `}` closing + /// the entry — composer writes `name` first, so this covers every key the + /// rewriter edits. + Found(usize, usize), + /// The name matched but the lock pins this OTHER version. + VersionMismatch(String), + NotFound, +} + +/// Locate `pkg`'s entry in a composer.lock (either `packages[]` or +/// `packages-dev[]` — the scan is over the whole file). +/// +/// Names match CASE-INSENSITIVELY, the way the composer crawler and the vendor +/// backend already match them: packagist canonicalizes to lowercase, but +/// hand-written mixed-case locks install fine and would otherwise silently miss +/// the redirect. The locked version must match the patched one through +/// composer's leading-`v` normalization (locks carry the pretty `v6.4.1`, PURLs +/// the bare `6.4.1`); matching on name alone would repoint whatever version +/// the lock happened to hold at a patch built for a different one. +pub(crate) fn find_composer_entry(content: &str, pkg: &str, version: &str) -> ComposerEntry { + let mut mismatched: Option = None; + for (name_idx, _) in content.match_indices("\"name\": \"") { + // The name is the value at `name_idx` — the entry's first field — + // and its closing quote precedes any `}` the object walk can stop + // at, so test it before walking to the end of the object: most + // occurrences name some other package. + if !json_string_field(&content[name_idx..], "name") + .is_some_and(|n| n.eq_ignore_ascii_case(pkg)) + { + continue; + } + let Some(end) = json_object_end_from(content, name_idx) else { + continue; + }; + let entry = &content[name_idx..=end]; + // Every package entry carries `version`; an `authors[]`/`support` + // object that happens to have a matching `name` does not. + let Some(locked) = json_string_field(entry, "version") else { + continue; + }; + if normalize_version(locked) == normalize_version(version) { + return ComposerEntry::Found(name_idx, end); + } + mismatched = Some(locked.to_string()); + } + match mismatched { + Some(locked) => ComposerEntry::VersionMismatch(locked), + None => ComposerEntry::NotFound, + } +} + +/// Append `"shasum": ""` as the last key of a `"dist": { … }` block, +/// indented like the keys already in it. VCS/zipball dists omit `shasum` +/// entirely; redirecting such a block without inserting the pin would leave the +/// hosted artifact unverified, so composer would install whatever the URL returned. +/// `block` is the whole dist object and already holds at least a `url`. +pub(crate) fn append_composer_shasum(block: &str, sha1: &str) -> String { + let Some(close) = block.rfind('}') else { + return block.to_string(); + }; + let head = block[..close].trim_end(); + let indent: String = head[head.rfind('\n').map_or(0, |i| i + 1)..] + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + format!( + "{head},\n{indent}\"shasum\": \"{sha1}\"{}", + &block[head.len()..] + ) +} + +pub(crate) static COMPOSER_DIST_TYPE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"("type": ")[^"]*(")"#).expect("static dist type regex is valid") +}); +pub(crate) static COMPOSER_DIST_URL_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"("url": ")[^"]*(")"#).expect("static dist url regex is valid")); +pub(crate) static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid") +}); + +/// Byte offset of the entry's `"source": {` key when that object is the +/// dist block's IMMEDIATE predecessor (only `,` + whitespace between them) — +/// the layout composer itself always writes (`source` then `dist`). +/// `None` when the entry has no source object there. +pub(crate) fn composer_source_before_dist( + content: &str, + entry_start: usize, + dist_start: usize, +) -> Option { + const SOURCE_KEY: &str = "\"source\": {"; + let source_start = entry_start + content[entry_start..dist_start].rfind(SOURCE_KEY)?; + let source_end = json_object_end_from(content, source_start + SOURCE_KEY.len())?; + (source_end < dist_start && content[source_end + 1..dist_start].trim() == ",") + .then_some(source_start) +} + +pub(crate) fn rewrite_composer_lock( + files: &BTreeMap, + overrides: &[DepOverride], + result: &mut RewriteResult, +) { + let composer: Vec<&DepOverride> = overrides + .iter() + .filter(|o| o.ecosystem == "composer") + .collect(); + if composer.is_empty() { + return; + } + // Parity with `redirect_npm_no_lockfile`: a granted dep the project has + // no lock to pin must be SAID, not silently dropped from the redirected + // count (a composer.json + installed vendor tree without a lock is + // discovered and granted like any other). + if !files.contains_key("composer.lock") { + result.warnings.push(RewriteWarning { + code: "redirect_composer_no_lockfile".into(), + detail: "no composer.lock present; composer redirect skipped".into(), + }); + return; + } + const DIST_KEY: &str = "\"dist\": {"; + let mut content = files["composer.lock"].clone(); + let type_re: &Regex = &COMPOSER_DIST_TYPE_RE; + let url_re: &Regex = &COMPOSER_DIST_URL_RE; + let shasum_re: &Regex = &COMPOSER_DIST_SHASUM_RE; + let mut changed = false; + for dep in &composer { + let composer_name = full_name(dep); + let Some(sha1) = dep.integrity.sha1.clone() else { + result.warnings.push(RewriteWarning { + code: "redirect_composer_missing_sha1".into(), + detail: format!("{composer_name} has no sha1 (dist.shasum) integrity"), + }); + continue; + }; + let (entry_start, entry_end) = + match find_composer_entry(&content, &composer_name, &dep.version) { + ComposerEntry::Found(start, end) => (start, end), + ComposerEntry::VersionMismatch(locked) => { + result.warnings.push(RewriteWarning { + code: "redirect_composer_version_mismatch".into(), + detail: format!( + "composer.lock pins {composer_name}@{locked}, not the patched {}", + dep.version + ), + }); + continue; + } + ComposerEntry::NotFound => { + result.warnings.push(RewriteWarning { + code: "redirect_composer_pkg_not_found".into(), + detail: format!( + "no composer.lock package named {composer_name}@{}", + dep.version + ), + }); + continue; + } + }; + // The dist block MUST belong to the located entry. Scanning forward + // from the name for the next `"dist": {` would walk into the FOLLOWING + // package whenever the target was installed from source, repointing a + // bystander's url + shasum — a checksum-clean install of the wrong + // code. A target with no dist of its own pins nothing: fail closed. + let Some(dist_start) = content[entry_start..=entry_end] + .find(DIST_KEY) + .map(|offset| entry_start + offset) + else { + result.warnings.push(RewriteWarning { + code: "redirect_composer_no_dist".into(), + detail: format!("{composer_name} has no dist block"), + }); + continue; + }; + let Some(dist_end) = json_object_end_from(&content, dist_start + DIST_KEY.len()) else { + result.warnings.push(RewriteWarning { + code: "redirect_composer_lock_malformed".into(), + detail: format!("{composer_name}'s dist block is unterminated"), + }); + continue; + }; + let block = content[dist_start..=dist_end].to_string(); + // Already redirected (either slash spelling): recording an edit whose + // `original` IS the hosted url would grow the ledger on every re-run + // and poison a future revert. + if artifact_url_present(&block, &dep.artifact_url) && block.contains(&sha1) { + continue; + } + if !block.contains("\"url\": \"") { + result.warnings.push(RewriteWarning { + code: "redirect_composer_no_dist_url".into(), + detail: format!("{composer_name}'s dist block has no url to redirect"), + }); + continue; + } + let mut rewritten = type_re.replace(&block, "${1}zip${2}").to_string(); + rewritten = url_re + .replace( + &rewritten, + format!("${{1}}{}${{2}}", dep.artifact_url).as_str(), + ) + .to_string(); + rewritten = if rewritten.contains("\"shasum\": \"") { + shasum_re + .replace(&rewritten, format!("${{1}}{sha1}${{2}}").as_str()) + .to_string() + } else { + append_composer_shasum(&rewritten, &sha1) + }; + // Drop the entry's `source` (the vendored backend does the same): + // when the dist download fails — checksum mismatch, an expired grant + // token, a patch-server outage — composer 1 and composer 2 before its + // source-fallback cutoff (2.2 LTS included) print "Now trying to + // download from source" and silently install the PRISTINE upstream + // commit from git, and `--prefer-source` / `preferred-install: + // source` always does. With the source gone the hosted archive is + // the only way to install the package, so a failed fetch fails the + // install instead of shipping the vulnerable code. The edit then + // spans `"source": {…},\n"dist": {…}`, so the ledger's + // fragment revert puts both blocks back byte-for-byte. + let (edit_start, original) = + match composer_source_before_dist(&content, entry_start, dist_start) { + Some(source_start) => (source_start, content[source_start..=dist_end].to_string()), + None => { + if content[entry_start..=entry_end].contains("\"source\": {") { + result.warnings.push(RewriteWarning { + code: "redirect_composer_source_kept".into(), + detail: format!( + "{composer_name}'s source block does not directly precede its \ + dist and was left in place; a failed hosted download may fall \ + back to it" + ), + }); + } + (dist_start, block.clone()) + } + }; + if rewritten != original { + // In place: a fresh whole-lock copy per edit would hold one + // lock-sized buffer per redirected dep. + content.replace_range(edit_start..=dist_end, &rewritten); + changed = true; + result.edits.push(FileEdit { + path: "composer.lock".into(), + kind: "redirect_composer_dist".into(), + action: "rewritten".into(), + key: Some(composer_name), + original: Some(Value::String(original)), + new: Some(Value::String(rewritten)), + }); + } + } + if changed { + result.files.insert("composer.lock".into(), content); + } +} diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs new file mode 100644 index 000000000..53b74410b --- /dev/null +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -0,0 +1,181 @@ +//! `composer.lock`: the ONE model of the format. +//! +//! [`ComposerLock`] reads a parsed lock once and answers what the modes ask +//! of it: +//! +//! * [`ComposerLock::entries`] — the registry inventory; +//! * [`ComposerLock::packages`] — every `packages` / `packages-dev` entry +//! ([`ComposerLockPackage`]), the raw material lockfile discovery +//! (`vex::discover::composer`) classifies as hosted / vendored refs and +//! the vendored backend (`vendor::composer_lock`) indexes its edits and +//! its ownership gate ([`ComposerLockPackage::wired_to`]) by; +//! * [`hosted::rewrite_composer_lock`] — the hosted planner's byte splice; +//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. + +pub(crate) mod hosted; + +use serde_json::Value; + +use crate::crawlers::composer_crawler::normalize_version; +use crate::patch::path_safety; +use crate::utils::digest::sha1_hex; +use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; +use crate::vendor::path::{parse_vendor_path, VendorPathParts}; + +use super::LockModel; + +// ── entry model ── + +/// One entry of a parsed `composer.lock` (see [`composer_lock_packages`]). +pub(crate) struct ComposerLockPackage<'a> { + /// `packages` or `packages-dev`. + pub(crate) section: &'static str, + /// The position in the section ARRAY, counting non-object elements too: + /// what a writer indexes `lock[section][index]` with. + pub(crate) index: usize, + pub(crate) name: Option<&'a str>, + /// As locked — the pretty `v`-prefixed spelling; callers normalize + /// through [`normalize_version`]. + pub(crate) version: Option<&'a str>, + /// The `dist` object: what composer's default `--prefer-dist` install + /// consumes, and the block both backends rewrite. + pub(crate) dist: Option<&'a Value>, +} + +impl<'a> ComposerLockPackage<'a> { + /// One section element read as an entry (a recorded wiring fragment is + /// read through this too, so it answers exactly as the live lock would). + pub(crate) fn of(section: &'static str, index: usize, pkg: &'a Value) -> Self { + ComposerLockPackage { + section, + index, + name: pkg.get("name").and_then(Value::as_str), + version: pkg.get("version").and_then(Value::as_str), + dist: pkg.get("dist"), + } + } + + /// A string field of the entry's `dist`. + pub(crate) fn dist_str(&self, key: &str) -> Option<&str> { + self.dist?.get(key)?.as_str() + } + + /// The `dist.shasum` pin: a 40-hex sha1 of the dist archive (any case, + /// lowercased), whatever the dist `type` — the inventory additionally + /// requires a `zip` dist at its call site. + pub(crate) fn dist_sha1(&self) -> Option { + self.dist_str("shasum") + .and_then(sha1_hex) + .map(LockIntegrity::Sha1Hex) + } + + /// The Socket-vendored path `dist.url` names, anchored anywhere + /// ([`parse_vendor_path`]: the writers' ownership rule, not discovery's + /// root-anchored attestation grammar). + pub(crate) fn dist_vendor_path(&self) -> Option { + self.dist_str("url").and_then(parse_vendor_path) + } + + /// Whether the entry's `dist.url` points into patch `uuid`'s vendored + /// composer copy — the ownership gate every restore / strand check + /// applies. + pub(crate) fn wired_to(&self, uuid: &str) -> bool { + self.dist_vendor_path() + .is_some_and(|p| p.eco == "composer" && p.uuid == uuid) + } +} + +/// Every entry composer installs from a parsed `composer.lock`, in lock +/// order: `packages`, then `packages-dev` (composer installs both by +/// default; a missing or non-array section is empty). The one walk the +/// inventory and lockfile discovery (`vex::discover::composer`) share. +pub(crate) fn composer_lock_packages(doc: &Value) -> Vec> { + let mut out = Vec::new(); + for section in ["packages", "packages-dev"] { + for (index, pkg) in doc + .get(section) + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + out.push(ComposerLockPackage::of(section, index, pkg)); + } + } + out +} + + +// ── the model ── + +/// One `composer.lock`, read once (see the module docs). +pub struct ComposerLock<'a> { + packages: Vec>, +} + +impl<'a> ComposerLock<'a> { + /// The model of a parsed lock document. + pub fn from_doc(doc: &'a Value) -> Self { + ComposerLock { + packages: composer_lock_packages(doc), + } + } + + /// Every entry, in install order ([`composer_lock_packages`]). + pub(crate) fn packages(&self) -> &[ComposerLockPackage<'a>] { + &self.packages + } + + /// The registry inventory: every entry with a safe `vendor/name` and + /// version, names lowercased to the canonical packagist form and + /// versions normalized through the crawler's [`normalize_version`] (so + /// installed and lockfile rows agree). Our own vendored path dists are + /// skipped; `dist.shasum` (the zip's sha1, frequently empty) is the + /// verifier of a zip dist only. + pub fn entries(&self) -> Vec { + let mut out = Vec::new(); + for pkg in &self.packages { + let (Some(name), Some(version)) = (pkg.name, pkg.version) else { + continue; + }; + let name = name.to_ascii_lowercase(); + // Share the crawler's normalization rather than re-deriving it: + // it strips `v` AND `V` (both are legal Composer tags), and a + // lockfile row that normalizes differently from the installed + // row double-counts the package — one installed `@1.2.3` plus a + // phantom lockfile-only `@V1.2.3`, both POSTed. + let version = normalize_version(version).to_string(); + if !path_safety::is_safe_multi_segment(&name) + || name.split('/').count() != 2 + || !path_safety::is_safe_single_segment(&version) + { + continue; + } + // Our own vendored entries use a path dist — skip. + if pkg.dist_str("type") == Some("path") || pkg.dist_vendor_path().is_some() { + continue; + } + let dist_url = pkg.dist_str("url").unwrap_or(""); + let is_zip = pkg.dist_str("type") == Some("zip"); + let integrity = match pkg.dist_sha1() { + Some(sha1) if is_zip => sha1, + _ => LockIntegrity::None, + }; + let purl = format!("pkg:composer/{name}@{version}"); + out.push(LockfileEntry { + ecosystem: "composer", + source_kind: SourceKind::Unspecified, + name, + version, + purl, + resolved: is_zip.then(|| http_url(dist_url)).flatten(), + integrity, + }); + } + out + } +} + +impl LockModel for ComposerLock<'_> { + const FORMAT: &'static str = "composer.lock"; +} diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 8f7b9e782..601c5bce5 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -24,6 +24,7 @@ //! its wiring, and in which roles. pub mod cargo; +pub mod composer; pub mod pnpm; pub mod registry; diff --git a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs index 488c5f744..187c3c257 100644 --- a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs @@ -6,6 +6,8 @@ //! output bytes, FileEdit list and warnings on randomized locks. use super::*; +use crate::crawlers::composer_crawler::normalize_version; +use crate::formats::composer::hosted::*; fn json_object_end_from_oracle(text: &str, from: usize) -> Option { let mut depth = 0usize; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 9a3c4bd25..8569eec09 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -23,7 +23,6 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use crate::crawlers::composer_crawler::normalize_version; use crate::utils::digest::is_hex64_lower; use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; @@ -50,6 +49,7 @@ pub mod presence; use crate::formats::pnpm::grammar as pnpm; use crate::formats::pnpm::plan_hosted; use crate::formats::cargo::CargoLock; +use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] @@ -4095,306 +4095,6 @@ pub fn artifact_url_spellings(artifact_url: &str) -> [String; 2] { [artifact_url.to_string(), artifact_url.replace('/', "\\/")] } -/// Byte offset of the `}` closing the JSON object that CONTAINS `from`, which -/// must be a position inside that object. Brace counting skips string literals, -/// so a brace inside a description or URL cannot move the boundary. -/// -/// Walks bytes, not chars: every byte it acts on is ASCII, and no byte of a -/// multi-byte UTF-8 sequence is, so the offsets are the char walk's (an -/// escaped multi-byte char clears `escaped` on its lead byte). -fn json_object_end_from(text: &str, from: usize) -> Option { - let mut depth = 0usize; - let mut in_string = false; - let mut escaped = false; - for (offset, &byte) in text.as_bytes()[from..].iter().enumerate() { - if in_string { - match byte { - _ if escaped => escaped = false, - b'\\' => escaped = true, - b'"' => in_string = false, - _ => {} - } - continue; - } - match byte { - b'"' => in_string = true, - b'{' => depth += 1, - b'}' if depth == 0 => return Some(from + offset), - b'}' => depth -= 1, - _ => {} - } - } - None -} - -/// Value of the first `"": ""` pair in `text` (composer writes its -/// lock with exactly one space after the colon, the same shape the surgical -/// `dist` regexes below assume). -fn json_string_field<'a>(text: &'a str, key: &str) -> Option<&'a str> { - let pattern = format!("\"{key}\": \""); - let start = text.find(&pattern)? + pattern.len(); - let end = text[start..].find('"')? + start; - Some(&text[start..end]) -} - -/// Outcome of locating a package entry in a composer.lock. -enum ComposerEntry { - /// Inclusive byte range from the entry's `"name"` key to the `}` closing - /// the entry — composer writes `name` first, so this covers every key the - /// rewriter edits. - Found(usize, usize), - /// The name matched but the lock pins this OTHER version. - VersionMismatch(String), - NotFound, -} - -/// Locate `pkg`'s entry in a composer.lock (either `packages[]` or -/// `packages-dev[]` — the scan is over the whole file). -/// -/// Names match CASE-INSENSITIVELY, the way the composer crawler and the vendor -/// backend already match them: packagist canonicalizes to lowercase, but -/// hand-written mixed-case locks install fine and would otherwise silently miss -/// the redirect. The locked version must match the patched one through -/// composer's leading-`v` normalization (locks carry the pretty `v6.4.1`, PURLs -/// the bare `6.4.1`); matching on name alone would repoint whatever version -/// the lock happened to hold at a patch built for a different one. -fn find_composer_entry(content: &str, pkg: &str, version: &str) -> ComposerEntry { - let mut mismatched: Option = None; - for (name_idx, _) in content.match_indices("\"name\": \"") { - // The name is the value at `name_idx` — the entry's first field — - // and its closing quote precedes any `}` the object walk can stop - // at, so test it before walking to the end of the object: most - // occurrences name some other package. - if !json_string_field(&content[name_idx..], "name") - .is_some_and(|n| n.eq_ignore_ascii_case(pkg)) - { - continue; - } - let Some(end) = json_object_end_from(content, name_idx) else { - continue; - }; - let entry = &content[name_idx..=end]; - // Every package entry carries `version`; an `authors[]`/`support` - // object that happens to have a matching `name` does not. - let Some(locked) = json_string_field(entry, "version") else { - continue; - }; - if normalize_version(locked) == normalize_version(version) { - return ComposerEntry::Found(name_idx, end); - } - mismatched = Some(locked.to_string()); - } - match mismatched { - Some(locked) => ComposerEntry::VersionMismatch(locked), - None => ComposerEntry::NotFound, - } -} - -/// Append `"shasum": ""` as the last key of a `"dist": { … }` block, -/// indented like the keys already in it. VCS/zipball dists omit `shasum` -/// entirely; redirecting such a block without inserting the pin would leave the -/// hosted artifact unverified, so composer would install whatever the URL returned. -/// `block` is the whole dist object and already holds at least a `url`. -fn append_composer_shasum(block: &str, sha1: &str) -> String { - let Some(close) = block.rfind('}') else { - return block.to_string(); - }; - let head = block[..close].trim_end(); - let indent: String = head[head.rfind('\n').map_or(0, |i| i + 1)..] - .chars() - .take_while(|c| c.is_whitespace()) - .collect(); - format!( - "{head},\n{indent}\"shasum\": \"{sha1}\"{}", - &block[head.len()..] - ) -} - -static COMPOSER_DIST_TYPE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"("type": ")[^"]*(")"#).expect("static dist type regex is valid") -}); -static COMPOSER_DIST_URL_RE: LazyLock = - LazyLock::new(|| Regex::new(r#"("url": ")[^"]*(")"#).expect("static dist url regex is valid")); -static COMPOSER_DIST_SHASUM_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"("shasum": ")[^"]*(")"#).expect("static dist shasum regex is valid") -}); - -/// Byte offset of the entry's `"source": {` key when that object is the -/// dist block's IMMEDIATE predecessor (only `,` + whitespace between them) — -/// the layout composer itself always writes (`source` then `dist`). -/// `None` when the entry has no source object there. -fn composer_source_before_dist( - content: &str, - entry_start: usize, - dist_start: usize, -) -> Option { - const SOURCE_KEY: &str = "\"source\": {"; - let source_start = entry_start + content[entry_start..dist_start].rfind(SOURCE_KEY)?; - let source_end = json_object_end_from(content, source_start + SOURCE_KEY.len())?; - (source_end < dist_start && content[source_end + 1..dist_start].trim() == ",") - .then_some(source_start) -} - -fn rewrite_composer_lock( - files: &BTreeMap, - overrides: &[DepOverride], - result: &mut RewriteResult, -) { - let composer: Vec<&DepOverride> = overrides - .iter() - .filter(|o| o.ecosystem == "composer") - .collect(); - if composer.is_empty() { - return; - } - // Parity with `redirect_npm_no_lockfile`: a granted dep the project has - // no lock to pin must be SAID, not silently dropped from the redirected - // count (a composer.json + installed vendor tree without a lock is - // discovered and granted like any other). - if !files.contains_key("composer.lock") { - result.warnings.push(RewriteWarning { - code: "redirect_composer_no_lockfile".into(), - detail: "no composer.lock present; composer redirect skipped".into(), - }); - return; - } - const DIST_KEY: &str = "\"dist\": {"; - let mut content = files["composer.lock"].clone(); - let type_re: &Regex = &COMPOSER_DIST_TYPE_RE; - let url_re: &Regex = &COMPOSER_DIST_URL_RE; - let shasum_re: &Regex = &COMPOSER_DIST_SHASUM_RE; - let mut changed = false; - for dep in &composer { - let composer_name = full_name(dep); - let Some(sha1) = dep.integrity.sha1.clone() else { - result.warnings.push(RewriteWarning { - code: "redirect_composer_missing_sha1".into(), - detail: format!("{composer_name} has no sha1 (dist.shasum) integrity"), - }); - continue; - }; - let (entry_start, entry_end) = - match find_composer_entry(&content, &composer_name, &dep.version) { - ComposerEntry::Found(start, end) => (start, end), - ComposerEntry::VersionMismatch(locked) => { - result.warnings.push(RewriteWarning { - code: "redirect_composer_version_mismatch".into(), - detail: format!( - "composer.lock pins {composer_name}@{locked}, not the patched {}", - dep.version - ), - }); - continue; - } - ComposerEntry::NotFound => { - result.warnings.push(RewriteWarning { - code: "redirect_composer_pkg_not_found".into(), - detail: format!( - "no composer.lock package named {composer_name}@{}", - dep.version - ), - }); - continue; - } - }; - // The dist block MUST belong to the located entry. Scanning forward - // from the name for the next `"dist": {` would walk into the FOLLOWING - // package whenever the target was installed from source, repointing a - // bystander's url + shasum — a checksum-clean install of the wrong - // code. A target with no dist of its own pins nothing: fail closed. - let Some(dist_start) = content[entry_start..=entry_end] - .find(DIST_KEY) - .map(|offset| entry_start + offset) - else { - result.warnings.push(RewriteWarning { - code: "redirect_composer_no_dist".into(), - detail: format!("{composer_name} has no dist block"), - }); - continue; - }; - let Some(dist_end) = json_object_end_from(&content, dist_start + DIST_KEY.len()) else { - result.warnings.push(RewriteWarning { - code: "redirect_composer_lock_malformed".into(), - detail: format!("{composer_name}'s dist block is unterminated"), - }); - continue; - }; - let block = content[dist_start..=dist_end].to_string(); - // Already redirected (either slash spelling): recording an edit whose - // `original` IS the hosted url would grow the ledger on every re-run - // and poison a future revert. - if artifact_url_present(&block, &dep.artifact_url) && block.contains(&sha1) { - continue; - } - if !block.contains("\"url\": \"") { - result.warnings.push(RewriteWarning { - code: "redirect_composer_no_dist_url".into(), - detail: format!("{composer_name}'s dist block has no url to redirect"), - }); - continue; - } - let mut rewritten = type_re.replace(&block, "${1}zip${2}").to_string(); - rewritten = url_re - .replace( - &rewritten, - format!("${{1}}{}${{2}}", dep.artifact_url).as_str(), - ) - .to_string(); - rewritten = if rewritten.contains("\"shasum\": \"") { - shasum_re - .replace(&rewritten, format!("${{1}}{sha1}${{2}}").as_str()) - .to_string() - } else { - append_composer_shasum(&rewritten, &sha1) - }; - // Drop the entry's `source` (the vendored backend does the same): - // when the dist download fails — checksum mismatch, an expired grant - // token, a patch-server outage — composer 1 and composer 2 before its - // source-fallback cutoff (2.2 LTS included) print "Now trying to - // download from source" and silently install the PRISTINE upstream - // commit from git, and `--prefer-source` / `preferred-install: - // source` always does. With the source gone the hosted archive is - // the only way to install the package, so a failed fetch fails the - // install instead of shipping the vulnerable code. The edit then - // spans `"source": {…},\n"dist": {…}`, so the ledger's - // fragment revert puts both blocks back byte-for-byte. - let (edit_start, original) = - match composer_source_before_dist(&content, entry_start, dist_start) { - Some(source_start) => (source_start, content[source_start..=dist_end].to_string()), - None => { - if content[entry_start..=entry_end].contains("\"source\": {") { - result.warnings.push(RewriteWarning { - code: "redirect_composer_source_kept".into(), - detail: format!( - "{composer_name}'s source block does not directly precede its \ - dist and was left in place; a failed hosted download may fall \ - back to it" - ), - }); - } - (dist_start, block.clone()) - } - }; - if rewritten != original { - // In place: a fresh whole-lock copy per edit would hold one - // lock-sized buffer per redirected dep. - content.replace_range(edit_start..=dist_end, &rewritten); - changed = true; - result.edits.push(FileEdit { - path: "composer.lock".into(), - kind: "redirect_composer_dist".into(), - action: "rewritten".into(), - key: Some(composer_name), - original: Some(Value::String(original)), - new: Some(Value::String(rewritten)), - }); - } - } - if changed { - result.files.insert("composer.lock".into(), content); - } -} - // ── nuget (nuget.config + packages.lock.json) ──────────────────────────────── fn default_nuget_config() -> String { "\n\n \n \n \n\n".to_string() diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index c2119f6d7..58a575ddc 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -50,7 +50,7 @@ use super::common::{ prune_empty_vendor_levels, refused, serialize_json, service_offline_conflict, stage_dir_for, swap_stage_into_place, synthesized_result, }; -use super::lock_inventory::{composer_lock_packages, ComposerLockPackage}; +use crate::formats::composer::{composer_lock_packages, ComposerLockPackage}; use super::parse_memo::ParseMemo; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_on_blocking_pool, extract_zip}; @@ -905,29 +905,21 @@ fn find_lock_entry(lock: &Value, pkg_lc: &str, version: &str) -> Option<(&'stati } /// The index in `lock[section]` of the FIRST entry named `pkg` (any case), -/// if its dist is still [`wired_to`] `uuid`. The ownership gate of a restore: +/// if its dist is still [`ComposerLockPackage::wired_to`] `uuid`. The ownership gate of a restore: /// a registry dist (composer update reverted it) or a different uuid (a /// newer vendor run owns the entry) is third-party state — never clobber it. fn wired_entry_index(lock: &Value, section: &str, pkg: &str, uuid: &str) -> Option { composer_lock_packages(lock) .into_iter() .find(|p| p.section == section && p.name.is_some_and(|n| n.eq_ignore_ascii_case(pkg))) - .filter(|p| wired_to(p, uuid)) + .filter(|p| p.wired_to(uuid)) .map(|p| p.index) } -/// Whether the entry's `dist.url` points into patch `uuid`'s vendored -/// composer copy — the ownership gate every restore / strand check applies. -fn wired_to(pkg: &ComposerLockPackage<'_>, uuid: &str) -> bool { - pkg.dist_vendor_path() - .is_some_and(|p| p.eco == "composer" && p.uuid == uuid) -} - /// True when the live entry already carries our path dist. fn entry_is_wired(entry: &Value, dist_url: &str) -> bool { - let dist = entry.get("dist"); - dist.and_then(|d| d.get("type")).and_then(Value::as_str) == Some("path") - && dist.and_then(|d| d.get("url")).and_then(Value::as_str) == Some(dist_url) + let pkg = ComposerLockPackage::of("packages", 0, entry); + pkg.dist_str("type") == Some("path") && pkg.dist_str("url") == Some(dist_url) } /// Rebuild the lock entry for the path dist (see module doc): every original @@ -1019,7 +1011,7 @@ async fn stranded_wired_packages( fn stranded_in(lock: &Value, uuid: &str, restorable: &HashSet) -> Vec { let mut out: Vec = Vec::new(); for pkg in composer_lock_packages(lock) { - let Some(name) = pkg.name.filter(|_| wired_to(&pkg, uuid)) else { + let Some(name) = pkg.name.filter(|_| pkg.wired_to(uuid)) else { continue; }; let name = name.to_lowercase(); diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs index 25227762d..207f5088d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs @@ -1,85 +1,15 @@ -//! `composer.lock`: the shared entry walk ([`composer_lock_packages`]) and -//! its registry view. +//! `composer.lock`: the registry view, read through the format's model +//! ([`ComposerLock`]). #[cfg(test)] use std::path::Path; use serde_json::Value; -use crate::crawlers::composer_crawler::normalize_version; -use crate::patch::path_safety; -use crate::utils::digest::sha1_hex; -use crate::vendor::path::{parse_vendor_path, VendorPathParts}; +use crate::formats::composer::ComposerLock; use super::view::ProjectView; -use super::{dedup_prefer_integrity, http_url, LockIntegrity, LockfileEntry, SourceKind}; - -// ── entry model ── - -/// One entry of a parsed `composer.lock` (see [`composer_lock_packages`]). -pub(crate) struct ComposerLockPackage<'a> { - /// `packages` or `packages-dev`. - pub(crate) section: &'static str, - /// The position in the section ARRAY, counting non-object elements too: - /// what a writer indexes `lock[section][index]` with. - pub(crate) index: usize, - pub(crate) name: Option<&'a str>, - /// As locked — the pretty `v`-prefixed spelling; callers normalize - /// through [`normalize_version`]. - pub(crate) version: Option<&'a str>, - /// The `dist` object: what composer's default `--prefer-dist` install - /// consumes, and the block both backends rewrite. - pub(crate) dist: Option<&'a Value>, -} - -impl ComposerLockPackage<'_> { - /// A string field of the entry's `dist`. - pub(crate) fn dist_str(&self, key: &str) -> Option<&str> { - self.dist?.get(key)?.as_str() - } - - /// The `dist.shasum` pin: a 40-hex sha1 of the dist archive (any case, - /// lowercased), whatever the dist `type` — the inventory additionally - /// requires a `zip` dist at its call site. - pub(crate) fn dist_sha1(&self) -> Option { - self.dist_str("shasum") - .and_then(sha1_hex) - .map(LockIntegrity::Sha1Hex) - } - - /// The Socket-vendored path `dist.url` names, anchored anywhere - /// ([`parse_vendor_path`]: the writers' ownership rule, not discovery's - /// root-anchored attestation grammar). - pub(crate) fn dist_vendor_path(&self) -> Option { - self.dist_str("url").and_then(parse_vendor_path) - } -} - -/// Every entry composer installs from a parsed `composer.lock`, in lock -/// order: `packages`, then `packages-dev` (composer installs both by -/// default; a missing or non-array section is empty). The one walk the -/// inventory and lockfile discovery (`vex::discover::composer`) share. -pub(crate) fn composer_lock_packages(doc: &Value) -> Vec> { - let mut out = Vec::new(); - for section in ["packages", "packages-dev"] { - for (index, pkg) in doc - .get(section) - .and_then(Value::as_array) - .into_iter() - .flatten() - .enumerate() - { - out.push(ComposerLockPackage { - section, - index, - name: pkg.get("name").and_then(Value::as_str), - version: pkg.get("version").and_then(Value::as_str), - dist: pkg.get("dist"), - }); - } - } - out -} +use super::{dedup_prefer_integrity, LockfileEntry}; // ── registry view ── @@ -109,44 +39,5 @@ pub(super) async fn inventory_composer_lock_raw_in( ) -> Option> { let bytes = view.read_bytes("composer.lock").await.ok()?; let doc: Value = serde_json::from_slice(&bytes).ok()?; - let mut out = Vec::new(); - for pkg in composer_lock_packages(&doc) { - let (Some(name), Some(version)) = (pkg.name, pkg.version) else { - continue; - }; - let name = name.to_ascii_lowercase(); - // Share the crawler's normalization rather than re-deriving it: - // it strips `v` AND `V` (both are legal Composer tags), and a - // lockfile row that normalizes differently from the installed - // row double-counts the package — one installed `@1.2.3` plus a - // phantom lockfile-only `@V1.2.3`, both POSTed. - let version = normalize_version(version).to_string(); - if !path_safety::is_safe_multi_segment(&name) - || name.split('/').count() != 2 - || !path_safety::is_safe_single_segment(&version) - { - continue; - } - // Our own vendored entries use a path dist — skip. - if pkg.dist_str("type") == Some("path") || pkg.dist_vendor_path().is_some() { - continue; - } - let dist_url = pkg.dist_str("url").unwrap_or(""); - let is_zip = pkg.dist_str("type") == Some("zip"); - let integrity = match pkg.dist_sha1() { - Some(sha1) if is_zip => sha1, - _ => LockIntegrity::None, - }; - let purl = format!("pkg:composer/{name}@{version}"); - out.push(LockfileEntry { - ecosystem: "composer", - source_kind: SourceKind::Unspecified, - name, - version, - purl, - resolved: is_zip.then(|| http_url(dist_url)).flatten(), - integrity, - }); - } - Some(out) + Some(ComposerLock::from_doc(&doc).entries()) } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 8f0e187d9..1ec7dfbba 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -70,7 +70,6 @@ pub mod view; pub(crate) mod wired; pub(crate) mod yarn; -pub(crate) use self::composer::{composer_lock_packages, ComposerLockPackage}; pub(crate) use self::npm::{npm_lock_nodes, NpmLockNode}; #[cfg(test)] pub(crate) use self::npm_family::inventory_npm_lock; 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 ac64c5923..83303ed86 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/recover.rs @@ -6,6 +6,7 @@ use std::path::Path; use serde_json::Value; use crate::crawlers::python_crawler::canonicalize_pypi_name; +use crate::formats::composer::ComposerLockPackage; use crate::utils::digest::{is_hex, is_sri_pin, sha256_hex}; use crate::utils::purl::percent_decode_purl_component; @@ -63,22 +64,19 @@ pub async fn recover_lock_entry( "composer" => { let original = wiring_original(entry, &["composer_lock_package"]) .ok_or_else(|| "no pre-vendor composer.lock fragment recorded".to_string())?; - let dist = original - .get("dist") - .ok_or_else(|| "the pre-vendor composer.lock fragment has no dist".to_string())?; - let url = dist - .get("url") - .and_then(serde_json::Value::as_str) + // The fragment is the entry as the lock held it; read it with + // the lock model's own field rules. + let pkg = ComposerLockPackage::of("packages", 0, original); + if pkg.dist.is_none() { + return Err("the pre-vendor composer.lock fragment has no dist".to_string()); + } + let url = pkg + .dist_str("url") .and_then(http_url) .ok_or_else(|| "the pre-vendor dist has no http(s) url".to_string())?; - let shasum = dist - .get("shasum") - .and_then(serde_json::Value::as_str) - .filter(|s| is_hex(s, 40)) - .ok_or_else(|| { - "the pre-vendor dist records no shasum; refusing an unverifiable fetch" - .to_string() - })?; + let integrity = pkg.dist_sha1().ok_or_else(|| { + "the pre-vendor dist records no shasum; refusing an unverifiable fetch".to_string() + })?; Ok(LockfileEntry { ecosystem: "composer", source_kind: SourceKind::Unspecified, @@ -86,7 +84,7 @@ pub async fn recover_lock_entry( name, version, resolved: Some(url), - integrity: LockIntegrity::Sha1Hex(shasum.to_ascii_lowercase()), + integrity, }) } "gem" => { diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 8562e74b0..568d9ca5a 100644 --- a/crates/socket-patch-core/src/vex/discover/composer.rs +++ b/crates/socket-patch-core/src/vex/discover/composer.rs @@ -61,7 +61,7 @@ use super::{ DiscoverCtx, Discovery, LocateOpts, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, }; use crate::crawlers::composer_crawler::normalize_version; -use crate::vendor::lock_inventory::{composer_lock_packages, ComposerLockPackage}; +use crate::formats::composer::{ComposerLock, ComposerLockPackage}; /// The lock both backends rewrite (root-relative). const COMPOSER_LOCK: &str = "composer.lock"; @@ -89,8 +89,8 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // The inventory's own walk: `packages` then `packages-dev` (a missing // or non-array section — composer writes `"packages-dev": []`, older / // hand-trimmed locks may omit it — is simply empty). - for entry in composer_lock_packages(&doc) { - entry_ref(ctx, file, &entry, out); + for entry in ComposerLock::from_doc(&doc).packages() { + entry_ref(ctx, file, entry, out); } } From afaa266c6d7a914df2ab1a6c0a008b0d41c77139 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:23:20 +0000 Subject: [PATCH 06/13] Read Bundler locks through one format model vendor::gemfile_lock becomes formats::gem: GemfileLock::parse plus entries() for the inventory (moved out of lock_inventory::gem, which keeps only its view I/O and ledger recovery's remote set), the restore_upstream() hook, and gem_download_url. The hosted planner's lock-source convergence moves verbatim to formats::gem::hosted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/gem/hosted.rs | 275 ++++++++++++++++++ .../gemfile_lock.rs => formats/gem/mod.rs} | 82 +++++- crates/socket-patch-core/src/formats/mod.rs | 1 + .../src/patch/redirect/mod.rs | 268 +---------------- crates/socket-patch-core/src/vendor/gem.rs | 2 +- .../src/vendor/lock_inventory/gem.rs | 57 +--- .../src/vendor/lock_inventory/mod.rs | 2 +- crates/socket-patch-core/src/vendor/mod.rs | 1 - .../socket-patch-core/src/vex/discover/gem.rs | 8 +- .../socket-patch-core/src/vex/discover/mod.rs | 2 +- 10 files changed, 368 insertions(+), 330 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/gem/hosted.rs rename crates/socket-patch-core/src/{vendor/gemfile_lock.rs => formats/gem/mod.rs} (80%) diff --git a/crates/socket-patch-core/src/formats/gem/hosted.rs b/crates/socket-patch-core/src/formats/gem/hosted.rs new file mode 100644 index 000000000..ec9920f2c --- /dev/null +++ b/crates/socket-patch-core/src/formats/gem/hosted.rs @@ -0,0 +1,275 @@ +//! The hosted planner's Bundler-lock leg: converge a redirected gem's source +//! attribution (its spec block moved into a patch-registry `GEM` section, +//! DEPENDENCIES source-pinned) as a line splice that keeps every line's own +//! `\r\n` / `\n` ending. The read model in the parent keeps no spans or +//! endings, which is why this walker is separate from it. + +use regex::Regex; +use serde_json::Value; + +use crate::patch::redirect::{gem_index_url_pattern, DepOverride, FileEdit, RewriteResult}; + +/// A lock line without its `\r?\n` ending (never more than one of each). +fn gem_lock_line_content(line: &str) -> &str { + let line = line.strip_suffix('\n').unwrap_or(line); + line.strip_suffix('\r').unwrap_or(line) +} + +/// The gem name of a 2-space DEPENDENCIES entry (` rails`, ` rails!`, +/// ` rails (= 7.0.0)!`) — the text before any constraint, sans source pin. +fn gem_lock_dependency_name(entry: &str) -> &str { + let entry = entry.trim_start(); + let entry = entry.split(" (").next().unwrap_or(entry); + entry.trim_end_matches('!') +} + +/// One parsed `GEM` section of a Gemfile.lock: its header line index, its +/// `remote:` lines (index + URL) and the exclusive end index — the start of +/// the next column-0 header (trailing blank separator included) or EOF. +struct GemLockSection { + start: usize, + remotes: Vec<(usize, String)>, + end: usize, +} + +/// Converge the lock's source attribution for one redirected dep so the +/// Gemfile + lock pair is what bundler itself would write after an install +/// from the redirected Gemfile (verified frozen-installable on bundler 4): +/// the dep's spec entry (+ its dependency sublines) moves out of the +/// upstream `GEM` section into a patch-registry `GEM` section +/// (`remote: `), and DEPENDENCIES pins ` (= )!` +/// (bundler's source-pin spelling for a block-scoped exact-version gem) — +/// added in sorted position when the dep was transitive. Without this the +/// CHECKSUMS pin leaves a MIXED state bundler refuses: the lock still +/// attributes the gem to the upstream remote, so the prescribed unfrozen +/// install exits 37 "mismatched checksums" and a frozen install exits 16. +/// +/// Idempotent and rotation-aware: a section whose remote matches the +/// token-wildcard pattern is recognized as ours (never duplicated) and its +/// remote is refreshed in place under a rotated grant +/// (`redirect_gemfile_lock_source_url`, mirroring the Gemfile refresh). +/// +/// Returns true when the lock ends converged (already, or via edits recorded +/// into `result`); false when the dep cannot be attributed safely — spec +/// entry absent or duplicated, a legacy multi-remote `GEM` section, or no +/// DEPENDENCIES section — in which case nothing is touched and the caller +/// surfaces the frozen-install caveat. +pub(crate) fn converge_gem_lock_source( + lk: &mut String, + dep: &DepOverride, + index_url: &str, + lock_name: &str, + lock_changed: &mut bool, + result: &mut RewriteResult, +) -> bool { + let eol = if lk.contains("\r\n") { "\r\n" } else { "\n" }; + let mut lines: Vec = lk.split_inclusive('\n').map(str::to_string).collect(); + let is_header = |c: &str| !c.is_empty() && !c.starts_with(' '); + + // Parse: GEM sections, the dep's 4-space spec entry, DEPENDENCIES range. + let spec_content = format!(" {} ({})", dep.name, dep.version); + let mut sections: Vec = Vec::new(); + let mut spec_at: Vec<(usize, usize)> = Vec::new(); // (section idx, line idx) + let mut deps_range: Option<(usize, usize)> = None; // exclusive of header + let mut i = 0; + while i < lines.len() { + let c = gem_lock_line_content(&lines[i]); + if !is_header(c) { + i += 1; + continue; + } + let header_is_gem = c == "GEM"; + let start = i; + let mut remotes = Vec::new(); + let mut j = i + 1; + while j < lines.len() && !is_header(gem_lock_line_content(&lines[j])) { + let cj = gem_lock_line_content(&lines[j]); + if header_is_gem { + if let Some(url) = cj.strip_prefix(" remote: ") { + remotes.push((j, url.to_string())); + } + if cj == spec_content { + spec_at.push((sections.len(), j)); + } + } + j += 1; + } + if header_is_gem { + sections.push(GemLockSection { + start, + remotes, + end: j, + }); + } else if c == "DEPENDENCIES" { + deps_range = Some((start + 1, j)); + } + i = j; + } + + let spec_pos = if spec_at.len() == 1 { + Some(spec_at[0]) + } else { + None + }; + let (Some((sec_idx, spec_idx)), Some((deps_start, deps_end))) = (spec_pos, deps_range) else { + return false; + }; + if sections[sec_idx].remotes.len() != 1 { + return false; + } + // Bundler always writes source sections before DEPENDENCIES — the pin + // edit below runs first on that premise (its lines sit after the parsed + // spec/remote/end indices, so they never shift). A hand-edited lock with + // DEPENDENCIES before the dep's GEM section breaks the premise: the + // transitive-dep pin INSERT would leave the spec-move splicing on stale + // indices. Fail soft to the mixed state instead. + if deps_start < sections[sec_idx].end { + return false; + } + let (remote_idx, remote_url) = sections[sec_idx].remotes[0].clone(); + let socket_remote_re = Regex::new(&format!("^{}$", gem_index_url_pattern(dep, index_url))) + .expect("anchored index-url pattern from the escaped URL is valid"); + let mut changed = false; + + // DEPENDENCIES pin first — its lines sit AFTER the GEM sections, so the + // spec move below never invalidates these indices (and vice versa would). + let target = format!(" {} (= {})!", dep.name, dep.version); + let is_entry = |c: &str| c.starts_with(" ") && !c.starts_with(" "); + let entry_idx = (deps_start..deps_end).find(|&k| { + let ck = gem_lock_line_content(&lines[k]); + is_entry(ck) && gem_lock_dependency_name(ck) == dep.name + }); + match entry_idx { + Some(k) if gem_lock_line_content(&lines[k]) == target => {} + Some(k) => { + let old = gem_lock_line_content(&lines[k]).trim_start().to_string(); + let ending = lines[k][gem_lock_line_content(&lines[k]).len()..].to_string(); + lines[k] = format!("{target}{ending}"); + result.edits.push(FileEdit { + path: lock_name.into(), + kind: "redirect_gemfile_lock_dependency_pin".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(Value::String(old)), + new: Some(Value::String(target.trim_start().to_string())), + }); + changed = true; + } + None => { + // Transitive dep: bundler keeps DEPENDENCIES sorted by name. + let mut at = deps_end; + for (k, line) in lines.iter().enumerate().take(deps_end).skip(deps_start) { + let ck = gem_lock_line_content(line); + if ck.is_empty() + || (is_entry(ck) && gem_lock_dependency_name(ck) > dep.name.as_str()) + { + at = k; + break; + } + } + lines.insert(at, format!("{target}{eol}")); + result.edits.push(FileEdit { + path: lock_name.into(), + kind: "redirect_gemfile_lock_dependency_pin".into(), + action: "added".into(), + key: Some(dep.name.clone()), + original: None, + new: Some(Value::String(target.trim_start().to_string())), + }); + changed = true; + } + } + + if socket_remote_re.is_match(&remote_url) { + // Already ours. Rotated grant: refresh the remote in place. + if remote_url != index_url { + let ending = + lines[remote_idx][gem_lock_line_content(&lines[remote_idx]).len()..].to_string(); + lines[remote_idx] = format!(" remote: {index_url}{ending}"); + result.edits.push(FileEdit { + path: lock_name.into(), + kind: "redirect_gemfile_lock_source_url".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(Value::String(remote_url)), + new: Some(Value::String(index_url.to_string())), + }); + changed = true; + } + } else { + // Move the spec (+ sublines) into a patch-registry section of its + // own, inserted where bundler itself writes it: bundler emits the + // rubygems `GEM` sections sorted by source identifier + // (`SourceList#lock_rubygems_sources`: `sort_by(&:identifier)`, i.e. + // by the section's remote URLs), so the new section goes before the + // first `GEM` section whose remotes sort after the index URL, else + // after the last one. A frozen install re-renders the lock, and + // since bundler 4.0.19 (rubygems#9750, "fail instead of warning when + // frozen mode can't update the lockfile") any difference is fatal: + // "Your lockfile needs to be updated, but it can't be because frozen + // mode is set". Appending after `https://rubygems.org/` when the + // patch registry (`https://patch.socket.dev/…`) sorts first would break + // every converged hosted pair under `BUNDLE_FROZEN` / deployment + // mode (verified: 4.0.15 installs it, 4.0.21 refuses it). + let mut last = spec_idx; + while last + 1 < lines.len() + && gem_lock_line_content(&lines[last + 1]).starts_with(" ") + { + last += 1; + } + let moved: Vec = lines.drain(spec_idx..=last).collect(); + let n = moved.len(); + // Section bounds after the drain (every drained line sat inside + // section `sec_idx`, which keeps its start). + let bounds = |k: usize| -> (usize, usize) { + let s = §ions[k]; + match k.cmp(&sec_idx) { + std::cmp::Ordering::Less => (s.start, s.end), + std::cmp::Ordering::Equal => (s.start, s.end - n), + std::cmp::Ordering::Greater => (s.start - n, s.end - n), + } + }; + let identifier = |k: usize| -> String { + sections[k] + .remotes + .iter() + .map(|(_, url)| url.as_str()) + .collect::>() + .join(", ") + }; + let insert_at = (0..sections.len()) + .find(|&k| identifier(k).as_str() > index_url) + .map(|k| bounds(k).0) + .unwrap_or_else(|| bounds(sections.len() - 1).1); + let mut block: Vec = Vec::with_capacity(moved.len() + 4); + block.push(format!("GEM{eol}")); + block.push(format!(" remote: {index_url}{eol}")); + block.push(format!(" specs:{eol}")); + for line in moved { + // Moved lines keep their own bytes; only a final line that lacked + // a newline (EOF) gains the file's ending. + if line.ends_with('\n') { + block.push(line); + } else { + block.push(format!("{line}{eol}")); + } + } + block.push(eol.to_string()); + lines.splice(insert_at..insert_at, block); + result.edits.push(FileEdit { + path: lock_name.into(), + kind: "redirect_gemfile_lock_gem_source".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(Value::String(remote_url)), + new: Some(Value::String(index_url.to_string())), + }); + changed = true; + } + + if changed { + *lk = lines.concat(); + *lock_changed = true; + } + true +} diff --git a/crates/socket-patch-core/src/vendor/gemfile_lock.rs b/crates/socket-patch-core/src/formats/gem/mod.rs similarity index 80% rename from crates/socket-patch-core/src/vendor/gemfile_lock.rs rename to crates/socket-patch-core/src/formats/gem/mod.rs index 4ec9c095f..7ffc06476 100644 --- a/crates/socket-patch-core/src/vendor/gemfile_lock.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -1,7 +1,9 @@ -//! Read model of a Bundler lockfile (`Gemfile.lock` / `gems.locked`), -//! shared by every reader of one: the lock inventory (the registry gems a -//! lock resolves, their `CHECKSUMS` pins and remotes) and lockfile discovery -//! (`vex::discover::gem`: the Socket-wired `GEM` / `PATH` sections). One +//! A Bundler lockfile (`Gemfile.lock` / `gems.locked`): the ONE read model +//! of the format, shared by every reader of one: the lock inventory +//! ([`GemfileLock::entries`]: the registry gems a lock resolves, their +//! `CHECKSUMS` pins and remotes), ledger recovery's remote set and lockfile +//! discovery (`vex::discover::gem`: the Socket-wired `GEM` / `PATH` +//! sections). One //! parse means both agree on which section a spec belongs to, which remote //! serves it and which checksum pins it. //! @@ -16,10 +18,15 @@ //! section at all) is collected in [`GemfileLock::problems`] for the //! readers that must refuse such a lock. +pub(crate) mod hosted; + use std::collections::{BTreeSet, HashMap}; use crate::utils::digest::sha256_hex; -use crate::vendor::lock_inventory::LockIntegrity; +use crate::utils::purl::simple_purl; +use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; + +use super::LockModel; /// The Bundler lockfiles, legacy spelling first: `Gemfile.lock` and /// `gems.locked` (what bundler writes instead when the manifest is @@ -143,6 +150,71 @@ impl<'t> GemfileLock<'t> { } } +impl<'t> GemfileLock<'t> { + /// Parse a lock text ([`parse`]). + pub fn parse(text: &'t str) -> Self { + parse(text) + } + + /// The registry inventory: `GEM`-section `specs:` entries plus the + /// bundler >= 2.6 `CHECKSUMS` sha256 pins when present (older locks stay + /// discovery-only). Platform-suffixed specs are skipped (platform gems + /// are unsupported for vendoring). Each spec resolves against its OWN + /// section's remote (bundler >= 2 emits one GEM section per source); a + /// section with several distinct remotes (a legacy bundler 1.x + /// multisource lock) leaves its specs without a resolved URL, fail + /// closed. What bundler would refuse (`problems`) still inventories + /// whatever parsed. `None` when nothing is inventoried. + pub fn entries(&self) -> Option> { + let gem_sections: Vec<&Section<'_>> = self.gem_sections().collect(); + let mut out = Vec::new(); + for section in &gem_sections { + let remotes: Vec<&str> = section.remote_bases().collect(); + for spec in section.specs.iter().filter_map(|line| line.parsed) { + if spec.platform.is_some() { + continue; + } + let Some(purl) = simple_purl("gem", spec.name, spec.version) else { + continue; + }; + let (name, version) = (spec.name, spec.version); + let integrity = self.integrity(name, version).unwrap_or(LockIntegrity::None); + let resolved = match remotes.as_slice() { + [base] => gem_download_url(base, name, version), + // No remote (a missing `remote:` line defaults to rubygems.org + // ONLY when the whole lock has one remote-less GEM section — + // the pre-multisource shape) or several remotes: fail closed. + [] if gem_sections.len() == 1 => { + gem_download_url("https://rubygems.org", name, version) + } + _ => None, + }; + out.push(LockfileEntry { + ecosystem: "gem", + source_kind: SourceKind::Unspecified, + purl, + resolved, + name: name.to_string(), + version: version.to_string(), + integrity, + }); + } + } + (!out.is_empty()).then_some(out) + } +} + +impl LockModel for GemfileLock<'_> { + const FORMAT: &'static str = "Gemfile.lock"; +} + +/// Where a rubygems-compatible registry at `base` (no trailing `/`) serves +/// `name`-`version`'s `.gem` — the inventory's resolved URL and ledger +/// recovery's fetch URL. `None` for a non-http(s) base. +pub(crate) fn gem_download_url(base: &str, name: &str, version: &str) -> Option { + http_url(&format!("{base}/downloads/{name}-{version}.gem")) +} + /// Parse a Bundler lock (see the module docs). pub(crate) fn parse(text: &str) -> GemfileLock<'_> { let mut sections: Vec> = Vec::new(); diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 601c5bce5..458cd970f 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -25,6 +25,7 @@ pub mod cargo; pub mod composer; +pub mod gem; pub mod pnpm; pub mod registry; diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8569eec09..eae39501d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -50,6 +50,7 @@ use crate::formats::pnpm::grammar as pnpm; use crate::formats::pnpm::plan_hosted; use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; +use crate::formats::gem::hosted::converge_gem_lock_source; use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] @@ -4651,7 +4652,7 @@ pub fn hosted_patch_url_uuids(url: &str, extra_origins: &[String]) -> Option String { +pub(crate) fn gem_index_url_pattern(dep: &DepOverride, index_url: &str) -> String { let mut url_pat = regex::escape(index_url); let derived_token = grant_token_path_segment(index_url, &dep.patch_uuid); let rotating = [ @@ -4707,271 +4708,6 @@ fn gem_spelling_residue(content: &str, deps: &[&DepOverride]) -> String { residue.trim_end().to_string() } -/// A lock line without its `\r?\n` ending (never more than one of each). -fn gem_lock_line_content(line: &str) -> &str { - let line = line.strip_suffix('\n').unwrap_or(line); - line.strip_suffix('\r').unwrap_or(line) -} - -/// The gem name of a 2-space DEPENDENCIES entry (` rails`, ` rails!`, -/// ` rails (= 7.0.0)!`) — the text before any constraint, sans source pin. -fn gem_lock_dependency_name(entry: &str) -> &str { - let entry = entry.trim_start(); - let entry = entry.split(" (").next().unwrap_or(entry); - entry.trim_end_matches('!') -} - -/// One parsed `GEM` section of a Gemfile.lock: its header line index, its -/// `remote:` lines (index + URL) and the exclusive end index — the start of -/// the next column-0 header (trailing blank separator included) or EOF. -struct GemLockSection { - start: usize, - remotes: Vec<(usize, String)>, - end: usize, -} - -/// Converge the lock's source attribution for one redirected dep so the -/// Gemfile + lock pair is what bundler itself would write after an install -/// from the redirected Gemfile (verified frozen-installable on bundler 4): -/// the dep's spec entry (+ its dependency sublines) moves out of the -/// upstream `GEM` section into a patch-registry `GEM` section -/// (`remote: `), and DEPENDENCIES pins ` (= )!` -/// (bundler's source-pin spelling for a block-scoped exact-version gem) — -/// added in sorted position when the dep was transitive. Without this the -/// CHECKSUMS pin leaves a MIXED state bundler refuses: the lock still -/// attributes the gem to the upstream remote, so the prescribed unfrozen -/// install exits 37 "mismatched checksums" and a frozen install exits 16. -/// -/// Idempotent and rotation-aware: a section whose remote matches the -/// token-wildcard pattern is recognized as ours (never duplicated) and its -/// remote is refreshed in place under a rotated grant -/// (`redirect_gemfile_lock_source_url`, mirroring the Gemfile refresh). -/// -/// Returns true when the lock ends converged (already, or via edits recorded -/// into `result`); false when the dep cannot be attributed safely — spec -/// entry absent or duplicated, a legacy multi-remote `GEM` section, or no -/// DEPENDENCIES section — in which case nothing is touched and the caller -/// surfaces the frozen-install caveat. -fn converge_gem_lock_source( - lk: &mut String, - dep: &DepOverride, - index_url: &str, - lock_name: &str, - lock_changed: &mut bool, - result: &mut RewriteResult, -) -> bool { - let eol = if lk.contains("\r\n") { "\r\n" } else { "\n" }; - let mut lines: Vec = lk.split_inclusive('\n').map(str::to_string).collect(); - let is_header = |c: &str| !c.is_empty() && !c.starts_with(' '); - - // Parse: GEM sections, the dep's 4-space spec entry, DEPENDENCIES range. - let spec_content = format!(" {} ({})", dep.name, dep.version); - let mut sections: Vec = Vec::new(); - let mut spec_at: Vec<(usize, usize)> = Vec::new(); // (section idx, line idx) - let mut deps_range: Option<(usize, usize)> = None; // exclusive of header - let mut i = 0; - while i < lines.len() { - let c = gem_lock_line_content(&lines[i]); - if !is_header(c) { - i += 1; - continue; - } - let header_is_gem = c == "GEM"; - let start = i; - let mut remotes = Vec::new(); - let mut j = i + 1; - while j < lines.len() && !is_header(gem_lock_line_content(&lines[j])) { - let cj = gem_lock_line_content(&lines[j]); - if header_is_gem { - if let Some(url) = cj.strip_prefix(" remote: ") { - remotes.push((j, url.to_string())); - } - if cj == spec_content { - spec_at.push((sections.len(), j)); - } - } - j += 1; - } - if header_is_gem { - sections.push(GemLockSection { - start, - remotes, - end: j, - }); - } else if c == "DEPENDENCIES" { - deps_range = Some((start + 1, j)); - } - i = j; - } - - let spec_pos = if spec_at.len() == 1 { - Some(spec_at[0]) - } else { - None - }; - let (Some((sec_idx, spec_idx)), Some((deps_start, deps_end))) = (spec_pos, deps_range) else { - return false; - }; - if sections[sec_idx].remotes.len() != 1 { - return false; - } - // Bundler always writes source sections before DEPENDENCIES — the pin - // edit below runs first on that premise (its lines sit after the parsed - // spec/remote/end indices, so they never shift). A hand-edited lock with - // DEPENDENCIES before the dep's GEM section breaks the premise: the - // transitive-dep pin INSERT would leave the spec-move splicing on stale - // indices. Fail soft to the mixed state instead. - if deps_start < sections[sec_idx].end { - return false; - } - let (remote_idx, remote_url) = sections[sec_idx].remotes[0].clone(); - let socket_remote_re = Regex::new(&format!("^{}$", gem_index_url_pattern(dep, index_url))) - .expect("anchored index-url pattern from the escaped URL is valid"); - let mut changed = false; - - // DEPENDENCIES pin first — its lines sit AFTER the GEM sections, so the - // spec move below never invalidates these indices (and vice versa would). - let target = format!(" {} (= {})!", dep.name, dep.version); - let is_entry = |c: &str| c.starts_with(" ") && !c.starts_with(" "); - let entry_idx = (deps_start..deps_end).find(|&k| { - let ck = gem_lock_line_content(&lines[k]); - is_entry(ck) && gem_lock_dependency_name(ck) == dep.name - }); - match entry_idx { - Some(k) if gem_lock_line_content(&lines[k]) == target => {} - Some(k) => { - let old = gem_lock_line_content(&lines[k]).trim_start().to_string(); - let ending = lines[k][gem_lock_line_content(&lines[k]).len()..].to_string(); - lines[k] = format!("{target}{ending}"); - result.edits.push(FileEdit { - path: lock_name.into(), - kind: "redirect_gemfile_lock_dependency_pin".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(Value::String(old)), - new: Some(Value::String(target.trim_start().to_string())), - }); - changed = true; - } - None => { - // Transitive dep: bundler keeps DEPENDENCIES sorted by name. - let mut at = deps_end; - for (k, line) in lines.iter().enumerate().take(deps_end).skip(deps_start) { - let ck = gem_lock_line_content(line); - if ck.is_empty() - || (is_entry(ck) && gem_lock_dependency_name(ck) > dep.name.as_str()) - { - at = k; - break; - } - } - lines.insert(at, format!("{target}{eol}")); - result.edits.push(FileEdit { - path: lock_name.into(), - kind: "redirect_gemfile_lock_dependency_pin".into(), - action: "added".into(), - key: Some(dep.name.clone()), - original: None, - new: Some(Value::String(target.trim_start().to_string())), - }); - changed = true; - } - } - - if socket_remote_re.is_match(&remote_url) { - // Already ours. Rotated grant: refresh the remote in place. - if remote_url != index_url { - let ending = - lines[remote_idx][gem_lock_line_content(&lines[remote_idx]).len()..].to_string(); - lines[remote_idx] = format!(" remote: {index_url}{ending}"); - result.edits.push(FileEdit { - path: lock_name.into(), - kind: "redirect_gemfile_lock_source_url".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(Value::String(remote_url)), - new: Some(Value::String(index_url.to_string())), - }); - changed = true; - } - } else { - // Move the spec (+ sublines) into a patch-registry section of its - // own, inserted where bundler itself writes it: bundler emits the - // rubygems `GEM` sections sorted by source identifier - // (`SourceList#lock_rubygems_sources`: `sort_by(&:identifier)`, i.e. - // by the section's remote URLs), so the new section goes before the - // first `GEM` section whose remotes sort after the index URL, else - // after the last one. A frozen install re-renders the lock, and - // since bundler 4.0.19 (rubygems#9750, "fail instead of warning when - // frozen mode can't update the lockfile") any difference is fatal: - // "Your lockfile needs to be updated, but it can't be because frozen - // mode is set". Appending after `https://rubygems.org/` when the - // patch registry (`https://patch.socket.dev/…`) sorts first would break - // every converged hosted pair under `BUNDLE_FROZEN` / deployment - // mode (verified: 4.0.15 installs it, 4.0.21 refuses it). - let mut last = spec_idx; - while last + 1 < lines.len() - && gem_lock_line_content(&lines[last + 1]).starts_with(" ") - { - last += 1; - } - let moved: Vec = lines.drain(spec_idx..=last).collect(); - let n = moved.len(); - // Section bounds after the drain (every drained line sat inside - // section `sec_idx`, which keeps its start). - let bounds = |k: usize| -> (usize, usize) { - let s = §ions[k]; - match k.cmp(&sec_idx) { - std::cmp::Ordering::Less => (s.start, s.end), - std::cmp::Ordering::Equal => (s.start, s.end - n), - std::cmp::Ordering::Greater => (s.start - n, s.end - n), - } - }; - let identifier = |k: usize| -> String { - sections[k] - .remotes - .iter() - .map(|(_, url)| url.as_str()) - .collect::>() - .join(", ") - }; - let insert_at = (0..sections.len()) - .find(|&k| identifier(k).as_str() > index_url) - .map(|k| bounds(k).0) - .unwrap_or_else(|| bounds(sections.len() - 1).1); - let mut block: Vec = Vec::with_capacity(moved.len() + 4); - block.push(format!("GEM{eol}")); - block.push(format!(" remote: {index_url}{eol}")); - block.push(format!(" specs:{eol}")); - for line in moved { - // Moved lines keep their own bytes; only a final line that lacked - // a newline (EOF) gains the file's ending. - if line.ends_with('\n') { - block.push(line); - } else { - block.push(format!("{line}{eol}")); - } - } - block.push(eol.to_string()); - lines.splice(insert_at..insert_at, block); - result.edits.push(FileEdit { - path: lock_name.into(), - kind: "redirect_gemfile_lock_gem_source".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(Value::String(remote_url)), - new: Some(Value::String(index_url.to_string())), - }); - changed = true; - } - - if changed { - *lk = lines.concat(); - *lock_changed = true; - } - true -} - fn rewrite_gem( files: &BTreeMap, overrides: &[DepOverride], diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index adbff138d..beb825058 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -70,7 +70,7 @@ use super::common::{ prune_empty_vendor_levels, refused, service_offline_conflict, stage_dir_for, swap_stage_into_place, synthesized_result, }; -use super::gemfile_lock::{is_plain_gem_token, split_checksum_entry, split_entry}; +use crate::formats::gem::{is_plain_gem_token, split_checksum_entry, split_entry}; use super::path::{parse_vendor_path, vendor_uuid_dir_rel}; use super::registry_fetch::{extract_gem_data, extract_on_blocking_pool}; use super::service_fetch::{ diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs index 61643eba9..72543adca 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs @@ -3,12 +3,12 @@ use std::path::Path; +pub(super) use crate::formats::gem::gem_download_url; +use crate::formats::gem::GemfileLock; use crate::utils::fs::read_regular_to_string; -use crate::utils::purl::simple_purl; -use crate::vendor::gemfile_lock::{self, Section}; use super::view::ProjectView; -use super::{dedup_prefer_integrity, http_url, LockIntegrity, LockfileEntry, SourceKind}; +use super::{dedup_prefer_integrity, LockfileEntry}; // ── registry view ── @@ -50,52 +50,7 @@ pub(super) async fn inventory_gemfile_lock_raw_in( // The shared lock model (lockfile discovery reads it too); what bundler // would refuse (`problems`) still inventories whatever parsed — this is // read-only discovery. - let lock = gemfile_lock::parse(&text); - let gem_sections: Vec<&Section<'_>> = lock.gem_sections().collect(); - let mut out = Vec::new(); - for section in &gem_sections { - let remotes: Vec<&str> = section.remote_bases().collect(); - for spec in section.specs.iter().filter_map(|line| line.parsed) { - if spec.platform.is_some() { - continue; - } - let Some(purl) = simple_purl("gem", spec.name, spec.version) else { - continue; - }; - let (name, version) = (spec.name, spec.version); - let integrity = lock.integrity(name, version).unwrap_or(LockIntegrity::None); - let resolved = match remotes.as_slice() { - [base] => gem_download_url(base, name, version), - // No remote (a missing `remote:` line defaults to rubygems.org - // ONLY when the whole lock has one remote-less GEM section — - // the pre-multisource shape) or several remotes: fail closed. - [] if gem_sections.len() == 1 => { - gem_download_url("https://rubygems.org", name, version) - } - _ => None, - }; - out.push(LockfileEntry { - ecosystem: "gem", - source_kind: SourceKind::Unspecified, - purl, - resolved, - name: name.to_string(), - version: version.to_string(), - integrity, - }); - } - } - if out.is_empty() { - return None; - } - Some(out) -} - -/// Where a rubygems-compatible registry at `base` (no trailing `/`) serves -/// `name`-`version`'s `.gem` — the inventory's resolved URL and ledger -/// recovery's fetch URL. `None` for a non-http(s) base. -pub(super) fn gem_download_url(base: &str, name: &str, version: &str) -> Option { - http_url(&format!("{base}/downloads/{name}-{version}.gem")) + GemfileLock::parse(&text).entries() } /// The DISTINCT `GEM remote:` bases across ALL GEM sections of the @@ -113,8 +68,8 @@ pub(super) async fn gem_remotes(project_root: &Path) -> Vec { let Ok(text) = read_regular_to_string(&project_root.join("Gemfile.lock")).await else { return Vec::new(); }; - let lock = gemfile_lock::parse(&text); - lock.gem_remote_bases() + GemfileLock::parse(&text) + .gem_remote_bases() .into_iter() .map(str::to_string) .collect() diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 1ec7dfbba..afe74e397 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -36,7 +36,7 @@ //! [`inventory_project_every_lock`] unions for ledger liveness). //! //! Formats whose reader a writer already owns keep the model there -//! (`cargo_lock::locked_packages`, `gemfile_lock`, `utils::python_lock` / +//! (`formats::cargo`, `formats::gem`, `utils::python_lock` / //! `poetry_lock`, `utils::requirements`), and only the registry view lives //! here. [`LockfileEntry::source_kind`] carries provenance a view knows //! positively (crates.io), which ledger liveness reads instead of inferring diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 9dae4b77d..b03537d33 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -62,7 +62,6 @@ pub mod cargo_tag; pub(crate) mod common; pub mod composer_lock; pub mod gem; -pub(crate) mod gemfile_lock; pub mod go_mod_edit; pub mod go_sum_edit; pub mod golang; diff --git a/crates/socket-patch-core/src/vex/discover/gem.rs b/crates/socket-patch-core/src/vex/discover/gem.rs index da1ef1e93..77f7388a8 100644 --- a/crates/socket-patch-core/src/vex/discover/gem.rs +++ b/crates/socket-patch-core/src/vex/discover/gem.rs @@ -10,7 +10,7 @@ //! //! ## Lock grammar //! -//! Read with the lock inventory's own model ([`gemfile_lock`]): column-0 +//! Read with the lock inventory's own model ([`GemfileLock`]): column-0 //! section headers, 2-space `remote:` keys, 4-space `specs:` entries, //! `CHECKSUMS` and `DEPENDENCIES` pins, CRLF tolerated. A file the model //! flags — conflict markers, indented text before the first header, or no @@ -126,8 +126,8 @@ use super::{ PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::vendor::gem::{gem_declaration_any, quoted_literal}; -use crate::vendor::gemfile_lock::{ - self, bundler_manifest_for, same_remote, GemfileLock, Section, SpecLine, BUNDLER_LOCKS, +use crate::formats::gem::{ + bundler_manifest_for, same_remote, GemfileLock, Section, SpecLine, BUNDLER_LOCKS, }; pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { @@ -136,7 +136,7 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let Some(text) = ctx.read_text(file, out).await else { continue; }; - let lock = gemfile_lock::parse(&text); + let lock = GemfileLock::parse(&text); // A readable lock with a `GEM` section listing several remotes. let merged = lock.problems.is_empty() && lock.gem_sections().any(|s| s.remotes.len() > 1); let blocks = if merged { diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ec555401d..24c330e4e 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -33,7 +33,7 @@ //! (`npm_lock_nodes`, `pnpm::pnpm_packages`, `yarn::classic_entries` / //! `berry_entries`, `BunLockb::parse_packages`, `vlt::vlt_lock_model`) and, //! for the other formats, the readers the writers own (`cargo_lock` / -//! `cargo_config`, `go_mod_edit` / `go_sum_edit`, `gemfile_lock`, +//! `cargo_config`, `go_mod_edit` / `go_sum_edit`, `formats::gem`, //! `composer_lock_packages`, the //! `utils::python_lock` / `poetry_lock` / `requirements` / `hatch` readers, //! `maven_pom`, `nuget_config` / `nuget_feed`). The inventory's registry From 9c078faa02784aa61611ed397acc3094672b36ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:25:39 +0000 Subject: [PATCH 07/13] Keep the rollback fixture's hashes for the macOS-only test The blob-retention test that reads before_hash/after_hash is #[cfg(target_os = "macos")]; allow the fields as dead elsewhere instead of dropping them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- crates/socket-patch-cli/tests/covgap_commands_rollback.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index 3f48f26f1..082b15c43 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,6 +149,11 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], + // Read only by the macOS-only blob-retention test. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + before_hash: String, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + after_hash: String, } fn patched_fixture() -> PatchedFixture { @@ -177,6 +182,8 @@ fn patched_fixture() -> PatchedFixture { purl, before, after, + before_hash, + after_hash, } } From 009d086f6198ed4b2e8511f0ce3a6c95a3f4a408 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:47:35 +0000 Subject: [PATCH 08/13] Route the yarn grammar split and bun.lock prelude through formats formats::yarn owns the one yarn.lock grammar decision: sniff_grammar (the head sniff the vendor flavor probe, the lock-inventory view and repair's reference flavor each re-derived) and is_berry_lock (the whole-file check the hosted rewriters and discovery share; the classic vendored backend's refusal gate now uses it too, so a BOM'd berry lock no longer slips past it). formats::bun::BunTextLock is the gate + split + packages parse five bun.lock readers copied; each keeps its own refusal wording. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/commands/repair_vendor.rs | 20 ++--- .../socket-patch-core/src/formats/bun/mod.rs | 49 +++++++++++ crates/socket-patch-core/src/formats/mod.rs | 2 + .../socket-patch-core/src/formats/yarn/mod.rs | 86 +++++++++++++++++++ .../src/patch/redirect/mod.rs | 38 +++----- .../socket-patch-core/src/vendor/bun_lock.rs | 38 ++++---- .../src/vendor/lock_inventory/bun.rs | 9 +- .../src/vendor/lock_inventory/view.rs | 31 +++---- .../src/vendor/npm_flavor.rs | 26 ++---- .../src/vendor/yarn_classic_lock.rs | 2 +- 10 files changed, 195 insertions(+), 106 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/bun/mod.rs create mode 100644 crates/socket-patch-core/src/formats/yarn/mod.rs diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 71454dfea..2fcb2e51e 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -55,6 +55,7 @@ use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::constants::SOCKET_DIR; use socket_patch_core::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; use socket_patch_core::formats::registry; +use socket_patch_core::formats::yarn::{sniff_grammar, YarnLockGrammar}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::utils::fs::read_regular_to_string; @@ -289,20 +290,11 @@ async fn detect_reference_flavor(project_root: &Path, eco: &str, uuid: &str) -> } if let Some(text) = read("yarn.lock").await { if text.contains(&needle) { - // Same head sniff as core's `sniff_yarn_lock` (BOM skipped, - // CRLF-tolerant); berry wins. - let head: Vec<&str> = text - .strip_prefix('\u{feff}') - .unwrap_or(&text) - .lines() - .take(30) - .collect(); - return if head.iter().any(|l| l.starts_with("__metadata:")) { - Some("yarn-berry".to_string()) - } else if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - Some("yarn-classic".to_string()) - } else { - None + // The format model's head sniff; berry wins. + return match sniff_grammar(&text) { + Some(YarnLockGrammar::Berry) => Some("yarn-berry".to_string()), + Some(YarnLockGrammar::Classic) => Some("yarn-classic".to_string()), + None => None, }; } } diff --git a/crates/socket-patch-core/src/formats/bun/mod.rs b/crates/socket-patch-core/src/formats/bun/mod.rs new file mode 100644 index 000000000..540207601 --- /dev/null +++ b/crates/socket-patch-core/src/formats/bun/mod.rs @@ -0,0 +1,49 @@ +//! Bun's text lock (`bun.lock`): the one fail-closed prelude every reader +//! starts from. +//! +//! The line grammar itself lives in `vendor::bun_lock_text` (the single-line +//! `"key": [tuple]` entry parser both backends splice with), and the binary +//! `bun.lockb` has its own codec (`vendor::bun_lockb`). What was copied at +//! every call site — gate the `lockfileVersion` head, split the text into +//! the splice's line coordinates, parse the `packages` section — is +//! [`BunTextLock::parse`]; each caller keeps its own refusal wording. + +use crate::vendor::bun_lock_text::{check_lock_version, parse_packages_section, BunEntry}; + +/// Why [`BunTextLock::parse`] refused a lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BunTextError { + /// The `lockfileVersion` head is missing or unsupported; the detail is + /// the shared gate's user-facing text (one message for every mode). + Version(String), + /// The `packages` section deviates from bun's emitted single-line + /// grammar. + Packages(String), +} + +impl BunTextError { + /// The refusal detail, whichever step refused. + pub(crate) fn detail(self) -> String { + match self { + BunTextError::Version(detail) | BunTextError::Packages(detail) => detail, + } + } +} + +/// A text `bun.lock`, gated and parsed once. +pub(crate) struct BunTextLock { + /// The text split on `\n` (a CRLF lock keeps each `\r`): the line + /// coordinates [`BunEntry::line_idx`] and the splices index. + pub(crate) lines: Vec, + /// Every `packages` entry, in lock order. + pub(crate) entries: Vec, +} + +impl BunTextLock { + pub(crate) fn parse(text: &str) -> Result { + check_lock_version(text).map_err(BunTextError::Version)?; + let lines: Vec = text.split('\n').map(str::to_string).collect(); + let entries = parse_packages_section(&lines).map_err(BunTextError::Packages)?; + Ok(BunTextLock { lines, entries }) + } +} diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 458cd970f..b13898363 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -27,7 +27,9 @@ pub mod cargo; pub mod composer; pub mod gem; pub mod pnpm; +pub(crate) mod bun; pub mod registry; +pub mod yarn; pub use registry::registry; diff --git a/crates/socket-patch-core/src/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs new file mode 100644 index 000000000..c7f51d5b2 --- /dev/null +++ b/crates/socket-patch-core/src/formats/yarn/mod.rs @@ -0,0 +1,86 @@ +//! `yarn.lock`, classic (v1) and berry (v2+): the grammar split every +//! reader of the file routes on. +//! +//! 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 `yarn.lock` head declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum YarnLockGrammar { + /// A column-0 `__metadata:` key (yarn >= 2). + Berry, + /// The `# yarn lockfile v1` comment header. + 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. +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 + } +} + +/// 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. +pub fn is_berry_lock(content: &str) -> bool { + strip_bom(content) + .lines() + .any(|line| line.starts_with("__metadata:")) +} + +fn strip_bom(text: &str) -> &str { + text.strip_prefix('\u{feff}').unwrap_or(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sniff_prefers_berry_and_skips_a_bom() { + assert_eq!(sniff_grammar("__metadata:\n version: 8\n"), Some(YarnLockGrammar::Berry)); + assert_eq!( + sniff_grammar("\u{feff}# yarn lockfile v1\r\n"), + Some(YarnLockGrammar::Classic) + ); + assert_eq!( + sniff_grammar("# yarn lockfile v1\n__metadata:\n"), + 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); + } + + #[test] + fn is_berry_lock_reads_the_whole_file_and_skips_a_bom() { + assert!(is_berry_lock("\u{feff}__metadata:\n")); + let deep = format!("{}__metadata:\n", "\n".repeat(100)); + assert!(is_berry_lock(&deep)); + assert!(!is_berry_lock("# yarn lockfile v1\n __metadata:\n")); + } +} diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index eae39501d..d6f141d3d 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -51,6 +51,7 @@ use crate::formats::pnpm::plan_hosted; use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::gem::hosted::converge_gem_lock_source; +pub(crate) use crate::formats::yarn::is_berry_lock; use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] @@ -3154,20 +3155,6 @@ pub(crate) fn yarn_lock_fragment_kind(kind: &str) -> bool { ) } -/// A yarn.lock is berry (v2+) when it carries the `__metadata:` header block; -/// anything else is a classic v1 lock. Shared by both yarn rewriters and -/// lockfile discovery (`vex::discover::yarn`) so the grammar split cannot -/// drift. 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. -pub(crate) fn is_berry_lock(content: &str) -> bool { - content - .strip_prefix('\u{feff}') - .unwrap_or(content) - .lines() - .any(|line| line.starts_with("__metadata:")) -} - /// The `cacheKey:` value from the `__metadata` block (berry writes it unquoted: /// ` cacheKey: 10c0`), mirroring the vendored backend's `berry_field`. fn berry_cache_key(content: &str) -> Option { @@ -3539,24 +3526,21 @@ pub fn preflight_bun_hosted(content: &str) -> Result<(), RewriteWarning> { fn parse_bun_hosted_lock( content: &str, ) -> Result<(Vec, Vec), RewriteWarning> { - use crate::vendor::bun_lock_text::{ - check_lock_version, has_workspace_packages, lock_version, parse_packages_section, - }; + use crate::vendor::bun_lock_text::{has_workspace_packages, lock_version}; // The shared gate's `Err` text IS the detail: hosted and vendored refuse // an unsupported head with one message (and one remedy per arm — a // future version means "update socket-patch", a missing integer means // "re-lock"), so the two modes cannot drift apart. - if let Err(detail) = check_lock_version(content) { - return Err(RewriteWarning { - code: "redirect_bun_lock_unsupported".into(), - detail, - }); - } - let lines: Vec = content.split('\n').map(str::to_string).collect(); - let entries = match parse_packages_section(&lines) { - Ok(entries) => entries, - Err(_) => { + let (lines, entries) = match crate::formats::bun::BunTextLock::parse(content) { + Ok(lock) => (lock.lines, lock.entries), + Err(crate::formats::bun::BunTextError::Version(detail)) => { + return Err(RewriteWarning { + code: "redirect_bun_lock_unsupported".into(), + detail, + }); + } + Err(crate::formats::bun::BunTextError::Packages(_)) => { // Fail-closed: never line-splice a lock whose packages section // deviates from bun's emitted single-line grammar. return Err(RewriteWarning { diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 71643202d..161d7b22e 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -37,13 +37,14 @@ use serde_json::Value; use sha2::{Digest, Sha512}; use crate::constants::SOCKET_DIR; +use crate::formats::bun::{BunTextError, BunTextLock}; use crate::manifest::schema::PatchRecord; use crate::patch::apply::PatchSources; use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_string}; use crate::utils::socket_dir::remove_tree_and_prune; use crate::vendor::bun_lock_text::{ - check_lock_version, decode_json_string, has_workspace_packages, lock_version, packages_bounds, - parse_entry_line, parse_packages_section, split_name_spec, BunEntry, + decode_json_string, has_workspace_packages, lock_version, packages_bounds, + parse_entry_line, split_name_spec, BunEntry, }; use super::common::{already_patched_result, refused}; @@ -216,10 +217,9 @@ pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, } Err(error) => return Err(("vendor_lockfile_missing", error.to_string())), }; - check_lock_version(&text).map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; - let lines = text.split('\n').map(str::to_string).collect::>(); - let entries = parse_packages_section(&lines) - .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + let entries = BunTextLock::parse(&text) + .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))? + .entries; check_workspace_compatibility(&text, &entries) } @@ -288,10 +288,9 @@ pub async fn wired_instances_all_ours( } Err(error) => return Err(("vendor_lockfile_missing", error.to_string())), }; - check_lock_version(&text).map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; - let lines = text.split('\n').map(str::to_string).collect::>(); - let entries = parse_packages_section(&lines) - .map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?; + let entries = BunTextLock::parse(&text) + .map_err(|e| ("vendor_lockfile_version_unsupported", e.detail()))? + .entries; let target_spec = format!("{name}@{version}"); let target_leaf = tgz_rel_leaf(&name, &version); let mut matched = 0usize; @@ -659,16 +658,15 @@ pub(super) async fn read_project(project_root: &Path) -> Result = lock_text.split('\n').map(str::to_string).collect(); - let entries = match parse_packages_section(&lines) { - Ok(entries) => entries, - Err(detail) => { + let (lines, entries) = match BunTextLock::parse(&lock_text) { + Ok(lock) => (lock.lines, lock.entries), + Err(BunTextError::Version(detail)) => { + return Err(Box::new(refused( + "vendor_lockfile_version_unsupported", + detail, + ))); + } + Err(BunTextError::Packages(detail)) => { // SECURITY/fail-closed: never line-splice a lock whose packages // section does not match the pinned single-line grammar. return Err(Box::new(refused( diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index bbb20df10..f1c26c852 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB}; +use crate::formats::bun::{BunTextError, BunTextLock}; use crate::vendor::bun_lock_text::{self, BunEntry}; use crate::vendor::bun_lockb::BunLockb; @@ -18,9 +19,11 @@ use super::{http_url, LockIntegrity, LockfileEntry, UnsupportedNpmLayout}; /// lock the backends refuse — a hand re-indented one included — is one /// neither the inventory nor lockfile discovery reads. pub(crate) fn bun_text_entries(text: &str) -> Result, String> { - bun_lock_text::check_lock_version(text)?; - let lines: Vec = text.split('\n').map(str::to_string).collect(); - bun_lock_text::parse_packages_section(&lines).map_err(|e| format!("{BUN_LOCK}: {e}")) + match BunTextLock::parse(text) { + Ok(lock) => Ok(lock.entries), + Err(BunTextError::Version(detail)) => Err(detail), + Err(BunTextError::Packages(e)) => Err(format!("{BUN_LOCK}: {e}")), + } } // ── file selection ── diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs index 106c85c77..7161b358b 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -17,6 +17,7 @@ use crate::utils::fs::{ }; use crate::vendor::npm_flavor::NpmLockFlavor; use crate::formats::pnpm::{sniff_lock_grammar, PnpmLockGrammar}; +use crate::formats::yarn::{sniff_grammar, YarnLockGrammar, UNIDENTIFIED_DETAIL}; use crate::vendor::VendorWarning; /// One in-memory file. @@ -289,10 +290,6 @@ impl ProjectView<'_> { } } -/// How many head lines the yarn content sniff reads (mirrors the disk -/// probe). -const YARN_SNIFF_HEAD_LINES: usize = 30; - /// [`crate::vendor::npm_flavor::detect_npm_lock_flavor`] over a /// [`ProjectView`]. The disk variant IS the disk probe; the memory variant /// follows the same decision table, with pnpm's own Plug'n'Play layout @@ -350,24 +347,16 @@ pub(crate) async fn detect_npm_lock_flavor_in( } if exists("yarn.lock") { let text = read_lock("yarn.lock")?; - let head: Vec<&str> = text - .strip_prefix('\u{feff}') - .unwrap_or(&text) - .lines() - .take(YARN_SNIFF_HEAD_LINES) - .collect(); - if head.iter().any(|l| l.starts_with("__metadata:")) { - break 'flavor NpmLockFlavor::YarnBerry; - } - if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - break 'flavor NpmLockFlavor::YarnClassic; + match sniff_grammar(&text) { + Some(YarnLockGrammar::Berry) => break 'flavor NpmLockFlavor::YarnBerry, + Some(YarnLockGrammar::Classic) => break 'flavor NpmLockFlavor::YarnClassic, + None => { + return Err(( + "vendor_lockfile_version_unsupported", + UNIDENTIFIED_DETAIL.to_string(), + )) + } } - return Err(( - "vendor_lockfile_version_unsupported", - "yarn.lock carries neither the `# yarn lockfile v1` header nor a berry \ - `__metadata:` key; cannot identify the lockfile version" - .to_string(), - )); } if exists(NPM_LOCKS[0]) || exists(NPM_LOCKS[1]) { break 'flavor NpmLockFlavor::PackageLock; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 2e6f6b219..a0ffe4248 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -26,6 +26,7 @@ use crate::patch::apply::PatchSources; use crate::utils::fs::{read_regular_to_bytes, read_regular_to_string}; use crate::formats::pnpm::PnpmLockGrammar; +use crate::formats::yarn::{sniff_grammar, YarnLockGrammar, UNIDENTIFIED_DETAIL}; use super::source::PackageSource; use super::state::VendorEntry; use super::{ @@ -91,11 +92,6 @@ use crate::constants::npm_family::{ BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, PNP_MARKERS, VLT_LOCK, }; -/// How many head lines the yarn content sniff reads (the v1 header sits in -/// the leading comment block; berry's `__metadata:` is the first top-level -/// key after it). -const YARN_SNIFF_HEAD_LINES: usize = 30; - /// Every lockfile name the probe knows, grouped into wiring families: the /// flavor that owns a family wires (or supersedes) every file in it, so only /// files OUTSIDE the detected family get the multiple-lockfiles warning. @@ -322,29 +318,19 @@ async fn read_lock(project_root: &Path, name: &str) -> Result Result { let text = read_lock(project_root, "yarn.lock").await?; - // CRLF lines split like LF ones; a leading BOM is not key text. - let head: Vec<&str> = text - .strip_prefix('\u{feff}') - .unwrap_or(&text) - .lines() - .take(YARN_SNIFF_HEAD_LINES) - .collect(); // Berry wins the check (it must never be mistaken for classic). The // node-modules linker keeps packages on disk for staging, and berry's // cache-zip checksum is reproducible from our tarball (berry_zip), so the // backend can wire it; PnP (caught earlier by the `.pnp.*` markers) is the // only berry layout vendor refuses. - if head.iter().any(|l| l.starts_with("__metadata:")) { - return Ok(NpmLockFlavor::YarnBerry); - } - if head.iter().any(|l| l.trim() == "# yarn lockfile v1") { - return Ok(NpmLockFlavor::YarnClassic); + match sniff_grammar(&text) { + Some(YarnLockGrammar::Berry) => return Ok(NpmLockFlavor::YarnBerry), + Some(YarnLockGrammar::Classic) => return Ok(NpmLockFlavor::YarnClassic), + None => {} } Err(( "vendor_lockfile_version_unsupported", - "yarn.lock carries neither the `# yarn lockfile v1` header nor a berry \ - `__metadata:` key; cannot identify the lockfile version" - .to_string(), + UNIDENTIFIED_DETAIL.to_string(), )) } 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 cd65d5244..38e5e9054 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -277,7 +277,7 @@ pub async fn vendor_yarn_classic<'a>( /// separates classic from berry, but rewriting a berry lock with classic /// grammar would corrupt it — never proceed past a `__metadata:` key. fn refuse_berry_lock(text: &str) -> Result<(), Box> { - if text.lines().any(|l| l.starts_with("__metadata:")) { + if crate::formats::yarn::is_berry_lock(text) { return Err(Box::new(refused( "vendor_lockfile_version_unsupported", "yarn.lock is a yarn berry (v2+) lockfile (top-level `__metadata:` key); the \ From d2d6113f9f3e09c9d4753265b4ebf08a611aea5a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:57:04 +0000 Subject: [PATCH 09/13] Read repair's yarn/bun trust anchor through the entry models wired_vendor_integrity scanned yarn.lock and bun.lock with a six-line forward window from any line naming the artifact. It now reads the entry models lockfile discovery uses: live classic blocks' integrity, berry checksum (yarn 4.0.x bare hex promoted under cacheKey 10c0), and bun's tarball tuple. That fixes a berry block whose carried dependencies pushed checksum out of the window (no anchor), a bare-hex checksum (no anchor), a shadowed classic block being read, and bun's digest-less re-save borrowing the next package's sha512 (a wrong anchor). Entries that disagree yield no anchor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/vendor/lock_inventory/tests.rs | 74 +++++++++++ .../src/vendor/lock_inventory/wired.rs | 118 ++++++++++++------ 2 files changed, 153 insertions(+), 39 deletions(-) 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 238877169..5d0a45f5c 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -2409,6 +2409,80 @@ async fn wired_vendor_integrity_reads_rewired_yarn_classic_and_skips_bad_json_lo ); } +/// The yarn / bun branches of `wired_vendor_integrity` read the entry +/// models lockfile discovery reads, not a line window: a berry block whose +/// carried sections push `checksum:` far below the reference, yarn 4.0.x's +/// bare-hex checksum, a CRLF classic lock, a shadowed classic block (yarn +/// keeps the last one) and bun's digest-less re-save (which must never +/// borrow the next tuple's sha512). +#[tokio::test] +async fn wired_vendor_integrity_reads_yarn_and_bun_entries_structurally() { + let rel = ".socket/vendor/npm/9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f/left-pad-1.3.0.tgz"; + let hex = "ab".repeat(64); + let berry = |checksum: &str| { + format!( + "__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"left-pad@file:./{rel}::locator=app%40workspace%3A.\":\n \ + version: 1.3.0\n \ + resolution: \"left-pad@file:./{rel}#./{rel}::hash=abc&locator=app%40workspace%3A.\"\n \ + dependencies:\n a: \"npm:1.0.0\"\n b: \"npm:1.0.0\"\n c: \"npm:1.0.0\"\n d: \"npm:1.0.0\"\n e: \"npm:1.0.0\"\n \ + checksum: {checksum}\n \ + languageName: node\n \ + linkType: hard\n" + ) + }; + for (checksum, want) in [ + (format!("10c0/{hex}"), format!("10c0/{hex}")), + (hex.clone(), format!("10c0/{hex}")), + ] { + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "yarn.lock", &berry(&checksum)).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::BerryChecksum(want)), + "{checksum}" + ); + } + + let classic = |key: &str, sri: &str| { + format!( + "{key}:\n version \"1.3.0\"\n resolved \"file:./{rel}#0000000000000000000000000000000000000000\"\n integrity {sri}\n" + ) + }; + let tmp = tempfile::tempdir().unwrap(); + let lock = format!( + "# yarn lockfile v1\n\n{}\n{}", + classic("left-pad@^1.3.0", "sha512-shadowed=="), + classic("left-pad@^1.3.0", "sha512-live==") + ) + .replace('\n', "\r\n"); + write(tmp.path(), "yarn.lock", &lock).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri("sha512-live==".into())), + "the live (last) block of a CRLF lock" + ); + + let bun = |ours: &str| { + format!( + "{{\n \"lockfileVersion\": 1,\n \"workspaces\": {{\n \"\": {{\n \"name\": \"app\",\n }},\n }},\n \"packages\": {{\n \"left-pad\": [\"left-pad@./{rel}\", {{}}{ours}],\n\n \"right-pad\": [\"right-pad@1.0.0\", \"\", {{}}, \"sha512-theirs==\"],\n }}\n}}\n" + ) + }; + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &bun(", \"sha512-ours==\"")).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + Some(LockIntegrity::Sri("sha512-ours==".into())) + ); + let tmp = tempfile::tempdir().unwrap(); + write(tmp.path(), "bun.lock", &bun("")).await; + assert_eq!( + wired_vendor_integrity(tmp.path(), rel).await, + None, + "a digest-less re-save pins nothing" + ); +} + /// `PnpmPackage::resolution_tokens` exposes the raw `resolution:` value the /// grammar refused (a nested map, a duplicate key, a wrapped flow map), so /// lockfile discovery can still tell a Socket-shaped entry from anything 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 50b57d816..9ed45e49d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -12,10 +12,16 @@ 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; use super::npm::npm_lock_nodes; -use super::recover::inline_yaml_field; +use super::yarn::{berry_checksum_pin, berry_entries, classic_entries}; use super::LockIntegrity; /// The integrity the REWIRED npm-family lockfile records for a vendored @@ -25,10 +31,9 @@ use super::LockIntegrity; /// matches it is exactly what the package manager would have installed. /// /// package-lock/shrinkwrap are parsed as JSON, pnpm through its format -/// model; the other text formats (yarn classic/berry, bun) are scanned with -/// a bounded forward window from each reference line. vlt yields `None`: its `file` nodes pin no -/// integrity (slot [2] is `null`), and `vlt-lock.json` is never scanned, -/// because the forward window would pick up a neighbouring node's sha512. +/// model, yarn (classic and berry) and bun.lock through the entry models +/// lockfile discovery reads. vlt yields `None`: its `file` nodes pin no +/// integrity (slot [2] is `null`). pub async fn wired_vendor_integrity( project_root: &Path, artifact_rel: &str, @@ -138,47 +143,82 @@ pub async fn wired_vendor_integrity( } } - // yarn / bun text locks: any line referencing the artifact path, - // integrity within a short forward window (the same block). - for lock in ["yarn.lock", BUN_LOCK] { - let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else { - continue; + // yarn: the entry models lockfile discovery reads (live blocks only — + // yarn keeps the last block per pattern), classic `integrity` SRI or + // berry `checksum:` (yarn 4.0.x bare hex promoted under cacheKey 10c0). + if let Ok(text) = read_regular_to_string(&project_root.join("yarn.lock")).await { + let pins: Vec = if is_berry_lock(&text) { + let lock = berry_entries(&text); + lock.entries + .iter() + .filter(|e| e.live) + .filter(|e| { + e.locator() + .and_then(|l| vendor_ref_decorated(l.reference)) + .is_some_and(|v| v.artifact_rel == rel) + }) + .filter_map(|e| { + berry_field(&e.block.lines, "checksum") + .and_then(|c| berry_checksum_pin(c, lock.cache_key.as_deref())) + }) + .collect() + } else { + classic_entries(&text) + .iter() + .filter(|e| e.live) + .filter(|e| { + classic_field(&e.block.lines, "resolved") + .and_then(vendor_ref_decorated) + .is_some_and(|v| v.artifact_rel == rel) + }) + .filter_map(|e| classic_field(&e.block.lines, "integrity")) + .filter(|sri| is_sri_pin(sri)) + .map(|sri| LockIntegrity::Sri(sri.to_string())) + .collect() }; - let lines: Vec<&str> = text.lines().collect(); - for (i, line) in lines.iter().enumerate() { - if !line.contains(rel) { - continue; - } - for probe in lines.iter().take((i + 6).min(lines.len())).skip(i) { - // classic `integrity …` / bun tuple `"sha512-…"`. - if let Some(v) = inline_yaml_field(probe, "integrity:") { - if is_sri_pin(&v) { - return Some(LockIntegrity::Sri(v)); - } - } - if let Some(rest) = probe.trim().strip_prefix("integrity ") { - let v = rest.trim().trim_matches('"'); - if is_sri_pin(v) { - return Some(LockIntegrity::Sri(v.to_string())); - } - } - if let Some(sri) = probe.split('"').rev().find(|tok| is_sri_pin(tok)) { - return Some(LockIntegrity::Sri(sri.to_string())); - } - // yarn berry: `checksum: 10c0/…`. - if let Some(v) = inline_yaml_field(probe, "checksum:") { - if v.split_once('/') - .is_some_and(|(k, b)| !k.is_empty() && !b.is_empty()) - { - return Some(LockIntegrity::BerryChecksum(v)); - } - } + if let Some(pin) = unanimous(pins) { + return Some(pin); + } + } + + // bun.lock: our tarball tuple `[spec, {meta}, "sha512-…"]` (bun + // < 1.3.10 re-saves it digest-less, which pins nothing). + if let Ok(text) = read_regular_to_string(&project_root.join(BUN_LOCK)).await { + if let Ok(entries) = bun_text_entries(&text) { + let pins: Vec = entries + .iter() + .filter(|e| { + matches!(e.elems.len(), 2 | 3) + && e.elems[1].starts_with('{') + && e.elems + .first() + .and_then(|spec| decode_json_string(spec)) + .is_some_and(|spec| { + split_name_spec(&spec) + .and_then(|(_, target)| vendor_ref(target)) + .is_some_and(|v| v.artifact_rel == rel) + }) + }) + .filter_map(|e| e.elems.get(2).and_then(|sri| decode_json_string(sri))) + .filter(|sri| is_sri_pin(sri)) + .map(LockIntegrity::Sri) + .collect(); + if let Some(pin) = unanimous(pins) { + return Some(pin); } } } None } +/// The one pin every entry agrees on; `None` when there is none or the +/// entries disagree (no anchor beats a wrong one). +fn unanimous(pins: Vec) -> Option { + let mut pins = pins.into_iter(); + let first = pins.next()?; + pins.all(|p| p == first).then_some(first) +} + #[cfg(test)] mod tests { use super::*; From cf050e43d1f4e352358ef73d81b14a97acab4398 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:15:42 +0000 Subject: [PATCH 10/13] Pin gem CHECKSUMS through the shared entry grammar The hosted writer found the dep's CHECKSUMS row with a regex that only matched a lowercase terminal sha256, while the discovery reader accepts uppercase digests, extra digest tokens and bare entries. Any of those fell through to the insert branch and added a second, conflicting row (which the reader then treats as no pin). The writer now locates the entry with formats::gem's split_checksum_entry inside the CHECKSUMS section and replaces that row in place, keeping its line ending and recording the old row verbatim for revert. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/gem/hosted.rs | 32 +++++ .../src/patch/redirect/mod.rs | 133 +++++++++++++----- 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/crates/socket-patch-core/src/formats/gem/hosted.rs b/crates/socket-patch-core/src/formats/gem/hosted.rs index ec9920f2c..0416c3594 100644 --- a/crates/socket-patch-core/src/formats/gem/hosted.rs +++ b/crates/socket-patch-core/src/formats/gem/hosted.rs @@ -273,3 +273,35 @@ pub(crate) fn converge_gem_lock_source( } true } + +/// The byte span (line content, ending excluded) of the `CHECKSUMS` entry +/// for exactly `name (version)` — the platform-less spec the hosted planner +/// pins — read with the shared entry grammar +/// ([`super::split_checksum_entry`]): a 2-space entry inside the column-0 +/// `CHECKSUMS` section, whatever digests it carries (bare, uppercase, +/// several algorithms). The first such entry; `None` when there is none. +/// CRLF endings stay outside the span. +pub(crate) fn checksum_entry_span(lock: &str, name: &str, version: &str) -> Option<(usize, usize)> { + let mut offset = 0; + let mut in_checksums = false; + for line in lock.split_inclusive('\n') { + let start = offset; + offset += line.len(); + let content = gem_lock_line_content(line); + if !content.is_empty() && !content.starts_with(' ') { + in_checksums = content == "CHECKSUMS"; + continue; + } + let Some(entry) = content.strip_prefix(" ").filter(|e| !e.starts_with(' ')) else { + continue; + }; + if in_checksums + && super::split_checksum_entry(entry) + .is_some_and(|(n, token, _)| n == name && token == version) + { + return Some((start, start + content.len())); + } + } + None +} + diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index d6f141d3d..517e043b1 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -50,7 +50,7 @@ use crate::formats::pnpm::grammar as pnpm; use crate::formats::pnpm::plan_hosted; use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; -use crate::formats::gem::hosted::converge_gem_lock_source; +use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source}; pub(crate) use crate::formats::yarn::is_berry_lock; use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; @@ -5044,46 +5044,30 @@ fn rewrite_gem( }); continue; } - let sum_line_re = Regex::new( - &(String::from(r"(?m)^( ") - + ®ex::escape(&dep.name) - + r" \(" - + ®ex::escape(&dep.version) - + r"\)) sha256=([0-9a-f]+)(\r?)$"), - ) - .expect("checksum-line regex from the escaped name/version is valid"); let new_val = format!("{} ({}) sha256={sha256}", dep.name, dep.version); - // Already redirected (re-run): the CHECKSUMS line is at the - // target value; recording an edit would grow the ledger forever. - let already_re = - Regex::new(&(String::from(r"(?m)^ ") + ®ex::escape(&new_val) + r"\r?$")) - .expect("already-redirected regex from the escaped line is valid"); let mut checksums_era = true; - if already_re.is_match(lk) { - // no-op - } else if let Some(m) = sum_line_re.captures(lk) { - // The pre-edit line goes into the ledger as `original` so a - // revert can restore the upstream sha. - let old_val = format!( - "{} ({}) sha256={}", - dep.name, - dep.version, - m.get(2) - .expect("sum_line_re always captures group 2 (sha hex)") - .as_str() - ); - *lk = sum_line_re - .replace(lk, format!("${{1}} sha256={sha256}${{3}}").as_str()) - .to_string(); - lock_changed = true; - result.edits.push(FileEdit { - path: lock_name.into(), - kind: "redirect_gemfile_lock_checksum".into(), - action: "rewritten".into(), - key: Some(dep.name.clone()), - original: Some(Value::String(old_val)), - new: Some(Value::String(new_val)), - }); + // The entry for exactly `name (version)`, read with the shared + // Bundler-lock grammar the discovery reader uses — whatever + // digests it carries (bare, uppercase, several algorithms), so it + // is REPLACED, never shadowed by a second, conflicting entry. + if let Some((start, end)) = checksum_entry_span(lk, &dep.name, &dep.version) { + let old_val = lk[start + 2..end].to_string(); + // Already redirected (re-run): the entry is at the target + // value; recording an edit would grow the ledger forever. + if old_val != new_val { + lk.replace_range(start + 2..end, &new_val); + lock_changed = true; + // The pre-edit entry goes into the ledger verbatim as + // `original` so a revert restores the upstream digests. + result.edits.push(FileEdit { + path: lock_name.into(), + kind: "redirect_gemfile_lock_checksum".into(), + action: "rewritten".into(), + key: Some(dep.name.clone()), + original: Some(Value::String(old_val)), + new: Some(Value::String(new_val)), + }); + } } else if checksums_re.is_match(lk) { *lk = checksums_re .replace( @@ -9946,6 +9930,77 @@ mod tests { ) } + /// The CHECKSUMS writer finds the dep's entry with the shared Bundler-lock + /// grammar the discovery reader uses, so every spelling that reader + /// accepts — lowercase, uppercase, extra digest tokens (space- or + /// comma-joined), a bare entry, CRLF — is REPLACED by the patched pin: + /// exactly one `rails (7.0.0)` row survives, it reads back as the + /// patched sha, and the ledger holds the old entry verbatim for revert. + #[test] + fn gem_checksum_rewrite_replaces_every_spelling_the_reader_accepts() { + let lower = "2".repeat(64); + let upper = "A".repeat(64); + let sha512 = "b".repeat(128); + let patched = "f".repeat(64); + let entries = [ + format!("rails (7.0.0) sha256={lower}"), + format!("rails (7.0.0) sha256={upper}"), + format!("rails (7.0.0) sha256={lower} sha512={sha512}"), + format!("rails (7.0.0) sha256={lower},sha512={sha512}"), + "rails (7.0.0)".to_string(), + ]; + for crlf in [false, true] { + for entry in &entries { + let mut lock = gem_lock(&format!(" {entry}")); + if crlf { + lock = lock.replace('\n', "\r\n"); + } + let mut files = BTreeMap::new(); + files.insert( + "Gemfile".to_string(), + "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n".to_string(), + ); + files.insert("Gemfile.lock".to_string(), lock); + let r = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + let out = r.files.get("Gemfile.lock").expect("lock rewritten"); + let rows: Vec<&str> = out + .lines() + .filter(|l| l.trim_start().starts_with("rails (7.0.0)") && l.starts_with(" ") && !l.starts_with(" ")) + .collect(); + assert_eq!( + rows, + [format!(" rails (7.0.0) sha256={patched}")], + "{entry} (crlf={crlf}): exactly one patched row\n{out}" + ); + let eol = if crlf { "\r\n" } else { "\n" }; + assert!( + out.contains(&format!(" rails (7.0.0) sha256={patched}{eol}")), + "{entry}: the entry keeps its line ending: {out:?}" + ); + let model = crate::formats::gem::GemfileLock::parse(out); + assert_eq!(model.checksum("rails", "7.0.0"), Some(patched.as_str()), "{entry}"); + assert!(!out.contains("\r\r"), "line endings kept: {out:?}"); + let edit = r + .edits + .iter() + .find(|e| e.kind == "redirect_gemfile_lock_checksum") + .expect("checksum edit recorded"); + assert_eq!(edit.action, "rewritten", "{entry}"); + assert_eq!(edit.original, Some(Value::String(entry.clone())), "{entry}"); + + // A re-run over the rewritten lock records nothing new. + files.insert("Gemfile".to_string(), r.files["Gemfile"].clone()); + files.insert("Gemfile.lock".to_string(), out.clone()); + let again = rewrite_registry_redirect(&files, &[gem_override("rails", "7.0.0")]); + assert!( + !again.edits.iter().any(|e| e.kind == "redirect_gemfile_lock_checksum"), + "{entry}: rerun is a no-op: {:?}", + again.edits + ); + } + } + } + /// The edit must splice by the regex match's byte range: a substring /// replace of the matched line's TEXT finds an identical commented-out /// duplicate earlier in the file first and corrupts the comment while the From 9301a3946d23c55e0302230e97c12f5c0839c6de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:25:22 +0000 Subject: [PATCH 11/13] Answer pnpm in-use from the model's parse; drop the placeholder hook PnpmLock computes its vendored-uuid set at parse time with one walk over the packages/snapshots block keys (the vendored planners' key grammar, each line's \r dropped). pnpm_entry_in_use memoizes the same set per lock bytes instead of keeping LockIndex's copy and the LF-only vendored_in_use_lines scan. A CRLF lock now answers like its LF twin (in use) instead of undeterminable; the unwired-revert guard still refuses. formats::LockModel with its default-unsupported restore_upstream() is removed: hosted upstream restoration belongs to the ledger-free hosted workstream and should land on these models, not beside them as a placeholder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/cargo/mod.rs | 5 -- .../src/formats/composer/mod.rs | 5 -- .../socket-patch-core/src/formats/gem/mod.rs | 4 - crates/socket-patch-core/src/formats/mod.rs | 53 ++----------- .../socket-patch-core/src/formats/pnpm/mod.rs | 76 +++++++++---------- .../socket-patch-core/src/vendor/pnpm_lock.rs | 63 ++++++--------- .../src/vendor/pnpm_lock_legacy.rs | 17 ++--- 7 files changed, 73 insertions(+), 150 deletions(-) diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index 9b57cce6e..82ea1101d 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -18,7 +18,6 @@ //! line-grammar probes the hosted rewriter reads with; //! * the vendored planner (`vendor::cargo_lock`) edits the same document //! with `toml_edit`; -//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. //! //! Everything here is pure; the callers own the reads. @@ -31,7 +30,6 @@ use crate::utils::purl::simple_purl; use crate::vendor::cargo_tag; use crate::vendor::lock_inventory::{LockIntegrity, LockfileEntry, SourceKind}; -use super::LockModel; // ── entry model ── @@ -324,6 +322,3 @@ impl CargoLock { } } -impl LockModel for CargoLock { - const FORMAT: &'static str = "Cargo.lock"; -} diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs index 53b74410b..1da78fcea 100644 --- a/crates/socket-patch-core/src/formats/composer/mod.rs +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -10,7 +10,6 @@ //! the vendored backend (`vendor::composer_lock`) indexes its edits and //! its ownership gate ([`ComposerLockPackage::wired_to`]) by; //! * [`hosted::rewrite_composer_lock`] — the hosted planner's byte splice; -//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. pub(crate) mod hosted; @@ -22,7 +21,6 @@ use crate::utils::digest::sha1_hex; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; use crate::vendor::path::{parse_vendor_path, VendorPathParts}; -use super::LockModel; // ── entry model ── @@ -176,6 +174,3 @@ impl<'a> ComposerLock<'a> { } } -impl LockModel for ComposerLock<'_> { - const FORMAT: &'static str = "composer.lock"; -} diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index 7ffc06476..a8054fc12 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -26,7 +26,6 @@ use crate::utils::digest::sha256_hex; use crate::utils::purl::simple_purl; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; -use super::LockModel; /// The Bundler lockfiles, legacy spelling first: `Gemfile.lock` and /// `gems.locked` (what bundler writes instead when the manifest is @@ -204,9 +203,6 @@ impl<'t> GemfileLock<'t> { } } -impl LockModel for GemfileLock<'_> { - const FORMAT: &'static str = "Gemfile.lock"; -} /// Where a rubygems-compatible registry at `base` (no trailing `/`) serves /// `name`-`version`'s `.gem` — the inventory's resolved URL and ledger diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index b13898363..5ad8790bd 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -12,8 +12,11 @@ //! `repair`'s trust anchors; //! * `plan_hosted()` / the vendored planners — the edits `scan --mode //! hosted` and `vendor` make; -//! * `in_use()` — whether a vendored artifact is still consumed; -//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. +//! * `in_use()` — whether a vendored artifact is still consumed. +//! +//! Restoring a hosted pin to its upstream entry (hosted rollback) is the +//! ledger-free-hosted workstream's; it lands on these models rather than +//! beside them. //! //! Models are PURE: text (or a parsed document) in, answers out. Every read //! stays with the caller, so the disk engines and the in-memory hosted @@ -33,52 +36,6 @@ pub mod yarn; pub use registry::registry; -/// The default upstream resolution a hosted pin is restored to: the -/// registry artifact of `name@version` as the package manager itself -/// would lock it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UpstreamPin<'a> { - pub name: &'a str, - pub version: &'a str, - /// The registry artifact URL, when the format records one. - pub resolved: Option<&'a str>, - /// The format's content verifier for that artifact (an SRI, a hex - /// sha256, …). - pub integrity: Option<&'a str>, -} - -/// What [`LockModel::restore_upstream`] did with a lock. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RestoreUpstream { - /// The lock text with every named pin restored. - Rewritten(String), - /// No entry of the lock is pinned to any of the named patches. - Unchanged, - /// The format cannot restore the pins in place; `remedy` tells the user - /// how to (`git checkout -- `, a re-lock). - Unsupported { remedy: String }, -} - -/// The contract every per-format model implements. -pub trait LockModel { - /// The lock's canonical file name, for diagnostics. - const FORMAT: &'static str; - - /// Rewrite every hosted pin of the named patches back to its default - /// upstream entry with the same writer the hosted planner uses. The - /// hosted-rollback workstream fills this in per format; until then - /// every format refuses with the checkout remedy. - fn restore_upstream(&self, _pins: &[UpstreamPin<'_>]) -> RestoreUpstream { - RestoreUpstream::Unsupported { - remedy: format!( - "restore {} from version control (`git checkout -- {}`)", - Self::FORMAT, - Self::FORMAT - ), - } - } -} - /// ARCHITECTURE GUARD (module docs): format models do no I/O and apply no /// host policy. #[cfg(test)] diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index 5510ebbaf..1d495d69a 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -20,7 +20,6 @@ //! * the vendored planners (`vendor::pnpm_lock` for lockfileVersion 9.0, //! `vendor::pnpm_lock_legacy` for 5.4 / 6.0) splice with [`lines`] and //! route on [`sniff_lock_grammar`]; -//! * [`LockModel::restore_upstream`] — the hosted-rollback hook. //! //! Everything here is pure (text in, answers out); the callers own the //! reads. @@ -32,12 +31,13 @@ pub(crate) mod lines; pub(crate) use grammar::{entry_field, is_pnpm_lock_text, Entry, Resolution}; pub(crate) use hosted::plan_hosted; +use std::collections::HashSet; + use crate::constants::npm_family::PNPM_LOCK; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry}; use crate::vendor::path::parse_vendor_path; -use super::LockModel; // ── entry model ── @@ -349,6 +349,8 @@ pub(crate) fn check_v9_lock_version(text: &str) -> Result<(), String> { pub struct PnpmLock<'t> { text: &'t str, packages: Vec>, + /// [`vendored_npm_uuids`] of the text. + vendored: HashSet, } /// One `packages:` entry whose resolution names a tarball — a candidate @@ -366,6 +368,7 @@ impl<'t> PnpmLock<'t> { PnpmLock { text, packages: pnpm_packages(text), + vendored: vendored_npm_uuids(text), } } @@ -459,44 +462,39 @@ impl<'t> PnpmLock<'t> { } /// Is the vendored npm artifact of patch `uuid` still consumed by this - /// lock? `Some(true)` when a `packages:` / `snapshots:` block is keyed - /// by it ([`vendored_npm_uuid`] — v9's `name@file:` and legacy's bare - /// `file:` keys alike); `Some(false)` when the lock carries none (the - /// `overrides:` declaration alone never counts: pnpm keeps it mirrored - /// from package.json even when nothing matches it); `None` when - /// undeterminable — a CRLF lock (a Windows autocrlf checkout) defeats - /// the line grammar, so the scan would find nothing and call a lock - /// that still resolves through the artifact provably orphaned. Callers - /// keep the entry on `None`, fail-safe. - pub fn vendored_in_use(&self, uuid: &str) -> Option { - if self.text.contains('\r') { - return None; - } - Some(vendored_in_use_lines(&lines::split_lines(self.text), uuid)) + /// lock? `true` when a `packages:` / `snapshots:` block is keyed by it + /// ([`vendored_npm_uuid`] — v9's `name@file:` and legacy's bare `file:` + /// keys alike); `false` when the lock carries none (the `overrides:` + /// declaration alone never counts: pnpm keeps it mirrored from + /// package.json even when nothing matches it). CRLF locks read like LF + /// ones. + pub fn vendored_in_use(&self, uuid: &str) -> bool { + self.vendored.contains(uuid) } } -/// [`PnpmLock::vendored_in_use`] over already-split (LF) lines — the -/// per-probe scan the v9 planner's lock index answers for when it has not -/// been built. -pub(crate) fn vendored_in_use_lines(lines: &[String], uuid: &str) -> bool { - for section in ["packages", "snapshots"] { - let Some((start, end)) = lines::section_bounds(lines, section) else { +/// The uuid of every `packages:` / `snapshots:` block key that resolves +/// into `.socket/vendor/npm//` — the block-key grammar the vendored +/// planners splice with ([`lines::parse_key_line`] at two-space indent), +/// read in one walk with each line's `\r` dropped, so a CRLF lock (a +/// Windows autocrlf checkout) answers like its LF twin. +pub(crate) fn vendored_npm_uuids(text: &str) -> HashSet { + let mut out = HashSet::new(); + let mut in_section = false; + for line in text.split('\n') { + let line = line.strip_suffix('\r').unwrap_or(line); + if !line.is_empty() && !line.starts_with(' ') { + in_section = line == "packages:" || line == "snapshots:"; continue; - }; - let mut i = start + 1; - while let Some(block) = lines::next_block(lines, i, end) { - if vendored_npm_uuid(&block.key).is_some_and(|u| u == uuid) { - return true; - } - i = block.end; + } + if !in_section { + continue; + } + if let Some(uuid) = lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) { + out.insert(uuid); } } - false -} - -impl LockModel for PnpmLock<'_> { - const FORMAT: &'static str = "pnpm-lock.yaml"; + out } #[cfg(test)] @@ -543,7 +541,7 @@ mod tests { } #[test] - fn vendored_in_use_reads_v9_and_legacy_keys_and_refuses_crlf() { + fn vendored_in_use_reads_v9_and_legacy_keys_and_crlf() { let v9 = format!( "lockfileVersion: '9.0'\n\npackages:\n\n a@file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz:\n resolution: {{integrity: sha512-x, tarball: file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz}}\n version: 1.0.0\n" ); @@ -554,17 +552,17 @@ mod tests { "lockfileVersion: '9.0'\n\nsnapshots:\n\n a@file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz: {{}}\n" ); for text in [&v9, &legacy, &snapshot] { - assert_eq!(PnpmLock::parse(text).vendored_in_use(UUID), Some(true), "{text}"); + assert!(PnpmLock::parse(text).vendored_in_use(UUID), "{text}"); let other = "22222222-2222-4222-8222-222222222222"; - assert_eq!(PnpmLock::parse(text).vendored_in_use(other), Some(false)); + assert!(!PnpmLock::parse(text).vendored_in_use(other)); let crlf = text.replace('\n', "\r\n"); - assert_eq!(PnpmLock::parse(&crlf).vendored_in_use(UUID), None); + assert!(PnpmLock::parse(&crlf).vendored_in_use(UUID), "CRLF reads like LF"); } // An overrides declaration alone is not usage. let overrides = format!( "lockfileVersion: '9.0'\n\noverrides:\n a@1.0.0: file:.socket/vendor/npm/{UUID}/a-1.0.0.tgz\n" ); - assert_eq!(PnpmLock::parse(&overrides).vendored_in_use(UUID), Some(false)); + assert!(!PnpmLock::parse(&overrides).vendored_in_use(UUID)); } #[test] diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 498bb1fc9..5c000e877 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -73,7 +73,7 @@ use super::state::{ use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; use crate::constants::npm_family::PNPM_LOCK; use crate::formats::pnpm::{ - check_v9_lock_version as check_lock_version, vendored_in_use_lines, vendored_npm_uuid, + check_v9_lock_version as check_lock_version, vendored_npm_uuids, }; use crate::formats::pnpm::lines::{ indent_of, next_block, parse_key_line, section_bounds, split_lines, unquote_value, yaml_key, @@ -562,20 +562,12 @@ pub async fn pnpm_entry_in_use(entry: &VendorEntry, project_root: &Path) -> Opti if check_lock_version(&text).is_err() { return None; } - // CRLF is undeterminable (see `PnpmLock::vendored_in_use`). - if text.contains('\r') { - return None; - } // Every `packages:`/`snapshots:` block key resolving into - // `.socket/vendor/npm//`, collected once per lock bytes (see - // [`LockIndex`]) once these bytes are probed again; the first probe - // runs the model's per-call scan ([`vendored_in_use_lines`]). - let doc = LOCK_MEMO.parse_infallible(text.as_bytes(), || LockDoc::new(split_lines(&text))); - doc.note_probe(); - Some(match doc.index() { - Some(index) => index.vendored_npm_uuids.contains(&entry.uuid), - None => vendored_in_use_lines(&doc.lines, &entry.uuid), - }) + // `.socket/vendor/npm//` — the format model's one walk + // ([`vendored_npm_uuids`], CRLF read like LF), collected once per lock + // bytes: a revert pass probes once per ledger entry. + let vendored = IN_USE_MEMO.parse_infallible(text.as_bytes(), || vendored_npm_uuids(&text)); + Some(vendored.contains(&entry.uuid)) } /// FAIL-CLOSED revert guard for a ledger entry with NO wiring records, @@ -2232,6 +2224,9 @@ fn matching_blocks( /// is split afresh. The backend re-seeds the slot with the lock it wrote. static LOCK_MEMO: ParseMemo = ParseMemo::new(); +/// [`pnpm_entry_in_use`]'s vendored-uuid set, per lock bytes. +static IN_USE_MEMO: ParseMemo> = ParseMemo::new(); + /// One lock's lines plus their [`LockIndex`] — a pure function of the /// lines, so of the bytes the memo keys on — built only once the same lines /// are probed a second time ([`INDEX_AFTER_PROBES`]). @@ -2482,9 +2477,6 @@ struct LockIndex { first_importer_ver_paren: HashMap, first_importer_dep_ver_paren: HashMap<(String, String), usize>, first_importer_catalog: HashMap<(String, String), usize>, - /// The uuid of every packages/snapshots key resolving into - /// `.socket/vendor/npm//` ([`pnpm_entry_in_use`]). - vendored_npm_uuids: HashSet, } /// Every prefix of `s` that ends right before a `(`. @@ -2593,14 +2585,6 @@ impl LockIndex { i = importer.end; } } - - for section in [&index.packages, &index.snapshots] { - for block in §ion.blocks { - if let Some(uuid) = vendored_npm_uuid(&block.key) { - index.vendored_npm_uuids.insert(uuid); - } - } - } index } @@ -5677,13 +5661,13 @@ snapshots: ); } - /// A CRLF lock breaks the packages/snapshots section probes, so the - /// in-use scan finds nothing and would call a still-referenced artifact - /// "provably orphaned" (`Some(false)`) — letting the unwired-revert - /// guard delete it out from under the lock. CRLF must be undeterminable - /// (`None`), which the guard refuses on while the lock exists. + /// A CRLF lock (a Windows autocrlf checkout) must never read as + /// "provably orphaned" while it still resolves through the artifact — + /// that would let the unwired-revert guard delete it out from under the + /// lock. The in-use walk reads CRLF like LF, so it answers `Some(true)` + /// and the guard refuses. #[tokio::test] - async fn crlf_lock_is_undeterminable_for_in_use_and_unwired_revert_refuses() { + async fn crlf_lock_reads_as_in_use_and_unwired_revert_refuses() { let (fx, entry) = reconstructed_fixture().await; let crlf_lock = fx.read(PNPM_LOCK).await.replace('\n', "\r\n"); tokio::fs::write(fx.root().join(PNPM_LOCK), &crlf_lock) @@ -5692,8 +5676,8 @@ snapshots: assert_eq!( pnpm_entry_in_use(&entry, fx.root()).await, - None, - "a CRLF lock is undeterminable, never provably orphaned" + Some(true), + "a CRLF lock still consuming the artifact reads as in use" ); let outcome = revert_pnpm(&entry, fx.root(), false).await; assert!(!outcome.success, "unwired revert must refuse: {outcome:?}"); @@ -8355,13 +8339,12 @@ snapshots: "seed {seed} {name}" ); } - for uuid in [UUID, OTHER_UUID] { - assert_eq!( - index.vendored_npm_uuids.contains(uuid), - vendored_in_use_lines(&lines, uuid), - "seed {seed} in-use {uuid}" - ); - } + // The in-use walk reads a CRLF lock like its LF twin. + assert_eq!( + vendored_npm_uuids(&text), + vendored_npm_uuids(&text.replace('\n', "\r\n")), + "seed {seed} in-use" + ); for name in NAMES { for version in VERSIONS { let scan = check_rewritable_refs_with(&lines, name, version, None); diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index b9d0b06c1..5e4236db5 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -737,7 +737,7 @@ pub async fn pnpm_legacy_entry_in_use(entry: &VendorEntry, project_root: &Path) Ok(PnpmLockGrammar::V54 | PnpmLockGrammar::V60) => {} _ => return None, } - PnpmLock::parse(&text).vendored_in_use(&entry.uuid) + Some(PnpmLock::parse(&text).vendored_in_use(&entry.uuid)) } // ─────────────────────────── pre-flight checks ─────────────────────────── @@ -2993,13 +2993,12 @@ packages: assert_eq!(pnpm_legacy_entry_in_use(&entry, fx.root()).await, None); } - /// A CRLF-converted lock (a Windows autocrlf checkout) is UNDETERMINABLE - /// for the in-use probe — `sniff_lock_grammar` tolerates the `\r` (its - /// `trim()` eats it) but every LF-exact section probe misses, so without - /// the guard the probe calls a lock that still resolves through the - /// artifact "provably orphaned" and the unwired-revert guard deletes it. + /// A CRLF-converted lock (a Windows autocrlf checkout) must never read + /// as "provably orphaned" while it still resolves through the artifact + /// (the unwired-revert guard would delete it): the in-use walk reads + /// CRLF like LF and answers `Some(true)`. #[tokio::test] - async fn crlf_lock_is_undeterminable_for_in_use_and_unwired_revert_refuses() { + async fn crlf_lock_reads_as_in_use_and_unwired_revert_refuses() { let fx = fixture_with(T_BEFORE_PKG, T7_BEFORE_LOCK).await; let (_, entry, _) = expect_done(fx.vendor(false).await); let mut entry = entry.unwrap(); @@ -3010,8 +3009,8 @@ packages: assert_eq!( pnpm_legacy_entry_in_use(&entry, fx.root()).await, - None, - "a CRLF lock is undeterminable, never provably orphaned" + Some(true), + "a CRLF lock still consuming the artifact reads as in use" ); // The empty-wiring (repair-reconstructed) revert rides that verdict. From f2e692a93b6b5033f14ddbac57cdb5e868b6a65f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:43:06 +0000 Subject: [PATCH 12/13] Move the maven pom and nuget config readers into formats vendor::maven_pom becomes formats::maven and nuget_config's routing reader becomes formats::nuget; the NuGet config file names and the stat-only same-file check stay in vendor::nuget_config with the callers' I/O. Lockfile discovery, their only reader, imports the models directly; the purity guards follow the files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../maven_pom.rs => formats/maven/mod.rs} | 0 crates/socket-patch-core/src/formats/mod.rs | 2 + .../src/formats/nuget/mod.rs | 233 +++++++++++++++++ .../src/vendor/lock_inventory/mod.rs | 10 +- crates/socket-patch-core/src/vendor/mod.rs | 1 - .../src/vendor/nuget_config.rs | 234 +----------------- .../src/vex/discover/maven.rs | 2 +- .../socket-patch-core/src/vex/discover/mod.rs | 4 +- .../src/vex/discover/nuget.rs | 3 +- 9 files changed, 247 insertions(+), 242 deletions(-) rename crates/socket-patch-core/src/{vendor/maven_pom.rs => formats/maven/mod.rs} (100%) create mode 100644 crates/socket-patch-core/src/formats/nuget/mod.rs diff --git a/crates/socket-patch-core/src/vendor/maven_pom.rs b/crates/socket-patch-core/src/formats/maven/mod.rs similarity index 100% rename from crates/socket-patch-core/src/vendor/maven_pom.rs rename to crates/socket-patch-core/src/formats/maven/mod.rs diff --git a/crates/socket-patch-core/src/formats/mod.rs b/crates/socket-patch-core/src/formats/mod.rs index 5ad8790bd..f3ea013a3 100644 --- a/crates/socket-patch-core/src/formats/mod.rs +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -29,6 +29,8 @@ pub mod cargo; pub mod composer; pub mod gem; +pub(crate) mod maven; +pub(crate) mod nuget; pub mod pnpm; pub(crate) mod bun; pub mod registry; diff --git a/crates/socket-patch-core/src/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs new file mode 100644 index 000000000..294b4ca7e --- /dev/null +++ b/crates/socket-patch-core/src/formats/nuget/mod.rs @@ -0,0 +1,233 @@ +//! NuGet config files: the routing reader (the per-directory names NuGet +//! probes and the stat-only same-file check stay with the callers' I/O in +//! `vendor::nuget_config`). +//! +//! The reader is a minimal, bounded XML tokenizer (no parser dependency) +//! that records only what NuGet routes by: `` directly under +//! `configuration/packageSources`, the mapping elements directly under +//! `configuration/packageSourceMapping`, and `disabledPackageSources` +//! entries whose `value` is `true`. Comments, CDATA, processing instructions +//! and DOCTYPEs are skipped; an unterminated tag or comment, an unquoted +//! attribute or a mismatched close tag makes the whole file `None`. + +use std::collections::BTreeSet; + +// ── pure reader ── + +/// Bound on element nesting — the config is tamper-able input (real configs +/// are three levels deep). +const MAX_XML_DEPTH: usize = 64; + +/// The parts of a `nuget.config` that route packages. +#[derive(Debug, Default)] +pub(crate) struct NugetConfig { + /// `configuration/packageSources/add` `(key, value)`, document order. + pub(crate) sources: Vec<(String, String)>, + /// `configuration/packageSourceMapping/packageSource` `(key, patterns)`, + /// one row per element, document order. + pub(crate) mappings: Vec<(String, Vec)>, + /// Keys `configuration/disabledPackageSources` turns off. + pub(crate) disabled: BTreeSet, +} + +/// One open (or self-closing) tag. +struct Tag<'a> { + name: &'a str, + attrs: Vec<(&'a str, String)>, + self_closing: bool, +} + +impl Tag<'_> { + fn attr(&self, name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(n, _)| *n == name) + .map(|(_, v)| v.as_str()) + } +} + +/// Parse the routing parts of `text`, or `None` when it is not well-formed +/// enough to trust (see the module docs). +pub(crate) fn parse_config(text: &str) -> Option { + let mut cfg = NugetConfig::default(); + let mut stack: Vec<&str> = Vec::new(); + // Index into `cfg.mappings` of the open `` element. + let mut open_mapping: Option = None; + let mut i = 0; + while let Some(rel) = text[i..].find('<') { + let at = i + rel; + let rest = &text[at..]; + if let Some(comment) = rest.strip_prefix("")? + 3; + } else if rest.starts_with("")? + 3; + } else if rest.starts_with("")? + 2; + } else if rest.starts_with("')? + 1; + } else if let Some(close) = rest.strip_prefix("')?; + if stack.pop()? != close[..end].trim() { + return None; + } + i = at + 2 + end + 1; + } else { + let (tag, consumed) = parse_open_tag(&rest[1..])?; + i = at + 1 + consumed; + visit(&stack, &tag, &mut cfg, &mut open_mapping); + if !tag.self_closing { + if stack.len() >= MAX_XML_DEPTH { + return None; + } + stack.push(tag.name); + } + } + } + stack.is_empty().then_some(cfg) +} + +/// Record `tag` if it sits where NuGet reads routing data. +fn visit(stack: &[&str], tag: &Tag<'_>, cfg: &mut NugetConfig, open_mapping: &mut Option) { + match (stack, tag.name) { + (["configuration", "packageSources"], "add") => { + if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { + cfg.sources.push((key.to_string(), value.to_string())); + } + } + (["configuration", "disabledPackageSources"], "add") => { + if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { + if value.trim().eq_ignore_ascii_case("true") { + cfg.disabled.insert(key.to_string()); + } + } + } + (["configuration", "packageSourceMapping"], "packageSource") => { + *open_mapping = match tag.attr("key") { + Some(key) => { + cfg.mappings.push((key.to_string(), Vec::new())); + (!tag.self_closing).then(|| cfg.mappings.len() - 1) + } + None => None, + }; + } + (["configuration", "packageSourceMapping", "packageSource"], "package") => { + if let (Some(idx), Some(pattern)) = (*open_mapping, tag.attr("pattern")) { + cfg.mappings[idx].1.push(pattern.trim().to_string()); + } + } + _ => {} + } +} + +/// Parse an open tag starting right after its `<`: `(tag, bytes consumed +/// through the closing `>`)`. Attribute values must be quoted (either XML +/// quote) and are entity-decoded. +fn parse_open_tag(s: &str) -> Option<(Tag<'_>, usize)> { + let name_end = s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; + let name = &s[..name_end]; + if name.is_empty() { + return None; + } + let mut attrs = Vec::new(); + let mut j = name_end; + loop { + j += leading_ws(&s[j..]); + let t = &s[j..]; + if t.starts_with("/>") { + return Some(( + Tag { + name, + attrs, + self_closing: true, + }, + j + 2, + )); + } + if t.starts_with('>') { + return Some(( + Tag { + name, + attrs, + self_closing: false, + }, + j + 1, + )); + } + let attr_end = t.find(|c: char| c.is_whitespace() || matches!(c, '=' | '>' | '/'))?; + if attr_end == 0 { + return None; + } + let attr = &t[..attr_end]; + j += attr_end; + j += leading_ws(&s[j..]); + j += s[j..].strip_prefix('=').map(|_| 1)?; + j += leading_ws(&s[j..]); + let t = &s[j..]; + let quote = t.chars().next().filter(|q| matches!(q, '"' | '\''))?; + let close = t[1..].find(quote)?; + let raw = &t[1..1 + close]; + if raw.contains('<') { + return None; + } + attrs.push((attr, decode_entities(raw))); + j += 1 + close + 1; + } +} + +fn leading_ws(s: &str) -> usize { + s.len() - s.trim_start().len() +} + +/// Decode the five predefined XML entities and numeric character references; +/// anything else is kept literally (it then fails the later grammar checks +/// rather than being guessed at). +fn decode_entities(raw: &str) -> String { + if !raw.contains('&') { + return raw.to_string(); + } + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(amp) = rest.find('&') { + out.push_str(&rest[..amp]); + let tail = &rest[amp..]; + let decoded = tail.find(';').filter(|&semi| semi <= 10).and_then(|semi| { + let entity = &tail[1..semi]; + let ch = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }?; + Some((ch, semi + 1)) + }); + match decoded { + Some((ch, len)) => { + out.push(ch); + rest = &tail[len..]; + } + None => { + out.push('&'); + rest = &tail[1..]; + } + } + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + #[test] + fn entity_decoding_is_minimal_and_safe() { + assert_eq!(super::decode_entities("a&b<//"), "a&b Option { /// `// ── file selection ──` (stat / list only, never a content read), and /// `// ── registry view ──` (unrestricted) — so lockfile discovery can /// import the models without bypassing its recognizing ctx reads. The same -/// rule covers the other readers discovery imports: `vendor::maven_pom`, -/// `vendor::nuget_config`'s reader half, and the `// ── pure reader ──` +/// rule covers the other readers discovery imports: `formats::maven`, +/// `formats::nuget`, and the `// ── pure reader ──` /// regions of the writer-owned `go_mod_edit`, `go_sum_edit`, /// `cargo_config` and `cargo_manifest`. #[cfg(test)] @@ -469,7 +469,7 @@ mod architecture_tests { check(name, &text); } // Vendor-side readers lockfile discovery imports. - for rel in ["vendor/nuget_config.rs", "vendor/maven_pom.rs"] { + for rel in ["formats/nuget/mod.rs", "formats/maven/mod.rs"] { let text = std::fs::read_to_string(src.join(rel)).expect("read reader module"); check(rel, &text); } @@ -481,8 +481,8 @@ mod architecture_tests { "vendor/go_sum_edit.rs", "vendor/cargo_config.rs", "vendor/cargo_manifest.rs", - "vendor/nuget_config.rs", - "vendor/maven_pom.rs", + "formats/nuget/mod.rs", + "formats/maven/mod.rs", ] { let text = std::fs::read_to_string(src.join(rel)).expect("read reader module"); assert!( diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index b03537d33..46b663cb2 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -67,7 +67,6 @@ pub mod go_sum_edit; pub mod golang; pub(crate) mod ledger_snapshots; pub mod lock_inventory; -pub(crate) mod maven_pom; pub mod maven_repo; pub(crate) mod npm_common; pub(crate) mod npm_dir; diff --git a/crates/socket-patch-core/src/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 50c9583e7..21f58ae16 100644 --- a/crates/socket-patch-core/src/vendor/nuget_config.rs +++ b/crates/socket-patch-core/src/vendor/nuget_config.rs @@ -1,225 +1,5 @@ -//! NuGet config files: the routing reader, then the per-directory names -//! NuGet probes and a stat-only same-file check. -//! -//! The reader is a minimal, bounded XML tokenizer (no parser dependency) -//! that records only what NuGet routes by: `` directly under -//! `configuration/packageSources`, the mapping elements directly under -//! `configuration/packageSourceMapping`, and `disabledPackageSources` -//! entries whose `value` is `true`. Comments, CDATA, processing instructions -//! and DOCTYPEs are skipped; an unterminated tag or comment, an unquoted -//! attribute or a mismatched close tag makes the whole file `None`. - -use std::collections::BTreeSet; - -// ── pure reader ── - -/// Bound on element nesting — the config is tamper-able input (real configs -/// are three levels deep). -const MAX_XML_DEPTH: usize = 64; - -/// The parts of a `nuget.config` that route packages. -#[derive(Debug, Default)] -pub(crate) struct NugetConfig { - /// `configuration/packageSources/add` `(key, value)`, document order. - pub(crate) sources: Vec<(String, String)>, - /// `configuration/packageSourceMapping/packageSource` `(key, patterns)`, - /// one row per element, document order. - pub(crate) mappings: Vec<(String, Vec)>, - /// Keys `configuration/disabledPackageSources` turns off. - pub(crate) disabled: BTreeSet, -} - -/// One open (or self-closing) tag. -struct Tag<'a> { - name: &'a str, - attrs: Vec<(&'a str, String)>, - self_closing: bool, -} - -impl Tag<'_> { - fn attr(&self, name: &str) -> Option<&str> { - self.attrs - .iter() - .find(|(n, _)| *n == name) - .map(|(_, v)| v.as_str()) - } -} - -/// Parse the routing parts of `text`, or `None` when it is not well-formed -/// enough to trust (see the module docs). -pub(crate) fn parse_config(text: &str) -> Option { - let mut cfg = NugetConfig::default(); - let mut stack: Vec<&str> = Vec::new(); - // Index into `cfg.mappings` of the open `` element. - let mut open_mapping: Option = None; - let mut i = 0; - while let Some(rel) = text[i..].find('<') { - let at = i + rel; - let rest = &text[at..]; - if let Some(comment) = rest.strip_prefix("")? + 3; - } else if rest.starts_with("")? + 3; - } else if rest.starts_with("")? + 2; - } else if rest.starts_with("')? + 1; - } else if let Some(close) = rest.strip_prefix("')?; - if stack.pop()? != close[..end].trim() { - return None; - } - i = at + 2 + end + 1; - } else { - let (tag, consumed) = parse_open_tag(&rest[1..])?; - i = at + 1 + consumed; - visit(&stack, &tag, &mut cfg, &mut open_mapping); - if !tag.self_closing { - if stack.len() >= MAX_XML_DEPTH { - return None; - } - stack.push(tag.name); - } - } - } - stack.is_empty().then_some(cfg) -} - -/// Record `tag` if it sits where NuGet reads routing data. -fn visit(stack: &[&str], tag: &Tag<'_>, cfg: &mut NugetConfig, open_mapping: &mut Option) { - match (stack, tag.name) { - (["configuration", "packageSources"], "add") => { - if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { - cfg.sources.push((key.to_string(), value.to_string())); - } - } - (["configuration", "disabledPackageSources"], "add") => { - if let (Some(key), Some(value)) = (tag.attr("key"), tag.attr("value")) { - if value.trim().eq_ignore_ascii_case("true") { - cfg.disabled.insert(key.to_string()); - } - } - } - (["configuration", "packageSourceMapping"], "packageSource") => { - *open_mapping = match tag.attr("key") { - Some(key) => { - cfg.mappings.push((key.to_string(), Vec::new())); - (!tag.self_closing).then(|| cfg.mappings.len() - 1) - } - None => None, - }; - } - (["configuration", "packageSourceMapping", "packageSource"], "package") => { - if let (Some(idx), Some(pattern)) = (*open_mapping, tag.attr("pattern")) { - cfg.mappings[idx].1.push(pattern.trim().to_string()); - } - } - _ => {} - } -} - -/// Parse an open tag starting right after its `<`: `(tag, bytes consumed -/// through the closing `>`)`. Attribute values must be quoted (either XML -/// quote) and are entity-decoded. -fn parse_open_tag(s: &str) -> Option<(Tag<'_>, usize)> { - let name_end = s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')?; - let name = &s[..name_end]; - if name.is_empty() { - return None; - } - let mut attrs = Vec::new(); - let mut j = name_end; - loop { - j += leading_ws(&s[j..]); - let t = &s[j..]; - if t.starts_with("/>") { - return Some(( - Tag { - name, - attrs, - self_closing: true, - }, - j + 2, - )); - } - if t.starts_with('>') { - return Some(( - Tag { - name, - attrs, - self_closing: false, - }, - j + 1, - )); - } - let attr_end = t.find(|c: char| c.is_whitespace() || matches!(c, '=' | '>' | '/'))?; - if attr_end == 0 { - return None; - } - let attr = &t[..attr_end]; - j += attr_end; - j += leading_ws(&s[j..]); - j += s[j..].strip_prefix('=').map(|_| 1)?; - j += leading_ws(&s[j..]); - let t = &s[j..]; - let quote = t.chars().next().filter(|q| matches!(q, '"' | '\''))?; - let close = t[1..].find(quote)?; - let raw = &t[1..1 + close]; - if raw.contains('<') { - return None; - } - attrs.push((attr, decode_entities(raw))); - j += 1 + close + 1; - } -} - -fn leading_ws(s: &str) -> usize { - s.len() - s.trim_start().len() -} - -/// Decode the five predefined XML entities and numeric character references; -/// anything else is kept literally (it then fails the later grammar checks -/// rather than being guessed at). -fn decode_entities(raw: &str) -> String { - if !raw.contains('&') { - return raw.to_string(); - } - let mut out = String::with_capacity(raw.len()); - let mut rest = raw; - while let Some(amp) = rest.find('&') { - out.push_str(&rest[..amp]); - let tail = &rest[amp..]; - let decoded = tail.find(';').filter(|&semi| semi <= 10).and_then(|semi| { - let entity = &tail[1..semi]; - let ch = match entity { - "amp" => Some('&'), - "lt" => Some('<'), - "gt" => Some('>'), - "quot" => Some('"'), - "apos" => Some('\''), - _ => entity - .strip_prefix("#x") - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) - .and_then(char::from_u32), - }?; - Some((ch, semi + 1)) - }); - match decoded { - Some((ch, len)) => { - out.push(ch); - rest = &tail[len..]; - } - None => { - out.push('&'); - rest = &tail[1..]; - } - } - } - out.push_str(rest); - out -} +//! NuGet config file selection: the per-directory names NuGet probes and a +//! stat-only same-file check (the routing reader is `formats::nuget`). // ── file selection ── @@ -242,13 +22,3 @@ pub(crate) async fn same_file(a: &std::path::Path, b: &std::path::Path) -> bool let _ = (a, b); false } - -#[cfg(test)] -mod tests { - #[test] - fn entity_decoding_is_minimal_and_safe() { - assert_eq!(super::decode_entities("a&b<//"), "a&b Date: Mon, 28 Sep 2026 01:53:06 +0000 Subject: [PATCH 13/13] Splice Cargo.lock at the spans of its one parse CargoLock::parse now reads the lock with toml_edit's spanned Document and records each [[package]]'s header, version/source/checksum value spans and string values, every table header, the [root] strings and the [metadata] lines. The hosted planner (CargoLock::plan_hosted) splices at those spans, and the rewriter's is_locked / locked_versions probes answer from the same parse, so the separate `[[package]]\nname = ...` text grammar, its regexes and block walkers are gone. The previous line-grammar planner is kept test-only as the oracle: randomized v1 and v3/v4 locks (plus twins, [root], sourceless v1, bare blocks, 1.2k-block locks) plan to identical bytes and FileEdits for every package, a re-run, and a second package over the result. The one allowed difference is a lock the old grammar could not read (a final block with no newline after `version`), which the span planner now finds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --- .../src/formats/cargo/hosted.rs | 512 ++++++++---------- .../src/formats/cargo/mod.rs | 220 +++++++- .../redirect/cargo_lock_equivalence_tests.rs | 427 +++++++++++++-- .../src/patch/redirect/mod.rs | 25 +- 4 files changed, 819 insertions(+), 365 deletions(-) diff --git a/crates/socket-patch-core/src/formats/cargo/hosted.rs b/crates/socket-patch-core/src/formats/cargo/hosted.rs index 0c8fbc382..4545b4013 100644 --- a/crates/socket-patch-core/src/formats/cargo/hosted.rs +++ b/crates/socket-patch-core/src/formats/cargo/hosted.rs @@ -1,316 +1,264 @@ //! The hosted planner's `Cargo.lock` leg: repoint a crate's `[[package]]` //! block (and a v1 lock's `[metadata]` checksum and full-id references) at -//! the Socket-hosted index, as text splices over cargo's own lock layout -//! (`[[package]]\nname = "…"\nversion = "…"\n`). The line grammar here is -//! the one the hosted rewriter's lock probes ([`is_locked`], -//! [`locked_versions`]) read with, so a probe and the splice always agree -//! on which blocks exist. +//! the Socket-hosted index, as splices at the byte spans [`CargoLock::parse`] +//! recorded — the one parse the rewriter's lock probes +//! ([`CargoLock::is_locked`], [`CargoLock::locked_versions`]) and the +//! dependents check read, so a probe and the splice always agree on which +//! blocks exist. Everything outside the spliced values keeps its bytes. -use std::sync::LazyLock; +use std::ops::Range; -use regex::Regex; use serde_json::Value; use crate::patch::redirect::FileEdit; -/// The line-anchored header cargo writes for `name`@`version`. -fn package_head(name: &str, version: &str) -> String { - format!("[[package]]\nname = \"{name}\"\nversion = \"{version}\"\n") -} - -/// Every offset of `needle` in `content` that starts a line. -fn line_anchored<'c>(content: &'c str, needle: &'c str) -> impl Iterator + 'c { - content - .match_indices(needle) - .map(|(at, _)| at) - .filter(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n') -} - -/// Whether the lock holds a `[[package]]` block for `name`@`version`. -pub(crate) fn is_locked(lock: &str, name: &str, version: &str) -> bool { - line_anchored(lock, &package_head(name, version)) - .next() - .is_some() -} - -/// Every version of `name` the lock holds a `[[package]]` block for, sorted -/// and deduplicated. -pub(crate) fn locked_versions(lock: &str, name: &str) -> Vec { - let head = format!("[[package]]\nname = \"{name}\"\nversion = \""); - let mut versions: Vec = line_anchored(lock, &head) - .filter_map(|at| { - let rest = &lock[at + head.len()..]; - rest.split_once('"').map(|(v, _)| v.to_string()) - }) - .collect(); - versions.sort(); - versions.dedup(); - versions -} +use super::CargoLock; /// The Cargo.lock edit kind for dependents' full-id references: `original` /// / `new` are the quoted `" ()"` ids, keyed /// `@`, and the inverse replaces EVERY occurrence of `new`. pub(crate) const CARGO_LOCK_REFERENCE_KIND: &str = "redirect_cargo_lock_reference"; -static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") -}); -static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") -}); -// `$` (not `\n`) so it also anchors a source line that ENDS the block: the -// trailing newline sits outside the block region. -static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)^(source = "[^"]*")$"#).expect("static source-line anchor regex is valid") -}); +/// Apply non-overlapping `splices` (ranges into `text`) to `text`. +fn splice(text: &str, mut splices: Vec<(Range, String)>) -> String { + splices.sort_by_key(|(r, _)| std::cmp::Reverse(r.start)); + let mut out = text.to_string(); + for (range, with) in splices { + out.replace_range(range, &with); + } + out +} -/// Repoint the crate's `[[package]]` at the hosted index with the patched -/// `.crate`'s checksum, in whichever Cargo.lock format the file is: -/// -/// * v2–v4: `source` + an inline `checksum` in the entry; -/// * v1 (cargo < 1.41, still read by every cargo): the entry carries only -/// `source`; the checksum lives in the trailing `[metadata]` table under -/// `"checksum ()"`, and every dependent names the -/// crate by its FULL package id `" ()"`. Both are -/// keyed by the source, so both must follow it — a v1 lock with only the -/// entry repointed names a package that no longer exists (cargo discards -/// the lock and re-resolves; `--locked` fails) and pins nothing. -/// -/// Full-id references are rewritten in any format (v2+ spells them that way -/// when a name + version is ambiguous). Each changed fragment is its own -/// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is -/// unambiguous) — the entry and the `[metadata]` line — and the dependents' -/// references are one `redirect_cargo_lock_reference` edit holding the -/// quoted full id, reverted at every occurrence. -pub(crate) fn plan_cargo_lock( - content: &str, - crate_name: &str, - version: &str, - index_url: &str, - cksum: &str, -) -> CargoLockPlan { - // Rust's regex has NO lookahead, so bound the [[package]] block by string - // search (see [`lock_block_end`]): from its header to the next block or - // trailing table (or EOF), so the bytes after the block (incl. the final - // newline) are preserved. - let head = package_head(crate_name, version); - // Every line-anchored header for this name@version. A Cargo.lock may - // legitimately hold TWO blocks for one name@version from different - // sources — after a redirect, a transitive crates.io copy resolves beside - // the socket-registry copy, and cargo sorts the crates.io block FIRST — - // so the first hit alone would repoint the wrong twin. - let heads: Vec = line_anchored(content, &head).collect(); - let block_start = match heads.as_slice() { - [] => return CargoLockPlan::NotFound, - [only] => *only, - twins => { - // Exactly one twin already at the target index is OURS (a re-run - // over a redirected lock); anything else cannot be attributed and - // the dep is skipped transactionally. - let target_source = format!("source = \"{index_url}\""); - let mut ours = twins.iter().copied().filter(|&at| { - let body_start = at + head.len(); - content[body_start..lock_block_end(content, body_start)] - .lines() - .any(|line| line == target_source) - }); - match (ours.next(), ours.next()) { - (Some(at), None) => at, - _ => return CargoLockPlan::Ambiguous, - } - } - }; - let body_start = block_start + head.len(); - let block_end = lock_block_end(content, body_start); - let original = content[block_start..block_end].to_string(); - let mut body = content[body_start..block_end].to_string(); - let old_source = CARGO_LOCK_SOURCE_LINE_RE - .captures(&body) - .map(|c| c[1].to_string()); - if old_source.is_some() { - body = CARGO_LOCK_SOURCE_LINE_RE - .replace(&body, format!("source = \"{index_url}\"").as_str()) - .to_string(); - } else { - body = format!("source = \"{index_url}\"\n{body}"); +/// Where a line inserted after the one `value` ends sits: just past that +/// line's newline, or at `end` (the block's last byte) with the newline to +/// prepend when the line ends the text. +fn after_line(content: &str, value: &Range, end: usize) -> (usize, bool) { + match content[value.end..end].find('\n') { + Some(n) => (value.end + n + 1, false), + None => (end, true), } - // A v1 lock keeps the checksum in `[metadata]`, keyed by the package id - // — the chosen block's OWN source when it has one, so a multi-source - // twin's line is never taken for ours. - let metadata_source = old_source - .as_deref() - .map_or_else(|| r#"[^)"]*"#.to_string(), regex::escape); - let metadata_re = Regex::new(&format!( - r#"(?m)^"checksum {} {} \({metadata_source}\)" = "[^"]*"$"#, - regex::escape(crate_name), - regex::escape(version) - )) - .expect("escaped lock metadata-line regex is valid"); - let metadata_line = metadata_re.find(content).map(|m| m.as_str().to_string()); - if metadata_line.is_none() { - if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { - body = CARGO_LOCK_CHECKSUM_LINE_RE - .replace(&body, format!("checksum = \"{cksum}\"").as_str()) - .to_string(); +} + +impl CargoLock { + /// End of the `[[package]]` block headed at `header`: the next table + /// header (another block, `[metadata]`, `[[patch.unused]]`, a + /// `[patch.*]` table) or EOF, excluding the newline(s) before it — so a + /// recorded original/new stops after the block's last content byte (the + /// TS rewriter's `(?=\n*$)` lookahead) while the file keeps its + /// newlines. Never before the end of the `version` line (`version`: its + /// value span): a block of just its identity keeps that line's newline. + fn block_end(&self, content: &str, header: usize, version: &Range) -> usize { + let headers = self.spans().map_or(&[][..], |s| &s.headers); + let mut end = headers + .iter() + .copied() + .find(|&h| h > header) + .unwrap_or(content.len()); + let floor = if content[version.end..end].starts_with('\n') { + version.end + 1 } else { - body = CARGO_LOCK_AFTER_SOURCE_RE - .replace(&body, format!("${{1}}\nchecksum = \"{cksum}\"").as_str()) - .to_string(); + version.end + }; + while end > floor && content.as_bytes()[end - 1] == b'\n' { + end -= 1; } + end } - let rebuilt = format!("{head}{body}"); - let key = format!("{crate_name}@{version}"); - let edit = |original: &str, new: &str| FileEdit { - path: "Cargo.lock".into(), - kind: "redirect_cargo_lock_entry".into(), - action: "rewritten".into(), - key: Some(key.clone()), - original: Some(Value::String(original.to_string())), - new: Some(Value::String(new.to_string())), - }; - let mut edits = Vec::new(); - let mut new_content = content.to_string(); - if rebuilt != original { - new_content.replace_range(block_start..block_end, &rebuilt); - edits.push(edit(&original, &rebuilt)); - } - if let Some(line) = metadata_line { - let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); - if line != pinned { - new_content = new_content.replacen(&line, &pinned, 1); - edits.push(edit(&line, &pinned)); + + /// Repoint the crate's `[[package]]` at the hosted index with the + /// patched `.crate`'s checksum, in whichever Cargo.lock format the file + /// is (`content`: the text this model was parsed from): + /// + /// * v2–v4: `source` + an inline `checksum` in the entry; + /// * v1 (cargo < 1.41, still read by every cargo): the entry carries only + /// `source`; the checksum lives in the trailing `[metadata]` table under + /// `"checksum ()"`, and every dependent names + /// the crate by its FULL package id `" ()"`. + /// Both are keyed by the source, so both must follow it — a v1 lock + /// with only the entry repointed names a package that no longer exists + /// (cargo discards the lock and re-resolves; `--locked` fails) and pins + /// nothing. + /// + /// Full-id references are rewritten in any format (v2+ spells them that + /// way when a name + version is ambiguous). Each changed fragment is its + /// own `redirect_cargo_lock_entry` edit (unique text, so the fragment + /// revert is unambiguous) — the entry and the `[metadata]` line — and the + /// dependents' references are one `redirect_cargo_lock_reference` edit + /// holding the quoted full id, reverted at every occurrence. + pub(crate) fn plan_hosted( + &self, + content: &str, + crate_name: &str, + version: &str, + index_url: &str, + cksum: &str, + ) -> CargoLockPlan { + let Some(spans) = self.spans() else { + return CargoLockPlan::NotFound; + }; + // Every block for this name@version. A Cargo.lock may legitimately + // hold TWO blocks for one name@version from different sources — after + // a redirect, a transitive crates.io copy resolves beside the + // socket-registry copy, and cargo sorts the crates.io block FIRST — + // so the first hit alone would repoint the wrong twin. + let hits: Vec = (0..self.packages.len()) + .filter(|&i| self.packages[i].name == crate_name && self.packages[i].version == version) + .collect(); + let i = match hits.as_slice() { + [] => return CargoLockPlan::NotFound, + [only] => *only, + twins => { + // Exactly one twin already at the target index is OURS (a + // re-run over a redirected lock); anything else cannot be + // attributed and the dep is skipped transactionally. + let mut ours = twins + .iter() + .copied() + .filter(|&i| self.packages[i].source.as_deref() == Some(index_url)); + match (ours.next(), ours.next()) { + (Some(i), None) => i, + _ => return CargoLockPlan::Ambiguous, + } + } + }; + let pkg = &self.packages[i]; + let at = &spans.packages[i]; + let block_start = at.header; + let block_end = self.block_end(content, block_start, &at.version); + let original = &content[block_start..block_end]; + let old_source = pkg.source.as_deref(); + + // A v1 lock keeps the checksum in `[metadata]`, keyed by the package + // id — the chosen block's OWN source when it has one, so a + // multi-source twin's line is never taken for ours. + let metadata_line = spans.metadata.iter().find(|(key, _)| match old_source { + Some(source) => *key == super::metadata_checksum_key(crate_name, version, source), + None => key + .strip_prefix(&format!("checksum {crate_name} {version} (")) + .and_then(|rest| rest.strip_suffix(')')) + .is_some_and(|source| !source.contains([')', '"'])), + }); + + // The block's own splices, relative to `block_start`. + let rel = |r: &Range| (r.start - block_start)..(r.end - block_start); + let quoted_index = format!("\"{index_url}\""); + let checksum_line = format!("checksum = \"{cksum}\""); + let mut block_splices = Vec::new(); + match &at.source { + Some(source) => { + block_splices.push((rel(source), quoted_index.clone())); + if metadata_line.is_none() { + match &at.checksum { + Some(checksum) => { + block_splices.push((rel(checksum), format!("\"{cksum}\""))); + } + None => { + let end = source.end - block_start; + block_splices.push((end..end, format!("\n{checksum_line}"))); + } + } + } + } + None => { + let mut lines = format!("source = {quoted_index}"); + if metadata_line.is_none() { + match &at.checksum { + Some(checksum) => { + block_splices.push((rel(checksum), format!("\"{cksum}\""))); + } + None => { + lines.push('\n'); + lines.push_str(&checksum_line); + } + } + } + let (insert, prepend) = after_line(content, &at.version, block_end); + let insert = insert - block_start; + let text = if prepend { + format!("\n{lines}") + } else { + format!("{lines}\n") + }; + block_splices.push((insert..insert, text)); + } } - } - // Dependents' full-id references to the OLD source, recorded as ONE - // `redirect_cargo_lock_reference` edit holding just the quoted id — - // never a dependent's whole block: a block referencing two patched - // packages (the root of a v1 lock) would hold two overlapping block - // edits, and reverting the first-applied one alone would find neither of - // its fragments. The id names this name + version + source exactly, so its - // inverse puts back EVERY occurrence, independently of any other - // package's edits and in any removal order. - if let Some(old) = old_source.filter(|old| old != index_url) { - let from = format!("\"{crate_name} {version} ({old})\""); - let to = format!("\"{crate_name} {version} ({index_url})\""); - let mut repointed_any = false; - // The oldest v1 locks keep the ROOT package in a standalone `[root]` - // table instead of the `[[package]]` array, with its own full-id - // `dependencies`. It precedes the array, so the block walk below - // never reaches it and the lock would keep naming a package it no - // longer contains (`--locked` fails; an unlocked build silently - // re-resolves). - if let Some((start, end)) = lock_root_table(&new_content) { - if new_content[start..end].contains(&from) { - let repointed = new_content[start..end].replace(&from, &to); - new_content.replace_range(start..end, &repointed); - repointed_any = true; + let rebuilt = splice(original, block_splices); + + let key = format!("{crate_name}@{version}"); + let edit = |original: &str, new: &str| FileEdit { + path: "Cargo.lock".into(), + kind: "redirect_cargo_lock_entry".into(), + action: "rewritten".into(), + key: Some(key.clone()), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(new.to_string())), + }; + let mut edits = Vec::new(); + let mut splices = Vec::new(); + if rebuilt != original { + edits.push(edit(original, &rebuilt)); + splices.push((block_start..block_end, rebuilt)); + } + if let Some((_, line)) = metadata_line { + let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); + if content[line.clone()] != pinned { + edits.push(edit(&content[line.clone()], &pinned)); + splices.push((line.clone(), pinned)); } } - let mut cursor = 0; - while let Some((start, end)) = next_lock_block(&new_content, cursor) { - if new_content[start..end].contains(&from) { - let repointed = new_content[start..end].replace(&from, &to); - new_content.replace_range(start..end, &repointed); - repointed_any = true; - cursor = start + repointed.len(); - } else { - cursor = end; + // Dependents' full-id references to the OLD source, recorded as ONE + // `redirect_cargo_lock_reference` edit holding just the quoted id — + // never a dependent's whole block: a block referencing two patched + // packages (the root of a v1 lock) would hold two overlapping block + // edits, and reverting the first-applied one alone would find neither + // of its fragments. The id names this name + version + source exactly, + // so its inverse puts back EVERY occurrence, independently of any + // other package's edits and in any removal order. The oldest v1 locks + // keep the ROOT package in a standalone `[root]` table with its own + // full-id `dependencies`; those follow too, or the lock would keep + // naming a package it no longer contains (`--locked` fails; an + // unlocked build silently re-resolves). + if let Some(old) = old_source.filter(|old| *old != index_url) { + let from = format!("\"{crate_name} {version} ({old})\""); + let to = format!("\"{crate_name} {version} ({index_url})\""); + let others = spans + .packages + .iter() + .enumerate() + .filter(|&(j, _)| j != i) + .flat_map(|(_, p)| &p.strings); + let refs: Vec> = spans + .root_strings + .iter() + .chain(others) + .filter(|r| content[(*r).clone()] == from) + .cloned() + .collect(); + if !refs.is_empty() { + splices.extend(refs.into_iter().map(|r| (r, to.clone()))); + edits.push(FileEdit { + kind: CARGO_LOCK_REFERENCE_KIND.into(), + ..edit(&from, &to) + }); } } - if repointed_any { - edits.push(FileEdit { - kind: CARGO_LOCK_REFERENCE_KIND.into(), - ..edit(&from, &to) - }); + // Already redirected (re-run): every fragment is at the target values; + // a recorded edit would have original == new and grow the ledger + // forever. + if edits.is_empty() { + return CargoLockPlan::AlreadyRedirected; + } + CargoLockPlan::Rewritten { + content: splice(content, splices), + edits, } } - // Already redirected (re-run): every fragment is at the target values; a - // recorded edit would have original == new and grow the ledger forever. - if edits.is_empty() { - return CargoLockPlan::AlreadyRedirected; - } - CargoLockPlan::Rewritten { - content: new_content, - edits, - } -} - -/// The v1 `[root]` table's span, when the lock has one: cargo before the -/// `[root]` removal recorded the root package there rather than in the -/// `[[package]]` array, and its `dependencies` spell full package ids the -/// same way. Bounded by [`lock_block_end`], like a package block. -fn lock_root_table(content: &str) -> Option<(usize, usize)> { - const HEADER: &str = "[root]\n"; - let at = content - .match_indices(HEADER) - .map(|(at, _)| at) - .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; - Some((at, lock_block_end(content, at + HEADER.len()))) -} - -/// The next `[[package]]` block starting at or after `from`, as -/// [`lock_block_end`] bounds it. -pub(crate) fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { - let rel = content.get(from..)?.find("[[package]]\n")?; - let start = from + rel; - if start != 0 && content.as_bytes()[start - 1] != b'\n' { - return next_lock_block(content, start + 1); - } - Some(( - start, - lock_block_end(content, start + "[[package]]\n".len()), - )) -} - -/// End of the `[[package]]` block whose body starts at `body_start`, -/// excluding the newline(s) before the next block / trailing table / EOF (so -/// a recorded original/new stops after the block's last content byte — the -/// TS rewriter's `(?=\n*$)` lookahead — while the file keeps its newlines). -pub(crate) fn lock_block_end(content: &str, body_start: usize) -> usize { - // The next block, or the `[metadata]` / `[[patch.unused]]` tables that - // trail the packages. The trailing tables are searched only up to the - // next block: they sit after every `[[package]]` (absent entirely from - // v3/v4 locks), and an unbounded search per block scanned to EOF for - // every block of every dep. Each marker holds its only `\n` at offset 0, - // so a hit starting before the next block also ends by it — the bounded - // minimum is the unbounded one. - let rest = &content[body_start..]; - let next_block = rest.find("\n[[package]]").unwrap_or(rest.len()); - let mut end = ["\n[metadata]", "\n[[patch.unused]]", "\n[patch"] - .iter() - .filter_map(|marker| rest[..next_block].find(marker)) - .min() - .map_or(body_start + next_block, |rel| body_start + rel); - while end > body_start && content.as_bytes()[end - 1] == b'\n' { - end -= 1; - } - end -} - -/// The previous, unbounded [`lock_block_end`], kept as the equivalence -/// oracle. -#[cfg(test)] -pub(crate) fn lock_block_end_unbounded(content: &str, body_start: usize) -> usize { - let mut end = [ - "\n[[package]]", - "\n[metadata]", - "\n[[patch.unused]]", - "\n[patch", - ] - .iter() - .filter_map(|marker| content[body_start..].find(marker)) - .min() - .map_or(content.len(), |rel| body_start + rel); - while end > body_start && content.as_bytes()[end - 1] == b'\n' { - end -= 1; - } - end } /// Outcome of the Cargo.lock `[[package]]` plan — distinguishes a re-run /// over an already-redirected block (no edit, no warning) from a genuinely /// missing package (the caller warns AND skips the dep entirely). +#[derive(Debug)] pub(crate) enum CargoLockPlan { Rewritten { content: String, diff --git a/crates/socket-patch-core/src/formats/cargo/mod.rs b/crates/socket-patch-core/src/formats/cargo/mod.rs index 82ea1101d..58b4dc2bc 100644 --- a/crates/socket-patch-core/src/formats/cargo/mod.rs +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -14,8 +14,10 @@ //! crate (the hosted planner's unpinnable-dependents refusal); //! * [`CargoLock::vendored_in_use`] — whether the lock builds a vendored //! `[patch]` copy ([`CopyClaim`]); -//! * [`hosted::plan_cargo_lock`] — the hosted planner's lock splice, and the -//! line-grammar probes the hosted rewriter reads with; +//! * [`CargoLock::plan_hosted`] ([`hosted`]) — the hosted planner's lock +//! splice, at the byte spans [`CargoLock::parse`] records, and the +//! [`CargoLock::is_locked`] / [`CargoLock::locked_versions`] probes the +//! hosted rewriter reads with; //! * the vendored planner (`vendor::cargo_lock`) edits the same document //! with `toml_edit`; //! @@ -23,7 +25,9 @@ pub(crate) mod hosted; -use toml_edit::{DocumentMut, Item}; +use std::ops::Range; + +use toml_edit::{Document, DocumentMut, Item, Table, Value}; use crate::utils::digest::is_hex; use crate::utils::purl::simple_purl; @@ -61,12 +65,38 @@ pub(crate) struct LockedPackage { /// entry without a string `name` and `version` is skipped. A lock with no /// packages has no `package` key and yields nothing. pub(crate) fn locked_packages(doc: &DocumentMut) -> Vec { - let metadata = doc.get("metadata").and_then(Item::as_table_like); + read_packages(doc.as_table(), false) + .into_iter() + .map(|(pkg, _)| pkg) + .collect() +} + +/// Where one `[[package]]`'s pieces sit in the lock text it was parsed +/// from — what the hosted splice ([`hosted`]) edits, so it rewrites exactly +/// the bytes the read model read. Every range is a TOML value's own span +/// (quotes included). +#[derive(Debug, Clone, Default)] +pub(crate) struct PackageSpans { + /// Offset of the block's `[[package]]` header. + pub(crate) header: usize, + pub(crate) version: Range, + pub(crate) source: Option>, + /// The inline (v2+) `checksum`. + pub(crate) checksum: Option>, + /// Every string value in the block (`dependencies`, a v1 `replace`): + /// where a full package id `"name version (source)"` can be spelled. + pub(crate) strings: Vec>, +} + +/// [`locked_packages`] over any root table, with each package's +/// [`PackageSpans`] when the table was parsed with spans (`spanned`). +fn read_packages(root: &Table, spanned: bool) -> Vec<(LockedPackage, Option)> { + let metadata = root.get("metadata").and_then(Item::as_table_like); let metadata_checksum = |name: &str, version: &str, source: Option<&str>| { let key = metadata_checksum_key(name, version, source?); metadata?.get(&key)?.as_str().map(str::to_string) }; - doc.get("package") + root.get("package") .and_then(Item::as_array_of_tables) .map(|pkgs| { pkgs.iter() @@ -88,24 +118,103 @@ pub(crate) fn locked_packages(doc: &DocumentMut) -> Vec { .collect() }) .unwrap_or_default(); - Some(LockedPackage { - name, - version, - source, - checksum, - dependencies, - }) + let spans = if spanned { package_spans(t) } else { None }; + Some(( + LockedPackage { + name, + version, + source, + checksum, + dependencies, + }, + spans, + )) }) .collect() }) .unwrap_or_default() } +/// The [`PackageSpans`] of a spanned `[[package]]` table. +fn package_spans(t: &Table) -> Option { + let value_span = |key: &str| t.get(key).and_then(Item::as_value).and_then(Value::span); + let mut strings = Vec::new(); + string_spans(t, &mut strings); + Some(PackageSpans { + header: t.span()?.start, + version: value_span("version")?, + source: value_span("source"), + checksum: value_span("checksum"), + strings, + }) +} + +/// The span of every string value under `t`, arrays and inline tables +/// included. +fn string_spans(t: &Table, out: &mut Vec>) { + fn value(v: &Value, out: &mut Vec>) { + match v { + Value::String(_) => out.extend(v.span()), + Value::Array(a) => a.iter().for_each(|v| value(v, out)), + Value::InlineTable(t) => t.iter().for_each(|(_, v)| value(v, out)), + _ => {} + } + } + for (_, item) in t.iter() { + match item { + Item::Value(v) => value(v, out), + Item::Table(t) => string_spans(t, out), + _ => {} + } + } +} + +/// The start of every table header (`[x]` / `[[x]]`) in a spanned lock, +/// sorted: what bounds a `[[package]]` block. Implicit tables (a dotted +/// `[patch.crates-io]`'s `patch`) have no header of their own. +fn header_starts(root: &Table, text: &str) -> Vec { + fn walk(t: &Table, text: &str, out: &mut Vec) { + for (_, item) in t.iter() { + let tables: Vec<&Table> = match item { + Item::Table(t) => vec![t], + Item::ArrayOfTables(a) => a.iter().collect(), + _ => continue, + }; + for t in tables { + if let Some(span) = t.span().filter(|s| text[s.clone()].starts_with('[')) { + out.push(span.start); + } + walk(t, text, out); + } + } + } + let mut out = Vec::new(); + walk(root, text, &mut out); + out.sort_unstable(); + out +} + +/// The spanned lock's `[metadata]` entries: each key as read, and the +/// span of its whole `"key" = "value"` line. +fn metadata_lines(root: &Table) -> Vec<(String, Range)> { + let Some(metadata) = root.get("metadata").and_then(Item::as_table) else { + return Vec::new(); + }; + metadata + .iter() + .filter_map(|(key, item)| { + let start = metadata.key(key)?.span()?.start; + let end = item.as_value()?.span()?.end; + Some((key.to_string(), start..end)) + }) + .collect() +} + /// `(name, version)` of every `[[patch.unused]]` entry: a `[patch]` cargo /// resolved and then did NOT use in the crate graph — the lock's own record /// that a patch (e.g. a vendored copy) is not what builds. -pub(crate) fn unused_patches(doc: &DocumentMut) -> Vec<(String, String)> { - doc.get("patch") +pub(crate) fn unused_patches(root: &Table) -> Vec<(String, String)> { + root.get("patch") .and_then(|patch| patch.get("unused")) .and_then(Item::as_array_of_tables) .map(|entries| { @@ -231,21 +340,94 @@ pub(crate) fn parse_ref(spelled: &str) -> (&str, Option<&str>, Option<&str>) { pub struct CargoLock { packages: Vec, unused: Vec<(String, String)>, + /// Present when parsed from text ([`CargoLock::parse`]): where each + /// package, table header and `[metadata]` line sits in it. + spans: Option, +} + +/// The byte spans of a [`CargoLock`] parsed from text. +#[derive(Debug)] +pub(crate) struct LockSpans { + /// Parallel to [`CargoLock::packages`]. + pub(crate) packages: Vec, + /// Every table header's start, sorted. + pub(crate) headers: Vec, + /// The string values of a v1 lock's standalone `[root]` table. + pub(crate) root_strings: Vec>, + /// `[metadata]` keys and their line spans. + pub(crate) metadata: Vec<(String, Range)>, } impl CargoLock { - /// The model of an already-parsed lock document. + /// The model of an already-parsed lock document (no spans: a + /// `DocumentMut` keeps none). pub fn from_doc(doc: &DocumentMut) -> Self { CargoLock { packages: locked_packages(doc), - unused: unused_patches(doc), + unused: unused_patches(doc.as_table()), + spans: None, } } - /// Parse a lock text; `Err` when it is not TOML (cargo itself refuses - /// to build from it). + /// Parse a lock text, keeping every span the hosted splice edits; + /// `Err` when it is not TOML (cargo itself refuses to build from it). pub fn parse(text: &str) -> Result { - Ok(Self::from_doc(&text.parse::()?)) + let doc = Document::parse(text)?; + let root = doc.as_table(); + let mut packages = Vec::new(); + let mut spans = Vec::new(); + let mut spanned = true; + for (pkg, span) in read_packages(root, true) { + packages.push(pkg); + match span { + Some(span) => spans.push(span), + None => spanned = false, + } + } + let root_strings = root + .get("root") + .and_then(Item::as_table) + .map(|t| { + let mut out = Vec::new(); + string_spans(t, &mut out); + out + }) + .unwrap_or_default(); + Ok(CargoLock { + unused: unused_patches(root), + spans: spanned.then(|| LockSpans { + packages: spans, + headers: header_starts(root, text), + root_strings, + metadata: metadata_lines(root), + }), + packages, + }) + } + + /// The spans, when parsed from text. + pub(crate) fn spans(&self) -> Option<&LockSpans> { + self.spans.as_ref() + } + + /// Whether the lock holds a `[[package]]` for `name`@`version`. + pub(crate) fn is_locked(&self, name: &str, version: &str) -> bool { + self.packages + .iter() + .any(|p| p.name == name && p.version == version) + } + + /// Every version of `name` the lock holds, sorted and deduplicated. + pub(crate) fn locked_versions(&self, name: &str) -> Vec { + let mut versions: Vec = self + .packages + .iter() + .filter(|p| p.name == name) + .map(|p| p.version.clone()) + .collect(); + versions.sort(); + versions.dedup(); + versions } /// Every `[[package]]`, in lock order. diff --git a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs index 0c5d76e2b..570ee7621 100644 --- a/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs @@ -1,11 +1,274 @@ -//! Equivalence oracle for the Cargo.lock block bound, which now searches the -//! trailing-table markers only up to the next `[[package]]` instead of to -//! EOF. The previous implementation is kept as -//! [`lock_block_end_unbounded`]; every consumer (`plan_cargo_lock`, -//! `next_lock_block`) is a function of the bound alone, so equality at every -//! body offset of every lock shape is equality of the rewriter. +//! Equivalence oracle for the Cargo.lock hosted splice, which now edits the +//! byte spans of the lock's one parse ([`CargoLock::parse`] → +//! [`CargoLock::plan_hosted`]) instead of searching the text for cargo's +//! canonical `[[package]]\nname = …\nversion = …\n` header. The previous +//! line-grammar planner is kept below, test-only, as the oracle: over every +//! canonical lock shape (v1 `[metadata]` + full-id references, v3/v4 inline +//! checksums, multi-source twins, `[root]`, trailers, a missing final +//! newline) both must produce identical bytes and identical `FileEdit`s, +//! for every package of every lock and a re-run over the result. (Locks the +//! old grammar could not read — a comment or reordered key inside a block — +//! are where the two differ by design: the span planner finds them.) + +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::Value; + +use crate::formats::cargo::hosted::{CargoLockPlan, CARGO_LOCK_REFERENCE_KIND}; +use crate::formats::cargo::CargoLock; +use crate::patch::redirect::FileEdit; + +// ── the oracle: the previous line-grammar planner, verbatim ── + +/// The line-anchored header cargo writes for `name`@`version`. +fn package_head(name: &str, version: &str) -> String { + format!("[[package]]\nname = \"{name}\"\nversion = \"{version}\"\n") +} + +/// Every offset of `needle` in `content` that starts a line. +fn line_anchored<'c>(content: &'c str, needle: &'c str) -> impl Iterator + 'c { + content + .match_indices(needle) + .map(|(at, _)| at) + .filter(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n') +} + +static CARGO_LOCK_SOURCE_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^source = "([^"]*)"$"#).expect("static lock source-line regex is valid") +}); +static CARGO_LOCK_CHECKSUM_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^checksum = "[^"]*"$"#).expect("static lock checksum-line regex is valid") +}); +// `$` (not `\n`) so it also anchors a source line that ENDS the block: the +// trailing newline sits outside the block region. +static CARGO_LOCK_AFTER_SOURCE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?m)^(source = "[^"]*")$"#).expect("static source-line anchor regex is valid") +}); + +/// Repoint the crate's `[[package]]` at the hosted index with the patched +/// `.crate`'s checksum, in whichever Cargo.lock format the file is: +/// +/// * v2–v4: `source` + an inline `checksum` in the entry; +/// * v1 (cargo < 1.41, still read by every cargo): the entry carries only +/// `source`; the checksum lives in the trailing `[metadata]` table under +/// `"checksum ()"`, and every dependent names the +/// crate by its FULL package id `" ()"`. Both are +/// keyed by the source, so both must follow it — a v1 lock with only the +/// entry repointed names a package that no longer exists (cargo discards +/// the lock and re-resolves; `--locked` fails) and pins nothing. +/// +/// Full-id references are rewritten in any format (v2+ spells them that way +/// when a name + version is ambiguous). Each changed fragment is its own +/// `redirect_cargo_lock_entry` edit (unique text, so the fragment revert is +/// unambiguous) — the entry and the `[metadata]` line — and the dependents' +/// references are one `redirect_cargo_lock_reference` edit holding the +/// quoted full id, reverted at every occurrence. +fn plan_cargo_lock( + content: &str, + crate_name: &str, + version: &str, + index_url: &str, + cksum: &str, +) -> CargoLockPlan { + // Rust's regex has NO lookahead, so bound the [[package]] block by string + // search (see [`lock_block_end`]): from its header to the next block or + // trailing table (or EOF), so the bytes after the block (incl. the final + // newline) are preserved. + let head = package_head(crate_name, version); + // Every line-anchored header for this name@version. A Cargo.lock may + // legitimately hold TWO blocks for one name@version from different + // sources — after a redirect, a transitive crates.io copy resolves beside + // the socket-registry copy, and cargo sorts the crates.io block FIRST — + // so the first hit alone would repoint the wrong twin. + let heads: Vec = line_anchored(content, &head).collect(); + let block_start = match heads.as_slice() { + [] => return CargoLockPlan::NotFound, + [only] => *only, + twins => { + // Exactly one twin already at the target index is OURS (a re-run + // over a redirected lock); anything else cannot be attributed and + // the dep is skipped transactionally. + let target_source = format!("source = \"{index_url}\""); + let mut ours = twins.iter().copied().filter(|&at| { + let body_start = at + head.len(); + content[body_start..lock_block_end(content, body_start)] + .lines() + .any(|line| line == target_source) + }); + match (ours.next(), ours.next()) { + (Some(at), None) => at, + _ => return CargoLockPlan::Ambiguous, + } + } + }; + let body_start = block_start + head.len(); + let block_end = lock_block_end(content, body_start); + let original = content[block_start..block_end].to_string(); + let mut body = content[body_start..block_end].to_string(); + let old_source = CARGO_LOCK_SOURCE_LINE_RE + .captures(&body) + .map(|c| c[1].to_string()); + if old_source.is_some() { + body = CARGO_LOCK_SOURCE_LINE_RE + .replace(&body, format!("source = \"{index_url}\"").as_str()) + .to_string(); + } else { + body = format!("source = \"{index_url}\"\n{body}"); + } + // A v1 lock keeps the checksum in `[metadata]`, keyed by the package id + // — the chosen block's OWN source when it has one, so a multi-source + // twin's line is never taken for ours. + let metadata_source = old_source + .as_deref() + .map_or_else(|| r#"[^)"]*"#.to_string(), regex::escape); + let metadata_re = Regex::new(&format!( + r#"(?m)^"checksum {} {} \({metadata_source}\)" = "[^"]*"$"#, + regex::escape(crate_name), + regex::escape(version) + )) + .expect("escaped lock metadata-line regex is valid"); + let metadata_line = metadata_re.find(content).map(|m| m.as_str().to_string()); + if metadata_line.is_none() { + if CARGO_LOCK_CHECKSUM_LINE_RE.is_match(&body) { + body = CARGO_LOCK_CHECKSUM_LINE_RE + .replace(&body, format!("checksum = \"{cksum}\"").as_str()) + .to_string(); + } else { + body = CARGO_LOCK_AFTER_SOURCE_RE + .replace(&body, format!("${{1}}\nchecksum = \"{cksum}\"").as_str()) + .to_string(); + } + } + let rebuilt = format!("{head}{body}"); + let key = format!("{crate_name}@{version}"); + let edit = |original: &str, new: &str| FileEdit { + path: "Cargo.lock".into(), + kind: "redirect_cargo_lock_entry".into(), + action: "rewritten".into(), + key: Some(key.clone()), + original: Some(Value::String(original.to_string())), + new: Some(Value::String(new.to_string())), + }; + let mut edits = Vec::new(); + let mut new_content = content.to_string(); + if rebuilt != original { + new_content.replace_range(block_start..block_end, &rebuilt); + edits.push(edit(&original, &rebuilt)); + } + if let Some(line) = metadata_line { + let pinned = format!("\"checksum {crate_name} {version} ({index_url})\" = \"{cksum}\""); + if line != pinned { + new_content = new_content.replacen(&line, &pinned, 1); + edits.push(edit(&line, &pinned)); + } + } + // Dependents' full-id references to the OLD source, recorded as ONE + // `redirect_cargo_lock_reference` edit holding just the quoted id — + // never a dependent's whole block: a block referencing two patched + // packages (the root of a v1 lock) would hold two overlapping block + // edits, and reverting the first-applied one alone would find neither of + // its fragments. The id names this name + version + source exactly, so its + // inverse puts back EVERY occurrence, independently of any other + // package's edits and in any removal order. + if let Some(old) = old_source.filter(|old| old != index_url) { + let from = format!("\"{crate_name} {version} ({old})\""); + let to = format!("\"{crate_name} {version} ({index_url})\""); + let mut repointed_any = false; + // The oldest v1 locks keep the ROOT package in a standalone `[root]` + // table instead of the `[[package]]` array, with its own full-id + // `dependencies`. It precedes the array, so the block walk below + // never reaches it and the lock would keep naming a package it no + // longer contains (`--locked` fails; an unlocked build silently + // re-resolves). + if let Some((start, end)) = lock_root_table(&new_content) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + } + } + let mut cursor = 0; + while let Some((start, end)) = next_lock_block(&new_content, cursor) { + if new_content[start..end].contains(&from) { + let repointed = new_content[start..end].replace(&from, &to); + new_content.replace_range(start..end, &repointed); + repointed_any = true; + cursor = start + repointed.len(); + } else { + cursor = end; + } + } + if repointed_any { + edits.push(FileEdit { + kind: CARGO_LOCK_REFERENCE_KIND.into(), + ..edit(&from, &to) + }); + } + } + // Already redirected (re-run): every fragment is at the target values; a + // recorded edit would have original == new and grow the ledger forever. + if edits.is_empty() { + return CargoLockPlan::AlreadyRedirected; + } + CargoLockPlan::Rewritten { + content: new_content, + edits, + } +} + +/// The v1 `[root]` table's span, when the lock has one: cargo before the +/// `[root]` removal recorded the root package there rather than in the +/// `[[package]]` array, and its `dependencies` spell full package ids the +/// same way. Bounded by [`lock_block_end`], like a package block. +fn lock_root_table(content: &str) -> Option<(usize, usize)> { + const HEADER: &str = "[root]\n"; + let at = content + .match_indices(HEADER) + .map(|(at, _)| at) + .find(|&at| at == 0 || content.as_bytes()[at - 1] == b'\n')?; + Some((at, lock_block_end(content, at + HEADER.len()))) +} + +/// The next `[[package]]` block starting at or after `from`, as +/// [`lock_block_end`] bounds it. +fn next_lock_block(content: &str, from: usize) -> Option<(usize, usize)> { + let rel = content.get(from..)?.find("[[package]]\n")?; + let start = from + rel; + if start != 0 && content.as_bytes()[start - 1] != b'\n' { + return next_lock_block(content, start + 1); + } + Some(( + start, + lock_block_end(content, start + "[[package]]\n".len()), + )) +} + +/// End of the `[[package]]` block whose body starts at `body_start`, +/// excluding the newline(s) before the next block / trailing table / EOF (so +/// a recorded original/new stops after the block's last content byte — the +/// TS rewriter's `(?=\n*$)` lookahead — while the file keeps its newlines). +fn lock_block_end(content: &str, body_start: usize) -> usize { + // The next block, or the `[metadata]` / `[[patch.unused]]` tables that + // trail the packages. The trailing tables are searched only up to the + // next block: they sit after every `[[package]]` (absent entirely from + // v3/v4 locks), and an unbounded search per block scanned to EOF for + // every block of every dep. Each marker holds its only `\n` at offset 0, + // so a hit starting before the next block also ends by it — the bounded + // minimum is the unbounded one. + let rest = &content[body_start..]; + let next_block = rest.find("\n[[package]]").unwrap_or(rest.len()); + let mut end = ["\n[metadata]", "\n[[patch.unused]]", "\n[patch"] + .iter() + .filter_map(|marker| rest[..next_block].find(marker)) + .min() + .map_or(body_start + next_block, |rel| body_start + rel); + while end > body_start && content.as_bytes()[end - 1] == b'\n' { + end -= 1; + } + end +} -use crate::formats::cargo::hosted::{lock_block_end, lock_block_end_unbounded, next_lock_block}; /// Deterministic xorshift64* — no `rand` dev-dependency. struct Rng(u64); @@ -113,82 +376,128 @@ fn synth_lock(rng: &mut Rng, blocks: usize, v1: bool) -> String { out } -fn assert_bound_matches_at(content: &str, body_start: usize, what: &str) { + +// ── the comparison ── + +const INDEX: &str = "sparse+https://socket.example/cargo/index/"; + +fn plan_new(lock: &str, name: &str, version: &str, cksum: &str) -> CargoLockPlan { + CargoLock::parse(lock) + .expect("synthesized locks parse") + .plan_hosted(lock, name, version, INDEX, cksum) +} + +/// A plan as comparable data. +fn shape(plan: &CargoLockPlan) -> (String, Option, Vec) { + match plan { + CargoLockPlan::Rewritten { content, edits } => { + ("rewritten".into(), Some(content.clone()), edits.clone()) + } + CargoLockPlan::AlreadyRedirected => ("already".into(), None, Vec::new()), + CargoLockPlan::NotFound => ("not-found".into(), None, Vec::new()), + CargoLockPlan::Ambiguous => ("ambiguous".into(), None, Vec::new()), + } +} + +fn assert_same(lock: &str, name: &str, version: &str, cksum: &str, what: &str) -> Option { + let old = plan_cargo_lock(lock, name, version, INDEX, cksum); + let new = plan_new(lock, name, version, cksum); + let (old, new) = (shape(&old), shape(&new)); + // The one shape the line grammar cannot read and cargo can: the block's + // canonical header without the newline after `version` (a final block + // at EOF with no trailing newline). The span planner finds it. + let canonical_head = format!("[[package]]\nname = \"{name}\"\nversion = \"{version}\"\n"); + if old.0 == "not-found" && new.0 != "not-found" && !lock.contains(&canonical_head) { + return new.1; + } + assert_eq!(new.0, old.0, "{what}: {name}@{version} outcome\n{lock}"); + assert_eq!(new.1, old.1, "{what}: {name}@{version} bytes\n{lock}"); assert_eq!( - lock_block_end(content, body_start), - lock_block_end_unbounded(content, body_start), - "{what}: bound at body offset {body_start}" + serde_json::to_value(&new.2).unwrap(), + serde_json::to_value(&old.2).unwrap(), + "{what}: {name}@{version} edits" ); + new.1 } -/// Every char boundary is a body offset: the bound must agree even where -/// the rewriter never asks, so no future caller can find a divergence. -fn assert_bound_matches_everywhere(content: &str, what: &str) { - for (at, _) in content.char_indices() { - assert_bound_matches_at(content, at, what); +/// `(name, version)` of every `[[package]]` the lock holds, in order. +fn targets(lock: &str) -> Vec<(String, String)> { + CargoLock::parse(lock) + .expect("synthesized locks parse") + .packages() + .iter() + .map(|p| (p.name.clone(), p.version.clone())) + .collect() +} + +/// Every package of `lock`, planned by both, then a second package planned +/// over the first's output and a re-run of the first (the no-op path). +fn assert_lock(lock: &str, rng: &mut Rng, what: &str) { + let all = targets(lock); + for (name, version) in &all { + let cksum = format!("{:016x}{:016x}", rng.next(), rng.next()); + let Some(once) = assert_same(lock, name, version, &cksum, what) else { + continue; + }; + assert_same(&once, name, version, &cksum, &format!("{what} re-run")); + if let Some((other, other_version)) = all.get(rng.below(all.len())) { + assert_same(&once, other, other_version, "00ff", &format!("{what} then another")); + } } - assert_bound_matches_at(content, content.len(), what); + // An absent package, on both sides. + assert_same(lock, "absent-crate", "9.9.9", "00", what); } #[test] -fn bound_matches_unbounded_at_every_offset_of_random_locks() { +fn span_splice_matches_the_line_grammar_on_random_locks() { let mut rng = Rng(0x9E37_79B9_7F4A_7C15); for case in 0..300 { let v1 = rng.chance(50); let blocks = rng.below(12); let lock = synth_lock(&mut rng, blocks, v1); - assert_bound_matches_everywhere(&lock, &format!("case {case} (v1={v1})")); - let crlf = lock.replace('\n', "\r\n"); - assert_bound_matches_everywhere(&crlf, &format!("case {case} crlf")); + assert_lock(&lock, &mut rng, &format!("case {case} (v1={v1})")); } } +/// Hand-written shapes the generator does not produce: multi-source twins +/// (one at the target index, and none), a v1 `[root]` table, a v1 block with +/// no source, a source ending the text, and blocks with neither line. #[test] -fn bound_matches_unbounded_on_hand_written_edges() { - for lock in [ - "", - "\n", - "[[package]]\n", - "[[package]]\nname = \"a\"\nversion = \"1.0.0\"", - "[[package]]\nname = \"a\"\nversion = \"1.0.0\"\n\n\n", - // Trailing tables immediately after the last block, and before it. - "[[package]]\nname = \"a\"\n[metadata]\n\"x\" = \"y\"\n", - "[[package]]\nname = \"a\"\n\n[patch.crates-io]\n\n[[package]]\nname = \"b\"\n", - "[[package]]\nname = \"a\"\n\n[[patch.unused]]\nname = \"u\"\n\n[metadata]\n", - // A trailer before a later block (not cargo-shaped, still must agree). - "[[package]]\nname = \"a\"\n\n[metadata]\n\n[[package]]\nname = \"b\"\n", - // Look-alikes without the leading newline. - "[[package]]\nname = \"a [metadata]\"\nx = \" [[package]]\"\n", - "\n\n[[package]]\n\n\n[[package]]\n\n", +fn span_splice_matches_the_line_grammar_on_hand_written_locks() { + let crates_io = CRATES_IO; + let twins_ours = format!( + "version = 3\n\n[[package]]\nname = \"t\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\"\nchecksum = \"aa\"\n\n[[package]]\nname = \"t\"\nversion = \"1.0.0\"\nsource = \"{INDEX}\"\nchecksum = \"bb\"\n" + ); + let twins_neither = twins_ours.replace(INDEX, "registry+https://other.example/index"); + let v1_root = format!( + "[root]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"d 1.0.0 ({crates_io})\",\n]\n\n[[package]]\nname = \"d\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\"\n\n[[package]]\nname = \"u\"\nversion = \"2.0.0\"\nsource = \"{crates_io}\"\ndependencies = [\n \"d 1.0.0 ({crates_io})\",\n]\n\n[metadata]\n\"checksum d 1.0.0 ({crates_io})\" = \"cc\"\n\"checksum u 2.0.0 ({crates_io})\" = \"dd\"\n" + ); + let sourceless_v1 = "[[package]]\nname = \"s\"\nversion = \"1.0.0\"\n\n[metadata]\n\"checksum s 1.0.0 (registry+x)\" = \"ee\"\n".to_string(); + let source_at_eof = format!("[[package]]\nname = \"e\"\nversion = \"1.0.0\"\nsource = \"{crates_io}\""); + let bare = "version = 3\n\n[[package]]\nname = \"b\"\nversion = \"1.0.0\"\n\n[[package]]\nname = \"c\"\nversion = \"1.0.0\"\n".to_string(); + let mut rng = Rng(0xD1B5_4A32_D192_ED03); + for (what, lock) in [ + ("twins, one ours", twins_ours), + ("twins, neither ours", twins_neither), + ("v1 [root]", v1_root), + ("v1 sourceless", sourceless_v1), + ("source at EOF", source_at_eof), + ("bare blocks", bare), ] { - assert_bound_matches_everywhere(lock, &format!("{lock:?}")); + assert_lock(&lock, &mut rng, what); } } -/// The large-lock shape: ≥1k blocks, where the unbounded search was -/// quadratic. Checked at every block body (what `plan_cargo_lock` asks for) -/// and along the `next_lock_block` walk its dependents loop takes. +/// The large-lock shape: ≥1k blocks, both formats, a sample of targets. #[test] -fn bound_matches_unbounded_at_every_block_of_a_large_lock() { - let mut rng = Rng(0xD1B5_4A32_D192_ED03); +fn span_splice_matches_the_line_grammar_on_a_large_lock() { + let mut rng = Rng(0x2545_F491_4F6C_DD1D); for v1 in [false, true] { let lock = synth_lock(&mut rng, 1_200, v1); - let mut blocks = 0; - for (at, _) in lock.match_indices("[[package]]\n") { - assert_bound_matches_at(&lock, at + "[[package]]\n".len(), "large"); - blocks += 1; - } - assert!(blocks >= 1_000, "fixture keeps the ≥1k-block shape"); - // The walk `plan_cargo_lock` does for dependents visits the same - // blocks with the same spans under either bound. - let mut cursor = 0; - while let Some((start, end)) = next_lock_block(&lock, cursor) { - assert_eq!( - end, - lock_block_end_unbounded(&lock, start + "[[package]]\n".len()), - "large (v1={v1}): block at {start}" - ); - cursor = end; + let all = targets(&lock); + assert!(all.len() >= 1_000, "fixture keeps the ≥1k-block shape"); + for (name, version) in all.iter().step_by(97) { + assert_same(&lock, name, version, "abcd", &format!("large (v1={v1})")); } } } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 517e043b1..6933627f9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -52,7 +52,7 @@ use crate::formats::cargo::CargoLock; use crate::formats::composer::hosted::rewrite_composer_lock; use crate::formats::gem::hosted::{checksum_entry_span, converge_gem_lock_source}; pub(crate) use crate::formats::yarn::is_berry_lock; -use crate::formats::cargo::hosted::{self as cargo_lock, plan_cargo_lock, CargoLockPlan}; +use crate::formats::cargo::hosted::CargoLockPlan; pub(crate) use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; #[cfg(test)] use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; @@ -1356,7 +1356,12 @@ fn rewrite_cargo( Absent, } let lock_commit = if let Some(lock_text) = cargo_lock.as_ref() { - match plan_cargo_lock(lock_text, &dep.name, &dep.version, index_url, &cksum) { + // A lock that does not parse never reaches here: the dependents + // check above refuses it. + let plan = CargoLock::parse(lock_text).map_or(CargoLockPlan::NotFound, |lock| { + lock.plan_hosted(lock_text, &dep.name, &dep.version, index_url, &cksum) + }); + match plan { CargoLockPlan::Rewritten { content, edits } => LockCommit::Write(content, edits), CargoLockPlan::AlreadyRedirected => LockCommit::InPlace, CargoLockPlan::NotFound => { @@ -1493,7 +1498,7 @@ fn cargo_not_declared_detail( } else { "Cargo.toml".to_string() }; - let transitive = lock.is_some_and(|lock| cargo_lock::is_locked(lock, crate_name, version)); + let transitive = cargo_lock_holds(lock, crate_name, version); if transitive { format!( "{crate_name}@{version} is a transitive-only dependency (Cargo.lock resolves it, \ @@ -1523,7 +1528,7 @@ fn cargo_requirement_excludes_detail( .map(|(path, req)| format!("\"{req}\" in {path}")) .collect::>() .join(", "); - let locked = lock.is_some_and(|lock| cargo_lock::is_locked(lock, crate_name, version)); + let locked = cargo_lock_holds(lock, crate_name, version); let remedy = if locked { "; Cargo.lock resolves it for another package, which a pin cannot reach — patch it \ with `socket-patch scan --mode vendored`" @@ -2383,12 +2388,22 @@ enum CargoWorkspaceEntry { OtherPackage, } +/// Whether a Cargo.lock (when there is one, and it parses) resolves +/// `crate_name`@`version`. +fn cargo_lock_holds(lock: Option<&str>, crate_name: &str, version: &str) -> bool { + lock.and_then(|lock| CargoLock::parse(lock).ok()) + .is_some_and(|lock| lock.is_locked(crate_name, version)) +} + /// Every version of `crate_name` a Cargo.lock holds other than `version`. fn cargo_lock_other_versions(lock: Option<&str>, crate_name: &str, version: &str) -> Vec { let Some(lock) = lock else { return Vec::new(); }; - let mut versions = cargo_lock::locked_versions(lock, crate_name); + let Ok(lock) = CargoLock::parse(lock) else { + return Vec::new(); + }; + let mut versions = lock.locked_versions(crate_name); versions.retain(|v| v != version); versions }