Skip to content

Commit 73c0c4f

Browse files
WS3: one lockfile model per ecosystem (#281)
* Drop never-read fields from the rollback covgap fixture clippy -D warnings rejects the dead before_hash/after_hash fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Read pnpm-lock.yaml through one format model formats::pnpm owns the pnpm lock grammar in every generation (shrinkwrap, 5.x block, 5.4/6.0/9.0 flow, Rush nested locks): PnpmLock::parse once, then entries(), resolves(), wired_refs(), wired_integrity(), vendored_in_use(), plan_hosted() and the restore_upstream() hook. The redirect rewriter's pnpm leg, the lock inventory, lockfile discovery, repair's integrity anchor and flavor sniff, the vendored v9/legacy planners and get's pnpm-PnP probe all read through it instead of their own walkers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Derive every wiring-file list from formats::registry() The hosted candidate list, repair's vendored-reference search space, lockfile discovery's vendored-liveness probe, the in-memory engine's root markers and file ecosystems, the npm flavor probe guard and pnpm detection now filter one table instead of keeping five parallel lists. The hosted candidate order is pinned by value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Read Cargo.lock through one format model formats::cargo owns the lock read model (LockedPackage, v1 [metadata] checksums, [[patch.unused]], dependency references) behind CargoLock: entries() for the inventory, packages() for lockfile discovery and the vendor probes, dependents() for the hosted planner's unpinnable-dependents refusal (which re-parsed the lock with its own walker), vendored_in_use() for the vendored-copy claim, and the restore_upstream() hook. The hosted planner's Cargo.lock splice moves to formats::cargo::hosted with the line-grammar probes the rewriter reads with (is_locked, locked_versions), replacing three copies of the header probe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Read composer.lock through one format model formats::composer owns the entry walk (ComposerLockPackage, now with its ownership gate wired_to) behind ComposerLock: entries() for the inventory and packages() for lockfile discovery and the vendored backend. The vendored backend's entry_is_wired and repair's recorded-fragment reader, which read dist fields straight off the JSON, go through the entry model. The hosted planner's byte scanner moves verbatim to formats::composer::hosted; its equivalence oracle stays green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Read Bundler locks through one format model vendor::gemfile_lock becomes formats::gem: GemfileLock::parse plus entries() for the inventory (moved out of lock_inventory::gem, which keeps only its view I/O and ledger recovery's remote set), the restore_upstream() hook, and gem_download_url. The hosted planner's lock-source convergence moves verbatim to formats::gem::hosted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Keep the rollback fixture's hashes for the macOS-only test The blob-retention test that reads before_hash/after_hash is #[cfg(target_os = "macos")]; allow the fields as dead elsewhere instead of dropping them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Route the yarn grammar split and bun.lock prelude through formats formats::yarn owns the one yarn.lock grammar decision: sniff_grammar (the head sniff the vendor flavor probe, the lock-inventory view and repair's reference flavor each re-derived) and is_berry_lock (the whole-file check the hosted rewriters and discovery share; the classic vendored backend's refusal gate now uses it too, so a BOM'd berry lock no longer slips past it). formats::bun::BunTextLock is the gate + split + packages parse five bun.lock readers copied; each keeps its own refusal wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Read repair's yarn/bun trust anchor through the entry models wired_vendor_integrity scanned yarn.lock and bun.lock with a six-line forward window from any line naming the artifact. It now reads the entry models lockfile discovery uses: live classic blocks' integrity, berry checksum (yarn 4.0.x bare hex promoted under cacheKey 10c0), and bun's tarball tuple. That fixes a berry block whose carried dependencies pushed checksum out of the window (no anchor), a bare-hex checksum (no anchor), a shadowed classic block being read, and bun's digest-less re-save borrowing the next package's sha512 (a wrong anchor). Entries that disagree yield no anchor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Pin gem CHECKSUMS through the shared entry grammar The hosted writer found the dep's CHECKSUMS row with a regex that only matched a lowercase terminal sha256, while the discovery reader accepts uppercase digests, extra digest tokens and bare entries. Any of those fell through to the insert branch and added a second, conflicting row (which the reader then treats as no pin). The writer now locates the entry with formats::gem's split_checksum_entry inside the CHECKSUMS section and replaces that row in place, keeping its line ending and recording the old row verbatim for revert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Answer pnpm in-use from the model's parse; drop the placeholder hook PnpmLock computes its vendored-uuid set at parse time with one walk over the packages/snapshots block keys (the vendored planners' key grammar, each line's \r dropped). pnpm_entry_in_use memoizes the same set per lock bytes instead of keeping LockIndex's copy and the LF-only vendored_in_use_lines scan. A CRLF lock now answers like its LF twin (in use) instead of undeterminable; the unwired-revert guard still refuses. formats::LockModel with its default-unsupported restore_upstream() is removed: hosted upstream restoration belongs to the ledger-free hosted workstream and should land on these models, not beside them as a placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Move the maven pom and nuget config readers into formats vendor::maven_pom becomes formats::maven and nuget_config's routing reader becomes formats::nuget; the NuGet config file names and the stat-only same-file check stay in vendor::nuget_config with the callers' I/O. Lockfile discovery, their only reader, imports the models directly; the purity guards follow the files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc * Splice Cargo.lock at the spans of its one parse CargoLock::parse now reads the lock with toml_edit's spanned Document and records each [[package]]'s header, version/source/checksum value spans and string values, every table header, the [root] strings and the [metadata] lines. The hosted planner (CargoLock::plan_hosted) splices at those spans, and the rewriter's is_locked / locked_versions probes answer from the same parse, so the separate `[[package]]\nname = ...` text grammar, its regexes and block walkers are gone. The previous line-grammar planner is kept test-only as the oracle: randomized v1 and v3/v4 locks (plus twins, [root], sourceless v1, bare blocks, 1.2k-block locks) plan to identical bytes and FileEdits for every package, a re-run, and a second package over the result. The one allowed difference is a lock the old grammar could not read (a final block with no newline after `version`), which the span planner now finds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BYsX2VfVNKHvfFAJnFryc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 28cebf7 commit 73c0c4f

