Skip to content

Commit a77ecad

Browse files
committed
Merge remote-tracking branch 'origin/release/v5-prerelease' into v5/waste-w4
# Conflicts: # CHANGELOG.md
2 parents 6f61425 + 14a9cb0 commit a77ecad

44 files changed

Lines changed: 4716 additions & 4057 deletions

Some content is hidden

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

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,6 +1843,19 @@ into the new version's section — see docs/releasing.md.
18431843
file qualifier) is refused from its reference, before its bytes are
18441844
downloaded: `auto` still warns `vendor_prebuilt_unavailable` and builds
18451845
the wheel locally, `service` still refuses.
1846+
- **One owner rule for the patch stores.** `list`, `vex`, `scan`'s
1847+
`updates[]`, `rollback` and `remove` now read the
1848+
manifest and the vendor ledger (plus, in `vex`, the hosted records)
1849+
through one view (`socket_patch_core::ledgers`) with one precedence:
1850+
manifest, then vendor ledger, then hosted records, by ledger key; a
1851+
manifest key claims every vendor entry filed under it or naming it as
1852+
base purl. Visible differences: `scan`'s `updates[]` no longer folds a
1853+
vendor entry the manifest claims by base purl; `vex` treats every vendor
1854+
entry a manifest key claims as a fallback copy of that key's record (a
1855+
second variant of the same base purl used to become its own candidate).
1856+
`get`'s installed-version narrowing now uses
1857+
`scan`'s lockfile and vendored-ledger discovery, so a corrupt vendor
1858+
ledger falls back to the committed artifacts there too.
18461859
- **The npm crawl skips tagged cache directories.** The walk that finds
18471860
workspace `node_modules` trees no longer descends into a directory that
18481861
carries a [Cache Directory Tagging](https://bford.info/cachedir/)

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex
3030

3131
**Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update).
3232

