diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e6a2c1ad..2716181e 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, @@ -1498,47 +1499,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 { @@ -1576,7 +1536,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). /// @@ -1645,6 +1605,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(), @@ -1674,8 +1635,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 @@ -1684,9 +1645,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/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6231254a..a78063ff 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::{ @@ -27,71 +28,11 @@ pub(crate) use vlt::rollback_heal as vlt_rollback_heal; pub(crate) use vlt::takeover_heal as vlt_takeover_heal; /// 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 @@ -304,41 +245,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 { @@ -1849,7 +1760,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; } @@ -3528,7 +3439,6 @@ mod tests { wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; - use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; @@ -4751,25 +4661,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/commands/vendored_backend/repair.rs b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs index 390195db..2744cd6a 100644 --- a/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs +++ b/crates/socket-patch-cli/src/commands/vendored_backend/repair.rs @@ -28,6 +28,7 @@ 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::registry; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::utils::fs::{ @@ -57,36 +58,6 @@ struct Candidate { reason: &'static str, } -/// Files the vendor backends rewire — the search space for -/// `.socket/vendor///` references. 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 @@ -148,13 +119,14 @@ pub(crate) async fn scan_vendor_references(project_root: &Path) -> Vec<(String, } /// Every wiring-bearing file name the vendor backends may rewrite, relative -/// to `project_root`: [`WIRING_FILES`], vlt importer manifests, the Python +/// to `project_root`: the registry's vendored wiring files +/// ([`registry::VENDORED`]), vlt importer manifests, the Python /// locks the root lists (and their scripts) and the requirements `-r` /// include tree. Sorted and deduplicated; entries need not exist. async fn wiring_files(project_root: &Path) -> Vec { - let mut files: Vec = WIRING_FILES - .iter() - .map(|file| (*file).to_string()) + 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) { diff --git a/crates/socket-patch-cli/src/hosted_memory/redirect.rs b/crates/socket-patch-cli/src/hosted_memory/redirect.rs index 7848425c..06af3a67 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 002cac15..053cf801 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 5a6fefeb..18287b85 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/bun/mod.rs b/crates/socket-patch-core/src/formats/bun/mod.rs new file mode 100644 index 00000000..54020760 --- /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/cargo/hosted.rs b/crates/socket-patch-core/src/formats/cargo/hosted.rs new file mode 100644 index 00000000..4545b401 --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/hosted.rs @@ -0,0 +1,273 @@ +//! 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 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::ops::Range; + +use serde_json::Value; + +use crate::patch::redirect::FileEdit; + +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"; + +/// 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 +} + +/// 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), + } +} + +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 { + version.end + }; + while end > floor && content.as_bytes()[end - 1] == b'\n' { + end -= 1; + } + end + } + + /// 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)); + } + } + 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)); + } + } + // 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) + }); + } + } + // 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, + } + } +} + +/// 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, + 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 00000000..58b4dc2b --- /dev/null +++ b/crates/socket-patch-core/src/formats/cargo/mod.rs @@ -0,0 +1,506 @@ +//! `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`]); +//! * [`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`; +//! +//! Everything here is pure; the callers own the reads. + +pub(crate) mod hosted; + +use std::ops::Range; + +use toml_edit::{Document, DocumentMut, Item, Table, Value}; + +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}; + + +// ── 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 { + 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) + }; + root.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(); + 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(root: &Table) -> Vec<(String, String)> { + root.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)>, + /// 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 (no spans: a + /// `DocumentMut` keeps none). + pub fn from_doc(doc: &DocumentMut) -> Self { + CargoLock { + packages: locked_packages(doc), + unused: unused_patches(doc.as_table()), + spans: None, + } + } + + /// 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 { + 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. + 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) + } +} + 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 00000000..0ab5dae3 --- /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 00000000..1da78fce --- /dev/null +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -0,0 +1,176 @@ +//! `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; + +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}; + + +// ── 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 + } +} + 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 00000000..0416c359 --- /dev/null +++ b/crates/socket-patch-core/src/formats/gem/hosted.rs @@ -0,0 +1,307 @@ +//! 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 +} + +/// 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/vendor/gemfile_lock.rs b/crates/socket-patch-core/src/formats/gem/mod.rs similarity index 81% rename from crates/socket-patch-core/src/vendor/gemfile_lock.rs rename to crates/socket-patch-core/src/formats/gem/mod.rs index 4ec9c095..a8054fc1 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,14 @@ //! 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}; + /// The Bundler lockfiles, legacy spelling first: `Gemfile.lock` and /// `gems.locked` (what bundler writes instead when the manifest is @@ -143,6 +149,68 @@ 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) + } +} + + +/// 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/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 new file mode 100644 index 00000000..f3ea013a --- /dev/null +++ b/crates/socket-patch-core/src/formats/mod.rs @@ -0,0 +1,92 @@ +//! 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. +//! +//! 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 +//! 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 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; +pub mod yarn; + +pub use registry::registry; + +/// 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/formats/nuget/mod.rs b/crates/socket-patch-core/src/formats/nuget/mod.rs new file mode 100644 index 00000000..294b4ca7 --- /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 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 00000000..c491bd22 --- /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 00000000..ce1726f1 --- /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 00000000..1d495d69 --- /dev/null +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -0,0 +1,584 @@ +//! `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`]; +//! +//! 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 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; + + +// ── 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>, + /// [`vendored_npm_uuids`] of the text. + vendored: HashSet, +} + +/// 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), + vendored: vendored_npm_uuids(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? `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) + } +} + +/// 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; + } + if !in_section { + continue; + } + if let Some(uuid) = lines::parse_key_line(line, 2).and_then(|(key, _, _)| vendored_npm_uuid(key)) { + out.insert(uuid); + } + } + out +} + +#[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_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!(PnpmLock::parse(text).vendored_in_use(UUID), "{text}"); + let other = "22222222-2222-4222-8222-222222222222"; + assert!(!PnpmLock::parse(text).vendored_in_use(other)); + let crlf = text.replace('\n', "\r\n"); + 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!(!PnpmLock::parse(&overrides).vendored_in_use(UUID)); + } + + #[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/formats/registry.rs b/crates/socket-patch-core/src/formats/registry.rs new file mode 100644 index 00000000..dd89a862 --- /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/formats/yarn/mod.rs b/crates/socket-patch-core/src/formats/yarn/mod.rs new file mode 100644 index 00000000..c7f51d5b --- /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/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..01ed5652 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/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs index 7912e153..34c648ad 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/patch/redirect/cargo_lock_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/cargo_lock_equivalence_tests.rs index ad4e20a0..570ee762 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 super::*; /// 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/composer_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs index 488c5f74..187c3c25 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/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index d6fb3831..65bfdeb8 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 98fd7930..95a18343 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; @@ -45,9 +44,19 @@ 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; +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::CargoLockPlan; +#[cfg(test)] +use crate::formats::cargo::hosted::CARGO_LOCK_REFERENCE_KIND; +#[cfg(test)] +use crate::formats::pnpm::hosted::pnpm_unrewritten_instances; #[cfg(test)] mod pnpm_equivalence_tests; mod poetry; @@ -252,7 +261,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(), @@ -425,7 +434,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); @@ -1343,7 +1352,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 => { @@ -1480,11 +1494,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 = cargo_lock_holds(lock, crate_name, version); if transitive { format!( "{crate_name}@{version} is a transitive-only dependency (Cargo.lock resolves it, \ @@ -1514,11 +1524,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 = 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`" @@ -1585,34 +1591,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" @@ -2399,23 +2384,23 @@ 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 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 Ok(lock) = CargoLock::parse(lock) else { + return Vec::new(); + }; + let mut versions = lock.locked_versions(crate_name); + versions.retain(|v| v != version); versions } @@ -2863,286 +2848,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, @@ -3245,421 +2950,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, @@ -3864,20 +3154,6 @@ fn yarn_classic_block_head(block: &str) -> Option<(String, Option)> { /// can reproduce offline; matches the vendored backend's `SUPPORTED_CACHE_KEY`. const YARN_BERRY_SUPPORTED_CACHE_KEY: &str = "10c0"; -/// 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 { @@ -4249,24 +3525,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 { @@ -4806,306 +4079,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() @@ -5662,7 +4635,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 = [ @@ -5718,271 +4691,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], @@ -6335,46 +5043,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( @@ -11237,6 +9929,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 @@ -13839,7 +12602,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 @@ -14955,7 +13718,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 60bd8516..906427f0 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/patch/redirect/upstream/cargo.rs b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs index da6fa754..c44d7919 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/cargo.rs @@ -16,6 +16,7 @@ use std::collections::{BTreeMap, BTreeSet}; use regex::Regex; use super::{Ctx, FormatResult, HostedPin, View}; +use crate::formats::cargo::CargoLock; /// How Cargo.lock names crates.io (cargo keeps this spelling even when it /// fetches over the sparse protocol). @@ -30,20 +31,14 @@ fn registry_name(uuid: &str) -> String { struct LockHit { uuid: String, + /// The package's position in [`CargoLock::packages`]. + index: usize, name: String, version: String, /// The hosted source string (`sparse+https://…/index/`). source: String, } -fn quoted_field(block: &str, field: &str) -> Option { - block.lines().find_map(|l| { - let rest = l.strip_prefix(field)?.trim_start().strip_prefix('=')?; - let v = rest.trim(); - v.strip_prefix('"')?.strip_suffix('"').map(str::to_string) - }) -} - pub(crate) async fn restore( view: &mut View<'_>, pins: &[&HostedPin], @@ -66,29 +61,34 @@ pub(crate) async fn restore( if let Some(raw) = lock_raw { let crlf = raw.contains("\r\n"); let mut lock = raw.replace("\r\n", "\n"); + // The one parse of the lock: every package with its value spans. + let model = match CargoLock::parse(&lock) { + Ok(model) => model, + Err(_) => { + for pin in pins { + result.refuse(&pin.uuid, "Cargo.lock does not parse as TOML"); + } + return result; + } + }; let mut hits: Vec = Vec::new(); - let mut from = 0; - while let Some((start, end)) = super::super::next_lock_block(&lock, from) { - from = end.max(start + 1); - let block = &lock[start..end]; - let Some(source) = quoted_field(block, "source") else { + for (i, pkg) in model.packages().iter().enumerate() { + let Some(source) = &pkg.source else { continue; }; - let Some(uuid) = ctx.hosted_uuid(&source) else { + let Some(uuid) = ctx.hosted_uuid(source) else { continue; }; if !by_uuid.contains_key(uuid.as_str()) { continue; } - match (quoted_field(block, "name"), quoted_field(block, "version")) { - (Some(name), Some(version)) => hits.push(LockHit { - uuid, - name, - version, - source, - }), - _ => result.refuse(&uuid, "its Cargo.lock entry names no crate and version"), - } + hits.push(LockHit { + uuid, + index: i, + name: pkg.name.clone(), + version: pkg.version.clone(), + source: source.clone(), + }); } let lookups = hits.iter().map(|h| async move { ( @@ -99,6 +99,7 @@ pub(crate) async fn restore( let cksums: BTreeMap> = futures_util::future::join_all(lookups).await.into_iter().collect(); let mut changed = false; + let mut restored: Vec<(&LockHit, String)> = Vec::new(); for hit in &hits { let cksum = match cksums.get(&hit.uuid) { Some(Ok(c)) => c.clone(), @@ -111,32 +112,26 @@ pub(crate) async fn restore( if result.refused.contains_key(&hit.uuid) { continue; } - // The entry's own source + checksum lines. - let mut from = 0; - while let Some((start, end)) = super::super::next_lock_block(&lock, from) { - from = end.max(start + 1); - let block = lock[start..end].to_string(); - if quoted_field(&block, "source").as_deref() != Some(hit.source.as_str()) - || quoted_field(&block, "name").as_deref() != Some(hit.name.as_str()) - || quoted_field(&block, "version").as_deref() != Some(hit.version.as_str()) - { - continue; - } - let rebuilt: Vec = block - .split('\n') - .map(|l| { - if l.starts_with("source = ") { - format!("source = \"{CRATES_IO_SOURCE}\"") - } else if l.starts_with("checksum = ") { - format!("checksum = \"{cksum}\"") - } else { - l.to_string() - } - }) - .collect(); - lock.replace_range(start..end, &rebuilt.join("\n")); - from = start + 1; + restored.push((hit, cksum)); + } + // The entries' own source + checksum values, spliced at the parse's + // spans (every hit is a distinct block: its source names its uuid). + let spans = model.spans().expect("a lock parsed from text carries spans"); + let mut splices: Vec<(std::ops::Range, String)> = Vec::new(); + for (hit, cksum) in &restored { + let at = &spans.packages[hit.index]; + if let Some(source) = &at.source { + splices.push((source.clone(), format!("\"{CRATES_IO_SOURCE}\""))); } + if let Some(checksum) = &at.checksum { + splices.push((checksum.clone(), format!("\"{cksum}\""))); + } + } + splices.sort_by_key(|(r, _)| std::cmp::Reverse(r.start)); + for (range, with) in splices { + lock.replace_range(range, &with); + } + for (hit, cksum) in &restored { // Dependents' full-id references and the v1 `[metadata]` key. lock = lock.replace(&format!("({})", hit.source), &format!("({CRATES_IO_SOURCE})")); let metadata_key = format!( diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs index 5be77710..ffa47b9f 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/client.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/client.rs @@ -388,7 +388,7 @@ impl UpstreamClient { if self.offline { return Err(OFFLINE.to_string()); } - if !crate::vendor::gemfile_lock::is_plain_gem_token(name) { + if !crate::formats::gem::is_plain_gem_token(name) { return Err(format!("{name:?} is not a plain gem name")); } let url = format!("{}/info/{name}", rubygems_base()); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs index 52e6b8c4..b804342c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs @@ -27,7 +27,9 @@ use std::collections::BTreeMap; use serde_json::Value; -use super::super::{find_composer_entry, json_object_end_from, json_string_field, ComposerEntry}; +use crate::formats::composer::hosted::{ + find_composer_entry, json_object_end_from, json_string_field, ComposerEntry, +}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::crawlers::composer_crawler::normalize_version; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs index cae1d699..e42e183c 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/gem.rs @@ -58,7 +58,7 @@ use regex::Regex; use super::{Ctx, FormatResult, HostedPin, View}; use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::gem::{gem_declaration_any, quoted_literal}; -use crate::vendor::gemfile_lock::{ +use crate::formats::gem::{ bundler_manifest_for, parse_spec, same_remote, split_checksum_entry, BUNDLER_LOCKS, }; diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs index 35166b00..7b90cbde 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/maven.rs @@ -318,7 +318,7 @@ pub(crate) async fn restore( if restored.is_empty() { continue; } - if let Err(why) = crate::vendor::maven_pom::parse_pom(&text) { + if let Err(why) = crate::formats::maven::parse_pom(&text) { refuse_all_in( &pins, rel, @@ -382,7 +382,7 @@ async fn restore_mvn( let other_hosted = rest.lines().any(|l| { path_of(l).is_some_and(|p| { p.split('/') - .any(|seg| crate::vendor::maven_pom::split_socket_version(seg).is_some()) + .any(|seg| crate::formats::maven::split_socket_version(seg).is_some()) }) }); if has_resolver_lines && !other_hosted { diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index d7a0a2d9..8bcb5d10 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -466,7 +466,7 @@ pub(crate) async fn restore_pnpm_locks( files: &[String], ctx: &Ctx<'_>, ) -> FormatResult { - use super::super::pnpm; + use crate::formats::pnpm::grammar as pnpm; let mut result = FormatResult::default(); let pins = by_uuid(pins); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs index 77b5a2eb..3531f7d0 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/nuget.rs @@ -23,7 +23,7 @@ use serde_json::Value; use super::npm::{by_uuid, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; -use crate::vendor::nuget_config::{parse_config, NugetConfig}; +use crate::formats::nuget::{parse_config, NugetConfig}; use crate::vendor::nuget_feed::normalize_nuget_version; const PACKAGES_LOCK: &str = "packages.lock.json"; diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 71643202..161d7b22 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/cargo_lock.rs b/crates/socket-patch-core/src/vendor/cargo_lock.rs index 2a800d93..7e50bcca 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/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index c2119f6d..58a575dd 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/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 162a05fd..2d5dd4ef 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/bun.rs b/crates/socket-patch-core/src/vendor/lock_inventory/bun.rs index bbb20df1..f1c26c85 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/cargo.rs b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs index f334b46e..37caa503 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/vendor/lock_inventory/composer.rs b/crates/socket-patch-core/src/vendor/lock_inventory/composer.rs index 25227762..207f5088 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/gem.rs b/crates/socket-patch-core/src/vendor/lock_inventory/gem.rs index 61643eba..72543adc 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 15330b99..1cacfe2e 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 @@ -70,11 +70,9 @@ 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; -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 +171,7 @@ pub struct LockfileEntry { } impl LockfileEntry { - fn npm( + pub(crate) fn npm( name: impl Into, version: impl Into, resolved: Option, @@ -364,7 +362,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()) } @@ -374,8 +372,8 @@ fn http_url(raw: &str) -> 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)] @@ -471,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); } @@ -483,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/lock_inventory/pnpm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs index 9d78c288..27aefcef 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 0e58a9a8..83303ed8 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" => { @@ -320,22 +318,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 e6f31357..5d0a45f5 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 @@ -2419,7 +2493,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 4f603f1d..7161b358 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,8 @@ 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::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/lock_inventory/wired.rs b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs index 35c2a2f3..9ed45e49 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/wired.rs @@ -6,15 +6,22 @@ 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::{ 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 @@ -23,11 +30,10 @@ 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 -/// integrity (slot [2] is `null`), and `vlt-lock.json` is never scanned, -/// because the forward window would pick up a neighbouring node's sha512. +/// package-lock/shrinkwrap are parsed as JSON, pnpm through its format +/// 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, @@ -130,48 +136,89 @@ 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] { - let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else { - continue; + // 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: 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) { - // pnpm `resolution: {integrity: …}` / 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::*; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index faafcbf1..5d6fc529 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -62,13 +62,11 @@ 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; 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/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 25a976a0..a0ffe424 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -25,7 +25,8 @@ 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 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. @@ -215,7 +211,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 @@ -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(), )) } @@ -852,9 +838,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 +849,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/vendor/nuget_config.rs b/crates/socket-patch-core/src/vendor/nuget_config.rs index 50c9583e..21f58ae1 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 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). - 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. - 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), - }) -} - -/// 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 + // `.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, @@ -1120,46 +1087,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). @@ -2297,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`]). @@ -2547,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 `(`. @@ -2658,20 +2585,6 @@ impl LockIndex { i = importer.end; } } - - 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); - } - } - } index } @@ -3245,155 +3158,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 +5050,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!( @@ -5896,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) @@ -5911,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:?}"); @@ -8574,13 +8339,12 @@ snapshots: "seed {seed} {name}" ); } - for uuid in [UUID, OTHER_UUID] { - assert_eq!( - index.vendored_npm_uuids.contains(uuid), - pnpm_entry_in_use_scan(uuid, &lines), - "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 f82b4567..5e4236db 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) + Some(PnpmLock::parse(&text).vendored_in_use(&entry.uuid)) } // ─────────────────────────── pre-flight checks ─────────────────────────── @@ -3071,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(); @@ -3088,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. 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 cd65d524..38e5e905 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 \ diff --git a/crates/socket-patch-core/src/vex/discover/cargo.rs b/crates/socket-patch-core/src/vex/discover/cargo.rs index 8c537427..86aab22d 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} {})", diff --git a/crates/socket-patch-core/src/vex/discover/composer.rs b/crates/socket-patch-core/src/vex/discover/composer.rs index 8562e74b..568d9ca5 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); } } diff --git a/crates/socket-patch-core/src/vex/discover/gem.rs b/crates/socket-patch-core/src/vex/discover/gem.rs index da1ef1e9..77f7388a 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/maven.rs b/crates/socket-patch-core/src/vex/discover/maven.rs index 9908e52c..d90720e7 100644 --- a/crates/socket-patch-core/src/vex/discover/maven.rs +++ b/crates/socket-patch-core/src/vex/discover/maven.rs @@ -93,7 +93,7 @@ use crate::patch::redirect::{ }; use crate::utils::digest::sha256_hex; use crate::vendor::lock_inventory::LockIntegrity; -use crate::vendor::maven_pom::{ +use crate::formats::maven::{ is_maven_coordinate, is_maven_version_text, parse_pom, split_socket_version, Pom, PomDep, PomRepo, }; diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 5f895fca..9a923263 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -33,10 +33,10 @@ //! (`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`, -//! `composer_lock_packages`, the +//! `cargo_config`, `go_mod_edit` / `go_sum_edit`, `formats::gem`, +//! `formats::composer`, the //! `utils::python_lock` / `poetry_lock` / `requirements` / `hatch` readers, -//! `maven_pom`, `nuget_config` / `nuget_feed`). The inventory's registry +//! `formats::maven`, `nuget_config` / `nuget_feed`). The inventory's registry //! views drop the Socket-owned entries (they feed registry discovery and //! fetches); the extractors here classify and validate exactly those. File //! selection and I/O stay with each consumer: discovery reads every present @@ -1738,33 +1738,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. diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index a9ddfe3a..3dbfcc55 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); } } diff --git a/crates/socket-patch-core/src/vex/discover/nuget.rs b/crates/socket-patch-core/src/vex/discover/nuget.rs index 61006586..d7f3cd95 100644 --- a/crates/socket-patch-core/src/vex/discover/nuget.rs +++ b/crates/socket-patch-core/src/vex/discover/nuget.rs @@ -74,7 +74,8 @@ use super::{ DIAG_REF_UNATTRIBUTABLE, }; use crate::vendor::lock_inventory::LockIntegrity; -use crate::vendor::nuget_config::{parse_config, same_file, NugetConfig, CONFIG_NAMES}; +use crate::formats::nuget::{parse_config, NugetConfig}; +use crate::vendor::nuget_config::{same_file, CONFIG_NAMES}; use crate::vendor::nuget_feed::{is_plain_nuget_token, nuget_lock_entries, nupkg_leaf}; use crate::vendor::path::VENDOR_DIR;