63 files changed

Lines changed: 4532 additions & 3386 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/socket-patch-cli/src/commands/get.rs‎

Lines changed: 8 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use socket_patch_core::api::types::{
1111
};
1212
use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages;
1313
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
14+
use socket_patch_core::formats::pnpm::PnpmLock;
1415
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
1516
use socket_patch_core::manifest::schema::{
1617
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
@@ -1498,47 +1499,6 @@ fn purl_has_version(purl: &str) -> bool {
14981499
})
14991500
}
15001501

1501-
/// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored
1502-
/// probes over the three lock grammars — a plain `contains` collides on
1503-
/// version prefixes (`left-pad@1.3.0` matches inside
1504-
/// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside
1505-
/// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside
1506-
/// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's
1507-
/// `/name/version` key spellings; a match counts only when the preceding
1508-
/// char cannot extend the name (start/whitespace/quote, or a `/` delimiter
1509-
/// itself preceded by such a boundary) and the following char cannot extend
1510-
/// the version (so `:`, `'`, `(`, and v5's `_peer` suffix all accept).
1511-
/// Heuristic by design: a false negative degrades to a calm skip, a false
1512-
/// positive costs one grant request the rewriter's per-dep confirmation
1513-
/// then ignores.
1514-
fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool {
1515-
let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
1516-
let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"');
1517-
for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] {
1518-
for (pos, _) in text.match_indices(needle.as_str()) {
1519-
let before_ok = match text[..pos].chars().next_back() {
1520-
None => true,
1521-
// v5/v6's leading key delimiter — legitimate only when the
1522-
// char before it is itself a boundary (otherwise this is a
1523-
// scoped `@scope/<name>` tail: a DIFFERENT package).
1524-
Some('/') => text[..pos - 1]
1525-
.chars()
1526-
.next_back()
1527-
.is_none_or(name_boundary),
1528-
Some(c) => name_boundary(c),
1529-
};
1530-
let after_ok = text[pos + needle.len()..]
1531-
.chars()
1532-
.next()
1533-
.is_none_or(version_boundary);
1534-
if before_ok && after_ok {
1535-
return true;
1536-
}
1537-
}
1538-
}
1539-
false
1540-
}
1541-
15421502
/// Outcome of the coarse installed-VERSION narrowing over a CVE/GHSA/PURL
15431503
/// search fan-out (see [`filter_to_installed_purls`]).
15441504
struct InstalledNarrowing {
@@ -1576,7 +1536,7 @@ struct InstalledNarrowing {
15761536
/// `yarn_pnp_unsupported`, not a false "not installed"). pnpm PnP skips
15771537
/// carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the
15781538
/// refusal's own remedy — keeps the versions the raw pnpm-lock.yaml text
1579-
/// resolves ([`pnpm_lock_resolves`]), labels a judged miss
1539+
/// resolves ([`PnpmLock::resolves`]), labels a judged miss
15801540
/// `package_not_installed` like any other mode, and reserves the layout
15811541
/// code for an unreadable lock (no judgment possible).
15821542
///
@@ -1645,6 +1605,7 @@ async fn filter_to_installed_purls(
16451605
let pnpm_pnp_lock_text: Option<String> = (pnp_pnpm && mode == super::scan::ScanMode::Hosted)
16461606
.then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok())
16471607
.flatten();
1608+
let pnpm_pnp_lock = pnpm_pnp_lock_text.as_deref().map(PnpmLock::parse);
16481609

16491610
let mut out = InstalledNarrowing {
16501611
kept: Vec::new(),
@@ -1674,8 +1635,8 @@ async fn filter_to_installed_purls(
16741635
// The pnpm PnP refusal's own remedy is the hosted lockfile
16751636
// rewrite — but only for versions the lock ACTUALLY resolves:
16761637
// keeping the whole fan-out would request grants for every
1677-
// version ever patched. Anchored probe over the raw lock text
1678-
// (see `pnpm_lock_resolves`); a hit is kept (the rewriter's
1638+
// version ever patched. The lock model's key probe
1639+
// (`PnpmLock::resolves`); a hit is kept (the rewriter's
16791640
// per-dep confirmation still decides). A judged MISS is a
16801641
// genuine "version not resolved" verdict — the layout blocked
16811642
// nothing — so it carries the same `package_not_installed` code
@@ -1684,9 +1645,9 @@ async fn filter_to_installed_purls(
16841645
let decoded = canon(&result.purl);
16851646
let coord = decoded.strip_prefix("pkg:npm/").unwrap_or(&decoded);
16861647
if mode == super::scan::ScanMode::Hosted {
1687-
match (pnpm_pnp_lock_text.as_deref(), coord.rsplit_once('@')) {
1688-
(Some(text), Some((name, version))) => {
1689-
if pnpm_lock_resolves(text, name, version) {
1648+
match (&pnpm_pnp_lock, coord.rsplit_once('@')) {
1649+
(Some(lock), Some((name, version))) => {
1650+
if lock.resolves(name, version) {
16901651
out.kept.push(result.clone());
16911652
continue;
16921653
}
@@ -3983,77 +3944,6 @@ pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
39833944
mod tests {
39843945
use super::*;
39853946

3986-
/// The pnpm-PnP hosted lock probe must be boundary-anchored: plain
3987-
/// substring matching collides on version prefixes, name suffixes, and
3988-
/// unscoped-inside-scoped names.
3989-
#[test]
3990-
fn pnpm_lock_resolves_is_boundary_anchored() {
3991-
// v9/v6/v5 key spellings all resolve.
3992-
assert!(pnpm_lock_resolves(
3993-
"lockfileVersion: '9.0'\n\nsnapshots:\n\n left-pad@1.3.0:\n",
3994-
"left-pad",
3995-
"1.3.0"
3996-
));
3997-
assert!(pnpm_lock_resolves(
3998-
" /left-pad@1.3.0:\n resolution: {}\n",
3999-
"left-pad",
4000-
"1.3.0"
4001-
));
4002-
assert!(pnpm_lock_resolves(
4003-
" /left-pad/1.3.0:\n resolution: {}\n",
4004-
"left-pad",
4005-
"1.3.0"
4006-
));
4007-
// Peer-qualified keys still resolve: v9 `(peer)` and v5 `_peer`.
4008-
assert!(pnpm_lock_resolves(
4009-
" 'left-pad@1.3.0(react@18.0.0)':\n",
4010-
"left-pad",
4011-
"1.3.0"
4012-
));
4013-
assert!(pnpm_lock_resolves(
4014-
" /left-pad/1.3.0_react@18.0.0:\n",
4015-
"left-pad",
4016-
"1.3.0"
4017-
));
4018-
// Scoped names resolve in both quoted-v9 and v6 spellings.
4019-
assert!(pnpm_lock_resolves(
4020-
" '@scope/name@1.0.0':\n",
4021-
"@scope/name",
4022-
"1.0.0"
4023-
));
4024-
assert!(pnpm_lock_resolves(
4025-
" /@scope/name@1.0.0:\n",
4026-
"@scope/name",
4027-
"1.0.0"
4028-
));
4029-
4030-
// Version-prefix collision: 1.3.0 must NOT match 1.3.0-beta.1.
4031-
assert!(!pnpm_lock_resolves(
4032-
" left-pad@1.3.0-beta.1:\n",
4033-
"left-pad",
4034-
"1.3.0"
4035-
));
4036-
// Name-suffix collision: `pad` must NOT match inside `left-pad`.
4037-
assert!(!pnpm_lock_resolves(" left-pad@1.3.0:\n", "pad", "1.3.0"));
4038-
assert!(!pnpm_lock_resolves(" /left-pad/1.3.0:\n", "pad", "1.3.0"));
4039-
// Unscoped-inside-scoped: `name` must NOT match `@scope/name`.
4040-
assert!(!pnpm_lock_resolves(
4041-
" '@scope/name@1.0.0':\n",
4042-
"name",
4043-
"1.0.0"
4044-
));
4045-
assert!(!pnpm_lock_resolves(
4046-
" /@scope/name@1.0.0:\n",
4047-
"name",
4048-
"1.0.0"
4049-
));
4050-
// Absent version: never resolves.
4051-
assert!(!pnpm_lock_resolves(
4052-
" left-pad@1.3.0:\n",
4053-
"left-pad",
4054-
"2.0.0"
4055-
));
4056-
}
40573947
use socket_patch_core::api::types::{PatchFileResponse, VulnerabilityResponse};
40583948
use std::collections::HashMap;
40593949

@@ -5032,36 +4922,6 @@ mod tests {
50324922
);
50334923
}
50344924

5035-
// --- pnpm_lock_resolves: needle at byte 0 ------------------------------
5036-
// The boundary probe reads the char BEFORE the match; a match at the very
5037-
// start of the text has none (`None => true`). A regression that indexes
5038-
// `text[..pos - 1]` unconditionally would underflow/panic here.
5039-
5040-
#[test]
5041-
fn pnpm_lock_resolves_needle_at_start_of_text() {
5042-
// pos == 0, plain v9 spelling: no preceding char is a valid boundary.
5043-
assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0"));
5044-
// pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the
5045-
// leading `/` delimiter itself has nothing before it.
5046-
assert!(pnpm_lock_resolves(
5047-
"/left-pad/1.3.0:\n",
5048-
"left-pad",
5049-
"1.3.0"
5050-
));
5051-
assert!(pnpm_lock_resolves(
5052-
"/left-pad@1.3.0:\n",
5053-
"left-pad",
5054-
"1.3.0"
5055-
));
5056-
// Still boundary-checked at the start of text: a scoped tail whose
5057-
// name begins mid-token must NOT match.
5058-
assert!(!pnpm_lock_resolves(
5059-
"@scope/left-pad@1.3.0:\n",
5060-
"left-pad",
5061-
"1.3.0"
5062-
));
5063-
}
5064-
50654925
// --- write_all_patch_blobs ---------------------------------------------
50664926
// The per-patch fan-out over write_blob_entry: the FIRST bad entry must
50674927
// fail the whole patch (Err(())) and leave nothing outside the blobs

0 commit comments

Comments
 (0)