33-
**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`src/hosted_memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it.
33+
**Internal `hosted-bundle` subcommand.** `socket-patch hosted-bundle` is a hidden, INTERNAL parity/debug harness for the in-memory hosted engine (`socket-patch-core` `src/hosted/memory/`, the engine the Node addon embeds): it reads a JSON bundle `{"files": {path: text}, "binaryFiles"?: {path: base64}, "presentOnly"?: [path], "symlinks"?: [path], "projectRoots"?: [dir], "pipenvMajor"?: n, "batchSize"?: n}` on stdin, queries the authenticated org API built from `--api-url` / `--api-token` / `--org` only (both of the latter are required; no public-proxy fallback), and prints the engine result — or `{"status":"error","error":{"code","message"}}` with exit 1 (exit 2 for unusable input or missing credentials). It never touches the filesystem. Its name, input and output carry NO stability guarantee; do not script it.
3434

3535
## Global arguments
3636

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! The project a command reads, loaded lazily and at most once per run:
2+
//! the patch stores ([`LoadedLedgers`]: manifest + both ledgers), the lock
3+
//! set (the lockfile inventory and its refused npm layouts) and the
4+
//! lockfile wiring discovery. `scan`, `vendor`, `vex`, `list` and `get`
5+
//! read these through one [`ProjectContext`] instead of each re-loading
6+
//! and re-merging them its own way.
7+
//!
8+
//! The lock set and the discovery read `--cwd` through one
9+
//! [`DiskSnapshot`], so each lock and config file is read once and both see
10+
//! the same bytes.
11+
//!
12+
//! Everything here is a read-only snapshot. A command that writes a store
13+
//! under the apply lock (the hosted engine, rollback, remove) re-loads it
14+
//! under that lock instead of trusting a pre-lock snapshot, and an embedded
15+
//! `--vex` after the writes loads its own inputs.
16+
17+
use std::path::PathBuf;
18+
19+
use socket_patch_core::ledgers::{Ledgers, LoadedLedgers};
20+
use socket_patch_core::vendor::lock_inventory::{
21+
DiskSnapshot, LockfileEntry, ProjectView, UnsupportedNpmLayout,
22+
};
23+
use socket_patch_core::vex::discover::Discovery;
24+
use tokio::sync::OnceCell;
25+
26+
use crate::args::GlobalArgs;
27+
28+
/// The project's lockfile inventory and the npm layouts it refused.
29+
pub(crate) struct LockSet {
30+
pub(crate) entries: Vec<LockfileEntry>,
31+
pub(crate) unsupported: Vec<UnsupportedNpmLayout>,
32+
}
33+
34+
pub(crate) struct ProjectContext<'a> {
35+
pub(crate) common: &'a GlobalArgs,
36+
/// Where the ledgers live: the manifest's project (see
37+
/// [`GlobalArgs::project_root`]).
38+
pub(crate) root: PathBuf,
39+
snapshot: DiskSnapshot<'a>,
40+
ledgers: OnceCell<LoadedLedgers>,
41+
locks: OnceCell<LockSet>,
42+
discovery: OnceCell<Discovery>,
43+
}
44+
45+
impl<'a> ProjectContext<'a> {
46+
pub(crate) fn new(common: &'a GlobalArgs) -> Self {
47+
Self::rooted(common, common.project_root())
48+
}
49+
50+
/// A context whose ledgers load from `root` (commands that read the
51+
/// ledgers of `--cwd` rather than of the manifest's project).
52+
pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self {
53+
Self {
54+
common,
55+
root,
56+
snapshot: DiskSnapshot::new(&common.cwd),
57+
ledgers: OnceCell::new(),
58+
locks: OnceCell::new(),
59+
discovery: OnceCell::new(),
60+
}
61+
}
62+
63+
/// The three stores, each with its own load outcome.
64+
pub(crate) async fn loaded(&self) -> &LoadedLedgers {
65+
self.ledgers
66+
.get_or_init(|| async {
67+
LoadedLedgers::load(&self.root, &self.common.resolved_manifest_path()).await
68+
})
69+
.await
70+
}
71+
72+
/// The readable stores as one view (a failed store reads as absent).
73+
pub(crate) async fn ledgers(&self) -> Ledgers<'_> {
74+
self.loaded().await.view()
75+
}
76+
77+
/// The lockfile inventory of `--cwd`.
78+
pub(crate) async fn locks(&self) -> &LockSet {
79+
self.locks
80+
.get_or_init(|| async {
81+
let (entries, unsupported) =
82+
socket_patch_core::vendor::lock_inventory::inventory_project_diagnosed_in(
83+
&ProjectView::Snapshot(&self.snapshot),
84+
)
85+
.await;
86+
LockSet {
87+
entries,
88+
unsupported,
89+
}
90+
})
91+
.await
92+
}
93+
94+
/// The lockfile wiring discovery of `--cwd` ([`super::discover_wiring`]).
95+
pub(crate) async fn discovery(&self) -> &Discovery {
96+
self.discovery
97+
.get_or_init(|| super::discover_wiring_in(self.common, &self.snapshot))
98+
.await
99+
}
100+
}

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

Lines changed: 20 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages;
1313
use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
1414
use socket_patch_core::formats::pnpm::PnpmLock;
1515
use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
16+
pub(crate) use socket_patch_core::manifest::records::record_from_patch_response;
17+
use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest};
1618
use socket_patch_core::manifest::schema::{
17-
PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo,
19+
PatchFileInfo, PatchManifest, PatchRecord,
1820
};
1921
use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants};
2022
use socket_patch_core::patch::apply_lock::{LockError, LockGuard};
@@ -366,43 +368,6 @@ async fn unwind_new_blobs(blobs_dir: &Path, hashes: &[String]) {
366368
}
367369
}
368370

369-
/// Convert the API-shaped vulnerability map on `PatchResponse` into the
370-
/// serialization-shaped map stored in the manifest.
371-
fn vulnerabilities_for_manifest(
372-
vulns: &HashMap<String, VulnerabilityResponse>,
373-
) -> HashMap<String, VulnerabilityInfo> {
374-
vulns
375-
.iter()
376-
.map(|(id, v)| {
377-
(
378-
id.clone(),
379-
VulnerabilityInfo {
380-
cves: v.cves.clone(),
381-
summary: v.summary.clone(),
382-
severity: v.severity.clone(),
383-
description: v.description.clone(),
384-
},
385-
)
386-
})
387-
.collect()
388-
}
389-
390-
/// Build the `PatchRecord` that will be inserted into the manifest for
391-
/// `patch`. `files` is the (purl-keyed) before/after-hash map the
392-
/// caller built — semantics for what counts as a "patchable file" differ
393-
/// between the get and download flows, so the caller owns that decision.
394-
fn build_patch_record(patch: &PatchResponse, files: HashMap<String, PatchFileInfo>) -> PatchRecord {
395-
PatchRecord {
396-
uuid: patch.uuid.clone(),
397-
exported_at: patch.published_at.clone(),
398-
files,
399-
vulnerabilities: vulnerabilities_for_manifest(&patch.vulnerabilities),
400-
description: patch.description.clone(),
401-
license: patch.license.clone(),
402-
tier: patch.tier.clone(),
403-
}
404-
}
405-
406371
/// Build a file map keyed by path, keeping only files that carry BOTH
407372
/// hashes — the rule used ONLY for installed-distribution matching in
408373
/// [`filter_to_installed_releases`]. New files (no `beforeHash`) can
@@ -426,44 +391,6 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap<String, PatchFileInf
426391
files
427392
}
428393

429-
/// Build the manifest-shaped `files` map from a fetched patch view,
430-
/// keeping EVERY file the patch touches — including net-new files the
431-
/// patch ADDS, which carry an `afterHash` but no `beforeHash`. A new
432-
/// file is recorded with an empty-string `beforeHash` sentinel, the same
433-
/// convention `save_and_apply_patch`'s by-uuid path relies on: apply
434-
/// treats an empty `beforeHash` as "create this file" and
435-
/// [`select_installed_variants`] treats it as non-discriminating.
436-
///
437-
/// This is the shared record-building rule for the scan/download/vendor
438-
/// flows AND the single-uuid apply path, so `get <uuid>` and
439-
/// `scan`/`apply`/`vendor` all record and write the same set of files.
440-
/// A both-hashes rule here would drop every added file (e.g. a whole-crate
441-
/// cargo export where ALL files lack a `beforeHash`, recorded as `files:{}`
442-
/// while reporting `applied:1`).
443-
fn files_for_manifest(patch: &PatchResponse) -> HashMap<String, PatchFileInfo> {
444-
let mut files = HashMap::new();
445-
for (file_path, file_info) in &patch.files {
446-
if let Some(after) = &file_info.after_hash {
447-
files.insert(
448-
file_path.clone(),
449-
PatchFileInfo {
450-
before_hash: file_info.before_hash.clone().unwrap_or_default(),
451-
after_hash: after.clone(),
452-
},
453-
);
454-
}
455-
}
456-
files
457-
}
458-
459-
/// `(purl, manifest record)` from a fetched patch view — retains
460-
/// patch-added new files via [`files_for_manifest`].
461-
pub(crate) fn record_from_patch_response(patch: &PatchResponse) -> (String, PatchRecord) {
462-
(
463-
patch.purl.clone(),
464-
build_patch_record(patch, files_for_manifest(patch)),
465-
)
466-
}
467394

468395
#[derive(Args)]
469396
pub struct GetArgs {
@@ -1523,9 +1450,10 @@ struct InstalledNarrowing {
15231450
/// installed copy (CI manifest-maintenance);
15241451
/// * hosted/vendored modes only: resolved in the project lockfile(s)
15251452
/// (hosted rewrites the lock; vendored auto-fetches pristine) or claimed
1526-
/// by the vendor ledger (fresh-clone re-vendor) — mirroring scan's
1527-
/// lockfile/vendored-ledger discovery supplements, including their
1528-
/// global-scan gate.
1453+
/// by the vendor ledger (fresh-clone re-vendor) — scan's own
1454+
/// lockfile/vendored-ledger discovery supplements (a corrupt vendor
1455+
/// ledger falls back to the committed artifacts, as in scan), including
1456+
/// their global-scan gate.
15291457
///
15301458
/// PnP layouts are surfaced, never silently misreported: yarn PnP packages
15311459
/// are structurally unpatchable in every mode (skip records carry
@@ -1564,24 +1492,27 @@ async fn filter_to_installed_purls(
15641492
let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await;
15651493
let mut present: HashSet<String> = found.keys().map(|k| canon(k)).collect();
15661494

1495+
let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone());
15671496
// Manifest membership counts as presence (read-only probe: a corrupt
15681497
// manifest degrades to "no extension" here — the download path's
15691498
// fail-closed read still guards every write).
1570-
if let Ok(Some(manifest)) = read_manifest(&common.resolved_manifest_path()).await {
1499+
if let Some(manifest) = ctx.ledgers().await.manifest {
15711500
present.extend(manifest.patches.keys().map(|k| canon(k)));
15721501
}
15731502

1574-
// Lockfile + vendor-ledger supplements (scan's discovery gate: never on
1575-
// global scans, which target the machine tree, not this project).
1503+
// scan's lockfile + vendored-ledger discovery supplements (and their
1504+
// gate: never on global scans, which target the machine tree, not this
1505+
// project).
15761506
let mut pnp_diags: Vec<lock_inventory::UnsupportedNpmLayout> = Vec::new();
1577-
if !common.global && common.global_prefix.is_none() {
1578-
let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await;
1579-
pnp_diags = unsupported;
1507+
if !common.is_global() {
1508+
let supplement = super::scan::project_lockfile_supplement(&ctx, &[], None).await;
1509+
pnp_diags = supplement.unsupported;
15801510
if mode != super::scan::ScanMode::Agent {
1581-
present.extend(entries.iter().map(|e| canon(&e.purl)));
1582-
if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await {
1583-
present.extend(state.entries.values().map(|e| canon(&e.base_purl)));
1584-
}
1511+
present.extend(supplement.entries.iter().map(|e| canon(&e.purl)));
1512+
let vendored =
1513+
super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor)
1514+
.await;
1515+
present.extend(vendored.iter().map(|p| canon(&p.purl)));
15851516
}
15861517
}
15871518

0 commit comments

Comments
 (0)