Skip to content

Commit 94dc5d2

Browse files
committed
Merge release/v5-prerelease (#281, one lockfile model per ecosystem) into v5/one-hosted-engine
#281's format-registry changes to the hosted flow land where this branch moved that code: core hosted::engine derives REDIRECT_CANDIDATE_FILES and file_ecosystem from formats::registry, hosted::guidance re-exports the pnpm lock-version sniffs from formats::pnpm, and the in-memory root detection reads registry::root_marker. The CLI hosted_memory redirect.rs #281 edited is already gone on this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju
2 parents f7466de + 73c0c4f commit 94dc5d2

64 files changed

Lines changed: 4532 additions & 3383 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
pub(crate) use socket_patch_core::manifest::records::record_from_patch_response;
1617
use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest};
@@ -1425,47 +1426,6 @@ fn purl_has_version(purl: &str) -> bool {
14251426
})
14261427
}
14271428

1428-
/// Does the raw pnpm-lock text RESOLVE `name@version`? Boundary-anchored
1429-
/// probes over the three lock grammars — a plain `contains` collides on
1430-
/// version prefixes (`left-pad@1.3.0` matches inside
1431-
/// `left-pad@1.3.0-beta.1`), name suffixes (`pad@1.3.0` inside
1432-
/// `left-pad@1.3.0`), and unscoped-inside-scoped names (`name@1.0.0` inside
1433-
/// `@scope/name@1.0.0`). The needles cover v6/v9's `name@version` and v5's
1434-
/// `/name/version` key spellings; a match counts only when the preceding
1435-
/// char cannot extend the name (start/whitespace/quote, or a `/` delimiter
1436-
/// itself preceded by such a boundary) and the following char cannot extend
1437-
/// the version (so `:`, `'`, `(`, and v5's `_peer` suffix all accept).
1438-
/// Heuristic by design: a false negative degrades to a calm skip, a false
1439-
/// positive costs one grant request the rewriter's per-dep confirmation
1440-
/// then ignores.
1441-
fn pnpm_lock_resolves(text: &str, name: &str, version: &str) -> bool {
1442-
let version_boundary = |c: char| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
1443-
let name_boundary = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r' | '\'' | '"');
1444-
for needle in [format!("{name}@{version}"), format!("/{name}/{version}")] {
1445-
for (pos, _) in text.match_indices(needle.as_str()) {
1446-
let before_ok = match text[..pos].chars().next_back() {
1447-
None => true,
1448-
// v5/v6's leading key delimiter — legitimate only when the
1449-
// char before it is itself a boundary (otherwise this is a
1450-
// scoped `@scope/<name>` tail: a DIFFERENT package).
1451-
Some('/') => text[..pos - 1]
1452-
.chars()
1453-
.next_back()
1454-
.is_none_or(name_boundary),
1455-
Some(c) => name_boundary(c),
1456-
};
1457-
let after_ok = text[pos + needle.len()..]
1458-
.chars()
1459-
.next()
1460-
.is_none_or(version_boundary);
1461-
if before_ok && after_ok {
1462-
return true;
1463-
}
1464-
}
1465-
}
1466-
false
1467-
}
1468-
14691429
/// Outcome of the coarse installed-VERSION narrowing over a CVE/GHSA/PURL
14701430
/// search fan-out (see [`filter_to_installed_purls`]).
14711431
struct InstalledNarrowing {
@@ -1504,7 +1464,7 @@ struct InstalledNarrowing {
15041464
/// `yarn_pnp_unsupported`, not a false "not installed"). pnpm PnP skips
15051465
/// carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the
15061466
/// refusal's own remedy — keeps the versions the raw pnpm-lock.yaml text
1507-
/// resolves ([`pnpm_lock_resolves`]), labels a judged miss
1467+
/// resolves ([`PnpmLock::resolves`]), labels a judged miss
15081468
/// `package_not_installed` like any other mode, and reserves the layout
15091469
/// code for an unreadable lock (no judgment possible).
15101470
///
@@ -1576,6 +1536,7 @@ async fn filter_to_installed_purls(
15761536
let pnpm_pnp_lock_text: Option<String> = (pnp_pnpm && mode == super::scan::ScanMode::Hosted)
15771537
.then(|| std::fs::read_to_string(common.cwd.join("pnpm-lock.yaml")).ok())
15781538
.flatten();
1539+
let pnpm_pnp_lock = pnpm_pnp_lock_text.as_deref().map(PnpmLock::parse);
15791540

15801541
let mut out = InstalledNarrowing {
15811542
kept: Vec::new(),
@@ -1605,8 +1566,8 @@ async fn filter_to_installed_purls(
16051566
// The pnpm PnP refusal's own remedy is the hosted lockfile
16061567
// rewrite — but only for versions the lock ACTUALLY resolves:
16071568
// keeping the whole fan-out would request grants for every
1608-
// version ever patched. Anchored probe over the raw lock text
1609-
// (see `pnpm_lock_resolves`); a hit is kept (the rewriter's
1569+
// version ever patched. The lock model's key probe
1570+
// (`PnpmLock::resolves`); a hit is kept (the rewriter's
16101571
// per-dep confirmation still decides). A judged MISS is a
16111572
// genuine "version not resolved" verdict — the layout blocked
16121573
// nothing — so it carries the same `package_not_installed` code
@@ -1615,9 +1576,9 @@ async fn filter_to_installed_purls(
16151576
let decoded = canon(&result.purl);
16161577
let coord = decoded.strip_prefix("pkg:npm/").unwrap_or(&decoded);
16171578
if mode == super::scan::ScanMode::Hosted {
1618-
match (pnpm_pnp_lock_text.as_deref(), coord.rsplit_once('@')) {
1619-
(Some(text), Some((name, version))) => {
1620-
if pnpm_lock_resolves(text, name, version) {
1579+
match (&pnpm_pnp_lock, coord.rsplit_once('@')) {
1580+
(Some(lock), Some((name, version))) => {
1581+
if lock.resolves(name, version) {
16211582
out.kept.push(result.clone());
16221583
continue;
16231584
}
@@ -3914,77 +3875,6 @@ pub(crate) fn base64_decode(input: &str) -> Result<Vec<u8>, String> {
39143875
mod tests {
39153876
use super::*;
39163877

3917-
/// The pnpm-PnP hosted lock probe must be boundary-anchored: plain
3918-
/// substring matching collides on version prefixes, name suffixes, and
3919-
/// unscoped-inside-scoped names.
3920-
#[test]
3921-
fn pnpm_lock_resolves_is_boundary_anchored() {
3922-
// v9/v6/v5 key spellings all resolve.
3923-
assert!(pnpm_lock_resolves(
3924-
"lockfileVersion: '9.0'\n\nsnapshots:\n\n left-pad@1.3.0:\n",
3925-
"left-pad",
3926-
"1.3.0"
3927-
));
3928-
assert!(pnpm_lock_resolves(
3929-
" /left-pad@1.3.0:\n resolution: {}\n",
3930-
"left-pad",
3931-
"1.3.0"
3932-
));
3933-
assert!(pnpm_lock_resolves(
3934-
" /left-pad/1.3.0:\n resolution: {}\n",
3935-
"left-pad",
3936-
"1.3.0"
3937-
));
3938-
// Peer-qualified keys still resolve: v9 `(peer)` and v5 `_peer`.
3939-
assert!(pnpm_lock_resolves(
3940-
" 'left-pad@1.3.0(react@18.0.0)':\n",
3941-
"left-pad",
3942-
"1.3.0"
3943-
));
3944-
assert!(pnpm_lock_resolves(
3945-
" /left-pad/1.3.0_react@18.0.0:\n",
3946-
"left-pad",
3947-
"1.3.0"
3948-
));
3949-
// Scoped names resolve in both quoted-v9 and v6 spellings.
3950-
assert!(pnpm_lock_resolves(
3951-
" '@scope/name@1.0.0':\n",
3952-
"@scope/name",
3953-
"1.0.0"
3954-
));
3955-
assert!(pnpm_lock_resolves(
3956-
" /@scope/name@1.0.0:\n",
3957-
"@scope/name",
3958-
"1.0.0"
3959-
));
3960-
3961-
// Version-prefix collision: 1.3.0 must NOT match 1.3.0-beta.1.
3962-
assert!(!pnpm_lock_resolves(
3963-
" left-pad@1.3.0-beta.1:\n",
3964-
"left-pad",
3965-
"1.3.0"
3966-
));
3967-
// Name-suffix collision: `pad` must NOT match inside `left-pad`.
3968-
assert!(!pnpm_lock_resolves(" left-pad@1.3.0:\n", "pad", "1.3.0"));
3969-
assert!(!pnpm_lock_resolves(" /left-pad/1.3.0:\n", "pad", "1.3.0"));
3970-
// Unscoped-inside-scoped: `name` must NOT match `@scope/name`.
3971-
assert!(!pnpm_lock_resolves(
3972-
" '@scope/name@1.0.0':\n",
3973-
"name",
3974-
"1.0.0"
3975-
));
3976-
assert!(!pnpm_lock_resolves(
3977-
" /@scope/name@1.0.0:\n",
3978-
"name",
3979-
"1.0.0"
3980-
));
3981-
// Absent version: never resolves.
3982-
assert!(!pnpm_lock_resolves(
3983-
" left-pad@1.3.0:\n",
3984-
"left-pad",
3985-
"2.0.0"
3986-
));
3987-
}
39883878
use socket_patch_core::api::types::{PatchFileResponse, VulnerabilityResponse};
39893879
use std::collections::HashMap;
39903880

@@ -4963,36 +4853,6 @@ mod tests {
49634853
);
49644854
}
49654855

4966-
// --- pnpm_lock_resolves: needle at byte 0 ------------------------------
4967-
// The boundary probe reads the char BEFORE the match; a match at the very
4968-
// start of the text has none (`None => true`). A regression that indexes
4969-
// `text[..pos - 1]` unconditionally would underflow/panic here.
4970-
4971-
#[test]
4972-
fn pnpm_lock_resolves_needle_at_start_of_text() {
4973-
// pos == 0, plain v9 spelling: no preceding char is a valid boundary.
4974-
assert!(pnpm_lock_resolves("left-pad@1.3.0:\n", "left-pad", "1.3.0"));
4975-
// pos == 0, v5/v6 `/name/version` and `/name@version` spellings: the
4976-
// leading `/` delimiter itself has nothing before it.
4977-
assert!(pnpm_lock_resolves(
4978-
"/left-pad/1.3.0:\n",
4979-
"left-pad",
4980-
"1.3.0"
4981-
));
4982-
assert!(pnpm_lock_resolves(
4983-
"/left-pad@1.3.0:\n",
4984-
"left-pad",
4985-
"1.3.0"
4986-
));
4987-
// Still boundary-checked at the start of text: a scoped tail whose
4988-
// name begins mid-token must NOT match.
4989-
assert!(!pnpm_lock_resolves(
4990-
"@scope/left-pad@1.3.0:\n",
4991-
"left-pad",
4992-
"1.3.0"
4993-
));
4994-
}
4995-
49964856
// --- write_all_patch_blobs ---------------------------------------------
49974857
// The per-patch fan-out over write_blob_entry: the FIRST bad entry must
49984858
// fail the whole patch (Err(())) and leave nothing outside the blobs

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,7 +2092,6 @@ mod tests {
20922092
wrap_tokens, wrap_words, TAKEOVER_INFO_CODES,
20932093
};
20942094
use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY};
2095-
use socket_patch_core::constants::npm_family;
20962095
use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES;
20972096
use socket_patch_core::patch::redirect::DepOverride;
20982097
use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV;
@@ -3316,25 +3315,54 @@ mod tests {
33163315
}
33173316

33183317
#[test]
3319-
fn redirect_candidates_match_the_shared_npm_family_table() {
3320-
// Drift guard, both directions, without classifying the non-npm
3321-
// rows: every table row flagged redirect_candidate must be in the
3322-
// candidate list, and no npm-family row NOT so flagged may appear
3323-
// (binary candidates are read separately).
3324-
for name in npm_family::names_with(|r| r.redirect_candidate) {
3325-
assert!(
3326-
REDIRECT_CANDIDATE_FILES.contains(&name),
3327-
"{name} is flagged redirect_candidate but missing from \
3328-
REDIRECT_CANDIDATE_FILES"
3329-
);
3330-
}
3331-
for name in npm_family::names_with(|r| !r.redirect_candidate) {
3332-
assert!(
3333-
!REDIRECT_CANDIDATE_FILES.contains(&name),
3334-
"{name} is deliberately NOT a redirect candidate (see the \
3335-
npm_family table) but appears in REDIRECT_CANDIDATE_FILES"
3336-
);
3337-
}
3318+
fn redirect_candidates_are_pinned_by_value() {
3319+
// Hardcoded on purpose: the candidate list is derived from the
3320+
// format registry, so a row dropped (or a HOSTED flag lost) there
3321+
// must fail here instead of silently shrinking what hosted reads.
3322+
assert_eq!(
3323+
*REDIRECT_CANDIDATE_FILES,
3324+
[
3325+
"package-lock.json",
3326+
"npm-shrinkwrap.json",
3327+
"pnpm-lock.yaml",
3328+
"shrinkwrap.yaml",
3329+
"node_modules/.modules.yaml",
3330+
"yarn.lock",
3331+
".yarnrc.yml",
3332+
"bun.lock",
3333+
"bun.lockb",
3334+
"vlt-lock.json",
3335+
"vlt.json",
3336+
"node_modules/.vlt-lock.json",
3337+
"requirements.txt",
3338+
"uv.lock",
3339+
"poetry.lock",
3340+
"pdm.lock",
3341+
"Pipfile.lock",
3342+
"pyproject.toml",
3343+
"hatch.toml",
3344+
"Cargo.toml",
3345+
"Cargo.lock",
3346+
".cargo/config.toml",
3347+
".cargo/config",
3348+
"composer.lock",
3349+
"nuget.config",
3350+
"packages.lock.json",
3351+
"Gemfile",
3352+
"Gemfile.lock",
3353+
"gems.rb",
3354+
"gems.locked",
3355+
"go.mod",
3356+
"go.sum",
3357+
"pom.xml",
3358+
".mvn/maven.config",
3359+
".mvn/checksums/checksums.sha256",
3360+
"settings.gradle",
3361+
"settings.gradle.kts",
3362+
"build.gradle",
3363+
"build.gradle.kts",
3364+
]
3365+
);
33383366
}
33393367
// ── Human-output formatting ────────────────────────────────────────────
33403368

‎crates/socket-patch-cli/src/commands/vendored_backend/repair.rs‎

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use std::path::{Path, PathBuf};
2828

2929
use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
3030
use socket_patch_core::constants::SOCKET_DIR;
31+
use socket_patch_core::formats::registry;
3132
use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
3233
use socket_patch_core::patch::copy_tree::remove_tree;
3334
use socket_patch_core::utils::fs::{
@@ -57,36 +58,6 @@ struct Candidate {
5758
reason: &'static str,
5859
}
5960

60-
/// Files the vendor backends rewire — the search space for
61-
/// `.socket/vendor/<eco>/<uuid>/<leaf>` references. The Python locks the
62-
/// root LISTS (`pylock*.toml`, `*.py.lock` + script) and the requirements
63-
/// `-r` include tree are appended at scan time.
64-
const WIRING_FILES: &[&str] = &[
65-
"vlt-lock.json",
66-
"package-lock.json",
67-
"npm-shrinkwrap.json",
68-
"pnpm-lock.yaml",
69-
"yarn.lock",
70-
"bun.lock",
71-
"package.json",
72-
"Cargo.toml",
73-
"Cargo.lock",
74-
// Pre-v5 vendored cargo wiring (migrated into Cargo.toml on re-run).
75-
".cargo/config.toml",
76-
".cargo/config",
77-
"go.mod",
78-
"composer.json",
79-
"composer.lock",
80-
"Gemfile",
81-
"Gemfile.lock",
82-
"uv.lock",
83-
"pyproject.toml",
84-
"poetry.lock",
85-
"pdm.lock",
86-
"Pipfile.lock",
87-
"requirements.txt",
88-
];
89-
9061
/// Scan the wiring-bearing files for vendored-artifact references,
9162
/// returning deduped `(ecosystem, uuid, artifact relpath)` triples. Pure
9263
/// 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,
148119
}
149120

150121
/// Every wiring-bearing file name the vendor backends may rewrite, relative
151-
/// to `project_root`: [`WIRING_FILES`], vlt importer manifests, the Python
122+
/// to `project_root`: the registry's vendored wiring files
123+
/// ([`registry::VENDORED`]), vlt importer manifests, the Python
152124
/// locks the root lists (and their scripts) and the requirements `-r`
153125
/// include tree. Sorted and deduplicated; entries need not exist.
154126
async fn wiring_files(project_root: &Path) -> Vec<String> {
155-
let mut files: Vec<String> = WIRING_FILES
156-
.iter()
157-
.map(|file| (*file).to_string())
127+
let mut files: Vec<String> = registry::paths_with(registry::VENDORED)
128+
.into_iter()
129+
.map(str::to_string)
158130
.collect();
159131
files.extend(vendor::vlt_lock::vlt_importer_package_jsons(project_root).await);
160132
if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(project_root) {

0 commit comments

Comments
 (0)