diff --git a/CHANGELOG.md b/CHANGELOG.md index f8eb6bb3..4a0e6718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1817,6 +1817,19 @@ into the new version's section — see docs/releasing.md. ### Changed +- **One owner rule for the patch stores.** `list`, `vex`, `scan`'s + `updates[]`, `rollback` and `remove` now read the + manifest and the vendor ledger (plus, in `vex`, the hosted records) + through one view (`socket_patch_core::ledgers`) with one precedence: + manifest, then vendor ledger, then hosted records, by ledger key; a + manifest key claims every vendor entry filed under it or naming it as + base purl. Visible differences: `scan`'s `updates[]` no longer folds a + vendor entry the manifest claims by base purl; `vex` treats every vendor + entry a manifest key claims as a fallback copy of that key's record (a + second variant of the same base purl used to become its own candidate). + `get`'s installed-version narrowing now uses + `scan`'s lockfile and vendored-ledger discovery, so a corrupt vendor + ledger falls back to the committed artifacts there too. - **The npm crawl skips tagged cache directories.** The walk that finds workspace `node_modules` trees no longer descends into a directory that carries a [Cache Directory Tagging](https://bford.info/cachedir/) diff --git a/Cargo.lock b/Cargo.lock index 7b687168..4475ec20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,6 +1816,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "toml_edit", "uuid", "walkdir", @@ -1832,7 +1833,6 @@ dependencies = [ "napi-derive", "serde", "serde_json", - "socket-patch-cli", "socket-patch-core", "tokio", "tokio-util", diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 45db1b71..4ce5cb3a 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -30,7 +30,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex **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). -**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. +**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. ## Global arguments diff --git a/crates/socket-patch-cli/src/commands/context.rs b/crates/socket-patch-cli/src/commands/context.rs new file mode 100644 index 00000000..3ae3b005 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/context.rs @@ -0,0 +1,100 @@ +//! The project a command reads, loaded lazily and at most once per run: +//! the patch stores ([`LoadedLedgers`]: manifest + both ledgers), the lock +//! set (the lockfile inventory and its refused npm layouts) and the +//! lockfile wiring discovery. `scan`, `vendor`, `vex`, `list` and `get` +//! read these through one [`ProjectContext`] instead of each re-loading +//! and re-merging them its own way. +//! +//! The lock set and the discovery read `--cwd` through one +//! [`DiskSnapshot`], so each lock and config file is read once and both see +//! the same bytes. +//! +//! Everything here is a read-only snapshot. A command that writes a store +//! under the apply lock (the hosted engine, rollback, remove) re-loads it +//! under that lock instead of trusting a pre-lock snapshot, and an embedded +//! `--vex` after the writes loads its own inputs. + +use std::path::PathBuf; + +use socket_patch_core::ledgers::{Ledgers, LoadedLedgers}; +use socket_patch_core::vendor::lock_inventory::{ + DiskSnapshot, LockfileEntry, ProjectView, UnsupportedNpmLayout, +}; +use socket_patch_core::vex::discover::Discovery; +use tokio::sync::OnceCell; + +use crate::args::GlobalArgs; + +/// The project's lockfile inventory and the npm layouts it refused. +pub(crate) struct LockSet { + pub(crate) entries: Vec, + pub(crate) unsupported: Vec, +} + +pub(crate) struct ProjectContext<'a> { + pub(crate) common: &'a GlobalArgs, + /// Where the ledgers live: the manifest's project (see + /// [`GlobalArgs::project_root`]). + pub(crate) root: PathBuf, + snapshot: DiskSnapshot<'a>, + ledgers: OnceCell, + locks: OnceCell, + discovery: OnceCell, +} + +impl<'a> ProjectContext<'a> { + pub(crate) fn new(common: &'a GlobalArgs) -> Self { + Self::rooted(common, common.project_root()) + } + + /// A context whose ledgers load from `root` (commands that read the + /// ledgers of `--cwd` rather than of the manifest's project). + pub(crate) fn rooted(common: &'a GlobalArgs, root: PathBuf) -> Self { + Self { + common, + root, + snapshot: DiskSnapshot::new(&common.cwd), + ledgers: OnceCell::new(), + locks: OnceCell::new(), + discovery: OnceCell::new(), + } + } + + /// The three stores, each with its own load outcome. + pub(crate) async fn loaded(&self) -> &LoadedLedgers { + self.ledgers + .get_or_init(|| async { + LoadedLedgers::load(&self.root, &self.common.resolved_manifest_path()).await + }) + .await + } + + /// The readable stores as one view (a failed store reads as absent). + pub(crate) async fn ledgers(&self) -> Ledgers<'_> { + self.loaded().await.view() + } + + /// The lockfile inventory of `--cwd`. + pub(crate) async fn locks(&self) -> &LockSet { + self.locks + .get_or_init(|| async { + let (entries, unsupported) = + socket_patch_core::vendor::lock_inventory::inventory_project_diagnosed_in( + &ProjectView::Snapshot(&self.snapshot), + ) + .await; + LockSet { + entries, + unsupported, + } + }) + .await + } + + /// The lockfile wiring discovery of `--cwd` ([`super::discover_wiring`]). + pub(crate) async fn discovery(&self) -> &Discovery { + self.discovery + .get_or_init(|| super::discover_wiring_in(self.common, &self.snapshot)) + .await + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ed9200e8..55d9fef7 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -13,8 +13,10 @@ 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}; +pub(crate) use socket_patch_core::manifest::records::record_from_patch_response; +use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest}; use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, + PatchFileInfo, PatchManifest, PatchRecord, }; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants}; use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; @@ -366,43 +368,6 @@ async fn unwind_new_blobs(blobs_dir: &Path, hashes: &[String]) { } } -/// Convert the API-shaped vulnerability map on `PatchResponse` into the -/// serialization-shaped map stored in the manifest. -fn vulnerabilities_for_manifest( - vulns: &HashMap, -) -> HashMap { - vulns - .iter() - .map(|(id, v)| { - ( - id.clone(), - VulnerabilityInfo { - cves: v.cves.clone(), - summary: v.summary.clone(), - severity: v.severity.clone(), - description: v.description.clone(), - }, - ) - }) - .collect() -} - -/// Build the `PatchRecord` that will be inserted into the manifest for -/// `patch`. `files` is the (purl-keyed) before/after-hash map the -/// caller built — semantics for what counts as a "patchable file" differ -/// between the get and download flows, so the caller owns that decision. -fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { - PatchRecord { - uuid: patch.uuid.clone(), - exported_at: patch.published_at.clone(), - files, - vulnerabilities: vulnerabilities_for_manifest(&patch.vulnerabilities), - description: patch.description.clone(), - license: patch.license.clone(), - tier: patch.tier.clone(), - } -} - /// Build a file map keyed by path, keeping only files that carry BOTH /// hashes — the rule used ONLY for installed-distribution matching in /// [`filter_to_installed_releases`]. New files (no `beforeHash`) can @@ -426,44 +391,6 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap` and -/// `scan`/`apply`/`vendor` all record and write the same set of files. -/// A both-hashes rule here would drop every added file (e.g. a whole-crate -/// cargo export where ALL files lack a `beforeHash`, recorded as `files:{}` -/// while reporting `applied:1`). -fn files_for_manifest(patch: &PatchResponse) -> HashMap { - let mut files = HashMap::new(); - for (file_path, file_info) in &patch.files { - if let Some(after) = &file_info.after_hash { - files.insert( - file_path.clone(), - PatchFileInfo { - before_hash: file_info.before_hash.clone().unwrap_or_default(), - after_hash: after.clone(), - }, - ); - } - } - files -} - -/// `(purl, manifest record)` from a fetched patch view — retains -/// patch-added new files via [`files_for_manifest`]. -pub(crate) fn record_from_patch_response(patch: &PatchResponse) -> (String, PatchRecord) { - ( - patch.purl.clone(), - build_patch_record(patch, files_for_manifest(patch)), - ) -} #[derive(Args)] pub struct GetArgs { @@ -1523,9 +1450,10 @@ struct InstalledNarrowing { /// installed copy (CI manifest-maintenance); /// * hosted/vendored modes only: resolved in the project lockfile(s) /// (hosted rewrites the lock; vendored auto-fetches pristine) or claimed -/// by the vendor ledger (fresh-clone re-vendor) — mirroring scan's -/// lockfile/vendored-ledger discovery supplements, including their -/// global-scan gate. +/// by the vendor ledger (fresh-clone re-vendor) — scan's own +/// lockfile/vendored-ledger discovery supplements (a corrupt vendor +/// ledger falls back to the committed artifacts, as in scan), including +/// their global-scan gate. /// /// PnP layouts are surfaced, never silently misreported: yarn PnP packages /// are structurally unpatchable in every mode (skip records carry @@ -1564,24 +1492,27 @@ async fn filter_to_installed_purls( let found = find_packages_for_rollback(&partitioned, &common.crawler_options(), true).await; let mut present: HashSet = found.keys().map(|k| canon(k)).collect(); + let ctx = super::context::ProjectContext::rooted(common, common.cwd.clone()); // Manifest membership counts as presence (read-only probe: a corrupt // manifest degrades to "no extension" here — the download path's // fail-closed read still guards every write). - if let Ok(Some(manifest)) = read_manifest(&common.resolved_manifest_path()).await { + if let Some(manifest) = ctx.ledgers().await.manifest { present.extend(manifest.patches.keys().map(|k| canon(k))); } - // Lockfile + vendor-ledger supplements (scan's discovery gate: never on - // global scans, which target the machine tree, not this project). + // scan's lockfile + vendored-ledger discovery supplements (and their + // gate: never on global scans, which target the machine tree, not this + // project). let mut pnp_diags: Vec = Vec::new(); - if !common.global && common.global_prefix.is_none() { - let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await; - pnp_diags = unsupported; + if !common.is_global() { + let supplement = super::scan::project_lockfile_supplement(&ctx, &[], None).await; + pnp_diags = supplement.unsupported; if mode != super::scan::ScanMode::Agent { - present.extend(entries.iter().map(|e| canon(&e.purl))); - if let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await { - present.extend(state.entries.values().map(|e| canon(&e.base_purl))); - } + present.extend(supplement.entries.iter().map(|e| canon(&e.purl))); + let vendored = + super::scan::project_vendored_supplement(common, &[], &ctx.loaded().await.vendor) + .await; + present.extend(vendored.iter().map(|p| canon(&p.purl))); } } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index c22dccbd..8fc77fab 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,12 +1,11 @@ use std::path::Path; use clap::Args; -use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::patch::redirect::RedirectState; use socket_patch_core::telemetry::track_patch_listed; -use socket_patch_core::vendor::state::{VendorEntry, VENDOR_STATE_REL}; +use socket_patch_core::vendor::state::{VendorState, VENDOR_STATE_REL}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{ @@ -19,25 +18,21 @@ pub struct ListArgs { pub common: GlobalArgs, } -/// Where a listed record lives. Declaration order is the tie-break order -/// when one purl appears in several stores: coexistence is real state (e.g. -/// an agent-applied patch alongside live hosted wiring), so every copy is -/// shown, labeled apart. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum Source { - /// A `.socket/manifest.json` entry (agent mode). - Manifest, - /// A hosted pin the lockfiles wire: `scan --mode hosted` keeps no - /// ledger and never writes the manifest, so the lockfiles are the only - /// record of a hosted patch. - Hosted, - /// A vendor-ledger record: vendored mode is manifest-free, so every - /// `scan`/`get --mode vendored` patch lives ONLY in - /// `.socket/vendor/state.json`, as a `detached` entry's embedded record - /// (the hosted rule again — a vendored-only project lists and exits 0). - /// A standalone `vendor` entry's fallback copy lists here too once no - /// manifest entry covers it — the checkout `vex` attests from it. - Vendored, +// Where a listed record lives (see `socket_patch_core::ledgers`): the +// manifest (agent mode); the hosted redirect ledger, where `scan --mode +// hosted` recorded its patches; or the vendor ledger, where vendored mode +// keeps every `scan`/`get --mode vendored` patch as a `detached` entry's +// embedded record (a standalone `vendor` entry's fallback copy lists too +// once no manifest entry covers it — the checkout `vex` attests from it). +use socket_patch_core::ledgers::Store as Source; + +/// The display order of one purl's copies: manifest, hosted, vendored. +fn display_rank(source: Source) -> u8 { + match source { + Source::Manifest => 0, + Source::Hosted => 1, + Source::Vendored => 2, + } } /// The `(mode, ledger)` label pair for a vendor-ledger record — the shared @@ -109,53 +104,46 @@ impl HostedListing { } } -/// Every listable record from all three stores, in a stable order: by -/// PURL, then manifest < hosted < vendored when one purl appears in more -/// than one. The record maps (`HashMap` manifest and vendor ledger / -/// hosted pins) never impose an order shared consumers could -/// diff, so the sort here is the contract. Only vendor entries whose -/// embedded record stands on its own fold in -/// ([`crate::commands::vendor_record_is_unowned`], the rule `vex` attests -/// by): a `detached` entry always (coexisting with a manifest entry is real -/// state, shown labeled apart), a standalone `vendor` entry's fallback copy -/// only when the manifest does not cover it — while it does, the manifest's -/// record IS that entry's record and listing the copy would double-list the -/// purl. A legacy entry with no embedded record never folds in. +/// Every listable record, in a stable order: by PURL, then +/// [`display_rank`] when one purl appears in more than one store. The +/// manifest and the vendor ledger go through the shared owner rule +/// ([`socket_patch_core::ledgers::Ledgers::listed`]: coexisting copies are +/// real state, shown labeled apart; a claimed fallback copy and a legacy +/// entry with no embedded record never list); every hosted pin lists as +/// its own copy (the lockfiles are the only hosted record). The record +/// maps never impose an order shared consumers could diff, so the sort +/// here is the contract. fn combined_entries<'a>( manifest: Option<&'a PatchManifest>, hosted: &'a [HostedListing], - vendor: Option<&'a std::collections::HashMap>, + vendor: Option<&'a VendorState>, ) -> Vec> { - let mut entries: Vec> = Vec::new(); - if let Some(manifest) = manifest { - entries.extend(manifest.patches.iter().map(|(purl, record)| ListEntry { - purl, - record, - source: Source::Manifest, + let ledgers = socket_patch_core::ledgers::Ledgers { + manifest, + vendor, + redirect: None, + }; + let mut entries: Vec> = ledgers + .listed() + .into_iter() + .map(|l| ListEntry { + purl: l.key, + record: l.record, + source: l.store, lockfiles: &[], - })); - } + }) + .collect(); entries.extend(hosted.iter().map(|h| ListEntry { purl: &h.purl, record: &h.record, source: Source::Hosted, lockfiles: &h.lockfiles, })); - if let Some(vendor) = vendor { - entries.extend(vendor.iter().filter_map(|(purl, entry)| { - let record = entry - .record - .as_ref() - .filter(|_| crate::commands::vendor_record_is_unowned(purl, entry, manifest))?; - Some(ListEntry { - purl, - record, - source: Source::Vendored, - lockfiles: &[], - }) - })); - } - entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.source.cmp(&b.source))); + entries.sort_by(|a, b| { + a.purl + .cmp(b.purl) + .then(display_rank(a.source).cmp(&display_rank(b.source))) + }); entries } @@ -379,8 +367,12 @@ pub async fn run(args: ListArgs) -> i32 { // unparseable), and any other `Err` (genuine I/O failure). No stat // pre-check: it would report any stat failure as `manifest_not_found` // and open a TOCTOU window. - let manifest = match read_manifest(&manifest_path).await { - Ok(manifest) => manifest, + // One load of the three stores, all from the SAME project as the + // manifest (see below); each keeps list's own posture. + let ctx = crate::commands::context::ProjectContext::new(&args.common); + let loaded = ctx.loaded().await; + let manifest = match &loaded.manifest { + Ok(manifest) => manifest.as_ref(), Err(e) => { // `InvalidData` (bad JSON or schema) is the contract's // `manifest_invalid`; everything else is `manifest_unreadable` @@ -394,7 +386,7 @@ pub async fn run(args: ListArgs) -> i32 { emit_error( &args, code, - manifest_error_message(&manifest_path, &e), + manifest_error_message(&manifest_path, e), Vec::new(), ); return 1; @@ -412,25 +404,23 @@ pub async fn run(args: ListArgs) -> i32 { // hosted pins it still describes; a malformed one degrades to "nothing // to consult", surfaced on stderr unless --silent, or in the envelope's // `warnings[]` under --json. - let project_root = args.common.project_root(); let mut warnings: Vec = Vec::new(); - let legacy_redirect = - match socket_patch_core::patch::redirect::load_redirect_state(&project_root).await { - Ok(state) => state, - Err(corrupt) => { - if args.common.json { - warnings.push(RunWarning { - code: "redirect_ledger_corrupt".to_string(), - detail: corrupt.to_string(), - }); - } else if !args.common.silent { - eprintln!("Warning: {corrupt}"); - } - None + let legacy_redirect = match &loaded.redirect { + Ok(state) => state.as_ref(), + Err(corrupt) => { + if args.common.json { + warnings.push(RunWarning { + code: "redirect_ledger_corrupt".to_string(), + detail: corrupt.to_string(), + }); + } else if !args.common.silent { + eprintln!("Warning: {corrupt}"); } - }; - let inventory = crate::commands::hosted_inventory(&args.common, &project_root).await; - let hosted = HostedListing::from_pins(&inventory.pins, legacy_redirect.as_ref()); + None + } + }; + let inventory = crate::commands::hosted_inventory(&args.common, &ctx.root).await; + let hosted = HostedListing::from_pins(&inventory.pins, legacy_redirect); // Contested hosted wiring cannot be listed as patches, but it is hosted // state: surface it (stderr / `warnings[]`), never hide it. let contested = inventory.contested_refusal(); @@ -444,17 +434,12 @@ pub async fn run(args: ListArgs) -> i32 { eprintln!("Warning: {}", crate::commands::rollback::capitalize_first(detail)); } } - let vendor_state = - crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; + let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent); // `combined_entries` folds only real records in (a record-less legacy // vendor entry asserts no patch), so entry emptiness is the whole exit // predicate. - let entries = combined_entries( - manifest.as_ref(), - &hosted, - vendor_state.as_ref().map(|s| &s.entries), - ); + let entries = combined_entries(manifest, &hosted, vendor_state); if manifest.is_none() && entries.is_empty() { if let Some(detail) = contested { emit_error(&args, "hosted_wiring_contested", detail, warnings); @@ -470,7 +455,7 @@ pub async fn run(args: ListArgs) -> i32 { // Telemetry: `patch_listed`'s `patches_count` means "manifest patches" // to its consumers, so it counts the manifest ONLY (0 on a ledger-only // project) rather than the listed entries. - let manifest_patch_count = manifest.as_ref().map_or(0, |m| m.patches.len()); + let manifest_patch_count = manifest.map_or(0, |m| m.patches.len()); let (api_token, org_slug) = args.common.telemetry_credentials(); track_patch_listed( manifest_patch_count, @@ -498,6 +483,7 @@ mod tests { //! consumers (PR bots, dashboards) can rely on it. use super::*; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; + use socket_patch_core::vendor::state::VendorEntry; use std::collections::HashMap; /// Envelope for a manifest-only listing (no hosted pins, no vendor @@ -817,6 +803,13 @@ mod tests { /// `record` is given (the manifest-free vendored posture), a legacy /// manifest-tracked entry (no record of its own) otherwise. Built from /// the on-disk JSON shape so the fixture follows the ledger schema. + fn as_state(entries: &HashMap) -> VendorState { + VendorState { + entries: entries.clone(), + ..VendorState::new() + } + } + fn vendor_entry(purl: &str, record: Option) -> VendorEntry { serde_json::from_value(serde_json::json!({ "ecosystem": "npm", @@ -862,7 +855,7 @@ mod tests { let env = build_list_envelope(&combined_entries( Some(&manifest), &pins, - Some(&vendor), + Some(&as_state(&vendor)), )); let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap(); let listed: Vec<(&str, &str)> = v["events"] @@ -896,7 +889,7 @@ mod tests { "the ledger's embedded record is the one listed: {v}" ); - let only = build_list_envelope(&combined_entries(None, &[], Some(&vendor))); + let only = build_list_envelope(&combined_entries(None, &[], Some(&as_state(&vendor)))); let v: serde_json::Value = serde_json::from_str(&only.to_pretty_json()).unwrap(); assert_eq!(v["status"], "success", "{v}"); assert_eq!(v["summary"]["discovered"], 2, "{v}"); @@ -960,7 +953,11 @@ mod tests { }; assert_eq!( - listed(&combined_entries(Some(&manifest), &[], Some(&vendor))), + listed(&combined_entries( + Some(&manifest), + &[], + Some(&as_state(&vendor)) + )), vec![ ( "pkg:npm/left-pad@1.3.0".to_string(), @@ -977,7 +974,7 @@ mod tests { ); // No manifest at all: every fallback copy stands on its own. - let only = listed(&combined_entries(None, &[], Some(&vendor))); + let only = listed(&combined_entries(None, &[], Some(&as_state(&vendor)))); assert_eq!(only.len(), 3, "{only:?}"); assert!( only.iter().all(|(_, mode, _)| mode == "vendored"), diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 87f55213..5e11c94b 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod apply; pub(crate) mod bun_preflight; +pub(crate) mod context; pub(crate) mod fetch_stage; pub mod get; pub mod hosted_bundle; @@ -47,15 +48,26 @@ pub(crate) async fn discover_wiring( common: &crate::args::GlobalArgs, root: &Path, ) -> socket_patch_core::vex::discover::Discovery { - let opts = socket_patch_core::vex::DiscoverOptions { + socket_patch_core::vex::discover_patched_refs_with(root, &discover_options(common)).await +} + +/// [`discover_wiring`] of the snapshot's root, reading through `snapshot`. +pub(crate) async fn discover_wiring_in( + common: &crate::args::GlobalArgs, + snapshot: &socket_patch_core::vendor::lock_inventory::DiskSnapshot<'_>, +) -> socket_patch_core::vex::discover::Discovery { + socket_patch_core::vex::discover_patched_refs_in(snapshot, &discover_options(common)).await +} + +fn discover_options(common: &crate::args::GlobalArgs) -> socket_patch_core::vex::DiscoverOptions { + socket_patch_core::vex::DiscoverOptions { patch_server_origins: common .patch_server_url .iter() .filter(|url| !url.trim().is_empty()) .cloned() .collect(), - }; - socket_patch_core::vex::discover_patched_refs_with(root, &opts).await + } } /// The project's hosted wiring as raw inventory (core @@ -112,17 +124,17 @@ pub(crate) fn hosted_state_from_pins( state } -/// Read-only lenient load of the vendor ledger (`.socket/vendor/state.json`): +/// Read-only lenient view of a loaded vendor ledger (`.socket/vendor/state.json`): /// missing → an empty ledger; malformed/unreadable → `None` with the /// problem surfaced on stderr unless `silent`. A read-only /// consumer (`list`) degrades a broken ledger to nothing-to-consult but /// must say so, while every path that writes or attests from it fails /// closed instead. -pub(crate) async fn load_vendor_state_lenient( - root: &Path, +pub(crate) fn vendor_state_lenient( + loaded: &std::io::Result, silent: bool, -) -> Option { - match socket_patch_core::vendor::load_state(root).await { +) -> Option<&socket_patch_core::vendor::state::VendorState> { + match loaded { Ok(state) => Some(state), Err(e) => { if !silent { @@ -135,29 +147,3 @@ pub(crate) async fn load_vendor_state_lenient( } } -/// Whether a vendor-ledger entry's embedded `record` stands on its own — -/// the rule every reader of embedded records shares (`vex`'s record plan -/// and `list`), so one -/// tree never lists "no patches" while its VEX document attests one. -/// -/// A `detached` entry (every `scan`/`get --mode vendored` entry) has no -/// manifest owner: its record is the only copy. A non-detached entry was -/// written by the manifest-driven standalone `vendor`, which embeds the -/// record as a fallback copy: the manifest record stays authoritative while -/// the manifest covers the entry — its ledger key, or its base purl (the -/// claim `vex_sources`' candidate builder applies) — and the embedded copy -/// stands in only when it does not (a checkout that never committed its -/// manifest, or one that dropped the purl while the lockfile still wires -/// the artifact). `repair` deliberately stays narrower (the copy is used -/// only with no manifest at all): it rebuilds artifacts, and a purl dropped -/// from a live manifest is the reconcile's to revert, not repair's to heal. -pub(crate) fn vendor_record_is_unowned( - key: &str, - entry: &socket_patch_core::vendor::VendorEntry, - manifest: Option<&socket_patch_core::manifest::schema::PatchManifest>, -) -> bool { - entry.detached - || manifest.is_none_or(|m| { - !m.patches.contains_key(key) && !m.patches.contains_key(&entry.base_purl) - }) -} diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 2f7a09a4..3d72b58c 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -23,18 +23,16 @@ use crate::commands::lock_cli::acquire_or_emit; use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, Status}; use crate::ui::plural; -/// Vendor-ledger entries matching a remove identifier (by ledger key, -/// base purl or uuid — `VendorEntry::matches_identifier`), sorted by key -/// for deterministic event order. +/// Vendor-ledger entries matching a remove identifier +/// ([`socket_patch_core::ledgers::Ledgers::matching`]), sorted by key for +/// deterministic event order. fn vendor_entries_matching(state: &VendorState, identifier: &str) -> Vec<(String, VendorEntry)> { - let mut matches: Vec<(String, VendorEntry)> = state - .entries - .iter() - .filter(|(key, entry)| entry.matches_identifier(key, identifier)) - .map(|(k, e)| (k.clone(), e.clone())) - .collect(); - matches.sort_by(|a, b| a.0.cmp(&b.0)); - matches + socket_patch_core::ledgers::Ledgers { + vendor: Some(state), + ..Default::default() + } + .matching(identifier) + .vendor } /// The lockfiles' hosted pins matching a remove identifier (by purl or diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index e49a4051..a84a1b3f 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1310,20 +1310,17 @@ pub async fn run(args: RollbackArgs) -> i32 { vendor_scope.extend(vendor_entries.iter().map(|(k, _)| k.clone())); hosted_scope.extend(redirect_records.iter().map(|(p, _)| p.clone())); } + let ledgers = socket_patch_core::ledgers::Ledgers { + manifest: Some(&manifest), + vendor: vendor_state_result.as_ref().ok(), + redirect: None, + }; for id in &identifiers { - let mut matched = false; - for (purl, patch) in &manifest.patches { - if patch_matches(purl, &patch.uuid, id) { - manifest_scope.insert(purl.clone()); - matched = true; - } - } - for (key, entry) in &vendor_entries { - if entry.matches_identifier(key, id) { - vendor_scope.insert(key.clone()); - matched = true; - } - } + let found = ledgers.matching(id); + let mut matched = !found.is_empty(); + manifest_scope.extend(found.manifest); + vendor_scope.extend(found.vendor.into_iter().map(|(k, _)| k)); + // Hosted pins live in the lockfiles, not in a store. for (purl, uuid) in &redirect_records { if patch_matches(purl, uuid, id) { hosted_scope.insert(purl.clone()); diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 642547aa..ce5425e9 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -31,54 +31,21 @@ pub(super) struct UpdateInfo { /// Lockfile-only packages: dependencies the project's lockfile resolves /// that have no crawled (installed) counterpart. #[derive(Default)] -pub(super) struct LockfileSupplement { - pub(super) packages: Vec, +pub(crate) struct LockfileSupplement { + pub(crate) packages: Vec, /// Literal crawler-form purls, for fast membership tests. - pub(super) purls: HashSet, + pub(crate) purls: HashSet, /// The FULL lockfile inventory the supplement was derived from (installed /// packages included), kept so the hosted-wiring probes reuse it instead /// of re-parsing every project lockfile. Empty for global scans. - pub(super) entries: Vec, + pub(crate) entries: Vec, /// npm layouts the lockfile inventory REFUSED (Plug'n'Play loaders). /// Scan surfaces these as refusal warnings: under PnP the installed-tree /// crawl is empty too, so otherwise the project scans as a silent no-op. - pub(super) unsupported: Vec, + pub(crate) unsupported: Vec, } -/// Map a core npm-layout refusal onto scan's warning channel as -/// `(code, detail)`. The yarn code matches apply's refusal errorCode -/// (`yarn_pnp_unsupported`) so consumers key on ONE name across commands; -/// the pnpm twin gets the parallel spelling. Details are scan-phrased (what -/// was NOT scanned + remedy) rather than the probe's vendor-phrased text. -pub(crate) fn unsupported_layout_warnings( - unsupported: &[socket_patch_core::vendor::lock_inventory::UnsupportedNpmLayout], -) -> Vec<(String, String)> { - unsupported - .iter() - .map(|diag| match diag.code { - "vendor_yarn_berry_unsupported" => ( - "yarn_pnp_unsupported".to_string(), - "this project uses yarn Plug'n'Play (a `.pnp.*` loader is present): its npm \ - packages live inside `.yarn/cache/*.zip`, not `node_modules/`, so socket-patch \ - cannot discover or patch them in ANY mode (agent, hosted, or vendored) — npm \ - dependencies were NOT scanned. Use `yarn patch ` to patch them instead." - .to_string(), - ), - "vendor_pnpm_pnp_unsupported" => ( - "pnpm_pnp_unsupported".to_string(), - "this project uses pnpm's Plug'n'Play linker (`node-linker=pnp` in .npmrc): \ - lockfile discovery is skipped under this layout, so lockfile-only npm \ - dependencies were NOT scanned. Switch .npmrc to `node-linker=isolated`, run \ - `pnpm install`, and re-run — or use `socket-patch scan --mode hosted`, which \ - edits pnpm-lock.yaml in place." - .to_string(), - ), - // Forward-compat: a new refusal code surfaces verbatim rather - // than being swallowed back into silence. - other => (other.to_string(), diag.detail.clone()), - }) - .collect() -} +pub(crate) use socket_patch_core::vendor::lock_inventory::unsupported_layout_warnings; /// Inventory the project's lockfile(s) and fabricate crawl entries for /// dependencies that are not installed. The fabricated `path` is the @@ -91,19 +58,19 @@ pub(crate) fn unsupported_layout_warnings( /// crawled): an entry of an ecosystem the crawl skipped is never counted /// lockfile-only, since there is no crawl to tell whether it is installed. /// `entries` still holds the full inventory. -pub(super) async fn lockfile_supplement( - common: &GlobalArgs, +pub(crate) async fn lockfile_supplement( + ctx: &crate::commands::context::ProjectContext<'_>, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], only: Option<&[String]>, ) -> LockfileSupplement { - use socket_patch_core::vendor::lock_inventory; - + let common = ctx.common; let mut out = LockfileSupplement::default(); if common.is_global() { return out; } - let (entries, unsupported) = lock_inventory::inventory_project_diagnosed(&common.cwd).await; - out.unsupported = unsupported; + let locks = ctx.locks().await; + out.unsupported = locks.unsupported.clone(); + let entries = &locks.entries; if entries.is_empty() { return out; } @@ -114,7 +81,7 @@ pub(super) async fn lockfile_supplement( .is_some_and(|eco| list.iter().any(|name| name == eco.cli_name())) }) }; - for entry in &entries { + for entry in entries { if crawled_purls.contains(entry.purl.as_str()) || !in_scope(&entry.purl) { continue; } @@ -124,7 +91,7 @@ pub(super) async fn lockfile_supplement( out.purls.insert(entry.purl.clone()); out.packages.push(pkg); } - out.entries = entries; + out.entries = entries.clone(); out } @@ -167,7 +134,7 @@ fn crawled_from_purl( /// runs all keep working before any install). They are NOT "lockfile-only" /// — nothing needs installing; the artifact satisfies the lock. `state` is /// the ledger `run` already loaded (`vendor::load_state`). -pub(super) async fn vendored_ledger_supplement( +pub(crate) async fn vendored_ledger_supplement( common: &GlobalArgs, crawled: &[socket_patch_core::crawlers::types::CrawledPackage], state: &std::io::Result, @@ -404,43 +371,49 @@ pub(super) async fn preverify_vendor_baselines( /// ledger) and vendored mode ONLY in `.socket/vendor/state.json`, so without /// this fold a pure hosted or vendored project's `updates[]` would always /// be empty. Precedence on a collision: manifest > hosted pins > vendor -/// ledger (the live lock over a possibly superseded vendored entry). Vendor -/// entries are keyed by their manifest-form ledger key (`detect_updates` -/// bridges the spellings); a legacy entry without an embedded record -/// contributes its uuid alone. Borrows the manifest untouched when nothing -/// else contributes. +/// ledger (the live lock over a possibly superseded vendored entry); the +/// vendor entries fold under the shared owner rule +/// ([`socket_patch_core::ledgers::Ledgers::owned`]), so one the manifest +/// claims (its key or base purl) stays behind the manifest's record. +/// Vendor entries are keyed by their ledger key (`detect_updates` bridges +/// the spellings); a legacy entry without an embedded record contributes +/// its uuid alone. Borrows the manifest untouched when nothing else +/// contributes. pub(super) fn merge_ledger_records_for_updates<'a>( manifest: Option<&'a PatchManifest>, vendor: Option<&VendorState>, hosted_pins: &[(String, String)], ) -> Option> { - let vendor_entries = vendor.map(|s| &s.entries).filter(|e| !e.is_empty()); - if vendor_entries.is_none() && hosted_pins.is_empty() { + use socket_patch_core::ledgers::{uuid_only_record, Ledgers, Store}; + let vendor = vendor.filter(|s| !s.entries.is_empty()); + if vendor.is_none() && hosted_pins.is_empty() { return manifest.map(Cow::Borrowed); } - let uuid_only = |uuid: &str| PatchRecord { - uuid: uuid.to_string(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }; let mut merged = manifest.cloned().unwrap_or_default(); for (purl, uuid) in hosted_pins { merged .patches .entry(purl.clone()) - .or_insert_with(|| uuid_only(uuid)); - } - for (purl, entry) in vendor_entries.into_iter().flatten() { - merged.patches.entry(purl.clone()).or_insert_with(|| { - entry - .record - .clone() - .unwrap_or_else(|| uuid_only(&entry.uuid)) - }); + .or_insert_with(|| uuid_only_record(uuid)); + } + let ledgers = Ledgers { + manifest, + vendor, + redirect: None, + }; + for owned in ledgers.owned() { + if owned.store == Store::Manifest { + continue; + } + merged + .patches + .entry(owned.key.to_string()) + .or_insert_with(|| { + owned + .record + .cloned() + .unwrap_or_else(|| uuid_only_record(owned.uuid)) + }); } Some(Cow::Owned(merged)) } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 445e0c52..b0afa29c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -9,7 +9,6 @@ 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,12 +26,18 @@ pub(crate) mod vlt; 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`: 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)); +#[cfg(test)] +pub(crate) use socket_patch_core::hosted::guidance::{ + npm_allow_remote_already_detail, npm_allow_remote_configured_detail, + npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, + npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, + npm_allow_remote_user_set_detail, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_carries_hosted_redirect, pnpm_lock_may_need_store_flag, pnpm_lock_version_major, + pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, + pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, + TrustPlan, +}; +pub(crate) use socket_patch_core::hosted::render::redirect_json_block; /// 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 @@ -50,439 +55,6 @@ fn wheel_metadata_concurrency(use_public_proxy: bool) -> usize { api_concurrency(use_public_proxy).min(WHEEL_METADATA_CONCURRENCY) } -/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, NEVER userinfo. -/// For user-facing messages that name where a lockfile now points — the -/// hosted artifact host follows `--api-url`, so hardcoding `patch.socket.dev` -/// would misname it in custom-server environments. The port is kept (it is -/// part of the authority the lock records); credentials are stripped: a -/// credentialed artifact URL (`https://user:secret@host/…`) must never leak -/// `user:secret` into the warning text or the persisted `--json` envelope — -/// both land in CI logs. Split by hand because this crate has no URL-parser -/// dependency (reqwest is dev-only here); per RFC 3986 a raw `@` in the -/// authority can ONLY be the userinfo terminator (it is percent-encoded -/// everywhere else), so the tail after the LAST `@` is exactly host[:port]. -pub(crate) fn url_host(url: &str) -> Option<&str> { - let rest = url.split_once("://").map_or(url, |(_, r)| r); - let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); - let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - (!host.is_empty()).then_some(host) -} - -/// Repo-relative path of the pnpm workspace manifest the trustLockfile -/// auto-config edits (the same file the vendor backend's override surface -/// uses). -pub(crate) const PNPM_WORKSPACE_REL: &str = "pnpm-workspace.yaml"; - -/// `FileEdit.kind` recorded when the hosted flow ensures `trustLockfile: -/// true` in pnpm-workspace.yaml. `action: "created"` — the workspace file -/// itself was created (a revert deletes it); `action: "added"` — the single -/// `trustLockfile: true` line was appended to an existing file (a revert -/// removes exactly that line). Additive ledger vocabulary: older ledgers -/// without it load unchanged (kind is an opaque string to the loader). -pub(crate) const REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND: &str = "redirect_pnpm_workspace_trust"; - -/// The honest-tradeoff + don't-rebuild tail shared by every trustLockfile -/// warning variant. The tradeoff sentence is a security disclosure, not -/// prose garnish: `trustLockfile: true` disables pnpm's lockfile -/// re-verification for the WHOLE lock, so it must be stated wherever the -/// setting is written or recommended. -pub(crate) const PNPM_TRUST_TRADEOFF_AND_CAUTION: &str = - "Note: trustLockfile makes pnpm skip its lockfile re-verification \ - (minimumReleaseAge / trustPolicy re-checks) for ALL lockfile entries, \ - not just the patched ones — the per-entry sha512 integrity pins are \ - still enforced. Do NOT follow pnpm's advice to rebuild the lockfile \ - (`pnpm clean --lockfile`): that silently discards the hosted patches and \ - reinstalls the vulnerable upstream artifact. pnpm <=10 ignores the \ - setting and installs work unchanged"; - -/// The policy preamble shared by every trustLockfile warning variant: -/// what was repointed, and how pnpm >=11 fails without trust. -pub(crate) fn pnpm_trust_policy_preamble(server: &str) -> String { - format!( - "pnpm-lock.yaml was repointed at {server}; pnpm >=11 rejects the \ - rewritten lock (pnpm 11: ERR_PNPM_TARBALL_URL_MISMATCH, pnpm 12: \ - ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION)" - ) -} - -/// The pre-auto-config guidance, kept verbatim for the runs where the -/// auto-config does not apply (legacy 5.x/6.0 locks, Rush nested locks, -/// `--no-trust-lockfile-config`): both verified recoveries, spelled exactly. -pub(crate) fn pnpm_trust_manual_guidance(server: &str) -> String { - format!( - "{}. Install with `pnpm install --trust-lockfile`, or commit \ - `trustLockfile: true` in pnpm-workspace.yaml so every install \ - accepts the patched artifacts. Do NOT follow pnpm's advice to \ - rebuild the lockfile (`pnpm clean --lockfile`): that silently \ - discards the hosted patches and reinstalls the vulnerable upstream \ - artifact. pnpm <=10 installs work unchanged", - pnpm_trust_policy_preamble(server), - ) -} - -/// The LEGACY-lock variant (lockfileVersion 5.x/6.0 — pnpm 7/8): those -/// majors have neither the pnpm >=11 lockfile trust policy nor any trust -/// flag or setting, so installs consume the redirected lock unchanged and -/// no trust step exists or is needed. Deliberately NEVER mentions -/// `pnpm install --trust-lockfile`: pnpm 7/8 reject the flag as an unknown -/// option, so headlining it here would hand users a command that errors. -pub(crate) fn pnpm_trust_legacy_detail(server: &str) -> String { - format!( - "The pnpm lockfile was repointed at {server}. This is a legacy \ - lock read by pnpm 1–8, which have no \ - lockfile trust policy: no trust step exists or is needed. Do NOT regenerate the lockfile \ - (deleting it, or re-resolving on a newer pnpm): that silently \ - discards the hosted patches and reinstalls the vulnerable upstream \ - artifact. If the project later moves to pnpm >=9, re-run \ - `socket-patch scan --mode hosted` so the regenerated lock is \ - switched to hosted (and trust-configured) again" - ) -} - -/// The unreadable-workspace fallback: pnpm-workspace.yaml EXISTS but could -/// not be read (permissions, invalid UTF-8, I/O error). Planning a Create -/// here would OVERWRITE the user's file with the root-only scaffold — -/// destroying their `packages:` globs — so the auto-config stands down and -/// the warning names the file, the error, and both manual recoveries. -pub(crate) fn pnpm_trust_workspace_unreadable_detail(server: &str, err: &std::io::Error) -> String { - format!( - "{}. {PNPM_WORKSPACE_REL} exists but could not be read ({err}); it \ - was left untouched — auto-configuring trust would risk overwriting \ - it. Fix the file, then install with `pnpm install --trust-lockfile` \ - or add `trustLockfile: true` to it yourself so every install \ - accepts the patched artifacts. Do NOT follow pnpm's advice to \ - rebuild the lockfile (`pnpm clean --lockfile`): that silently \ - discards the hosted patches and reinstalls the vulnerable upstream \ - artifact. pnpm <=10 installs work unchanged", - pnpm_trust_policy_preamble(server), - ) -} - -/// The pnpm-workspace.yaml read, classified for the trust auto-config: -/// `Ok(Some(text))` — read fine; `Ok(None)` — ABSENT (`ErrorKind::NotFound`, -/// the only state where planning a Create is safe); `Err(e)` — present but -/// unreadable, so the caller must fall back to warning-only guidance -/// (planning a Create would overwrite the user's `packages:` globs). -/// -/// FIFO-safe (`read_regular_to_string_sync`: non-blocking open + fstat): a -/// FIFO planted at the path classifies as unreadable (`InvalidInput`) instead -/// of wedging the run in `open(2)`. -fn read_workspace_for_trust(path: &std::path::Path) -> std::io::Result> { - match socket_patch_core::utils::fs::read_regular_to_string_sync(path) { - Ok(text) => Ok(Some(text)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - } -} - -/// HEAL-ON-RERUN probe: does this (unspliced) root pnpm-lock.yaml already -/// carry a granted hosted artifact URL from an EARLIER run? Same spelling -/// set as the confirmation probe (raw / `\/`-escaped via -/// `artifact_url_present`, plus the percent-encoded form) so a writer's -/// spelling can never be one this probe misses. Lets an idempotent re-scan -/// plan the trust config for a project that missed it once (opted-out first -/// run, or a crash between the lock write and the workspace write). -fn pnpm_lock_carries_hosted_redirect( - lock_text: &str, - overrides: &[socket_patch_core::patch::redirect::DepOverride], -) -> bool { - let groups: Vec> = overrides - .iter() - .filter(|o| o.ecosystem == "npm") - .map(|o| npm_lock_url_needles(&o.artifact_url)) - .collect(); - socket_patch_core::patch::redirect::presence::groups_present(&[lock_text], &groups) - .into_iter() - .any(|present| present) -} - -/// The spellings of an npm artifact URL a pnpm lock may carry — raw or -/// `\/`-escaped ([`artifact_url_spellings`](socket_patch_core::patch::redirect::artifact_url_spellings), -/// the `artifact_url_present` pair) plus the percent-encoded form — searched -/// in one multi-needle pass ([`groups_present`](socket_patch_core::patch::redirect::presence::groups_present)). -fn npm_lock_url_needles(artifact_url: &str) -> Vec { - let mut needles: Vec = - socket_patch_core::patch::redirect::artifact_url_spellings(artifact_url).into(); - needles.push(socket_patch_core::utils::uri::encode_uri_component( - artifact_url, - )); - needles -} - -/// The HEAL-ON-RERUN gate: when this run spliced no root pnpm-lock.yaml -/// (`root_spliced` false) but the on-disk root lock is v9 and already -/// carries a granted hosted artifact URL, return its text so the trust -/// block engages anyway. Legacy (<9) and unparseable-version locks stay -/// `None` (fail closed: never write config for a lock era we can't read), -/// as does a root lock this run DID splice (the splice path covers it). -pub(crate) fn pnpm_heal_root<'a>( - root_spliced: bool, - disk_root: Option<&'a String>, - overrides: &[socket_patch_core::patch::redirect::DepOverride], -) -> Option<&'a String> { - if root_spliced { - return None; - } - disk_root.filter(|text| { - pnpm_lock_version_major(text).is_some_and(|major| major >= 9) - && pnpm_lock_carries_hosted_redirect(text, overrides) - }) -} - -/// The auto-config variant: trust was (or, on `--dry-run`, would be) -/// configured in pnpm-workspace.yaml, so installs need no flags. -pub(crate) fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) -> String { - let how = match (created, dry_run) { - (true, false) => "`trustLockfile: true` was written to a new", - (false, false) => "`trustLockfile: true` was merged into the existing", - (true, true) => "`trustLockfile: true` would be written to a new", - (false, true) => "`trustLockfile: true` would be merged into the existing", - }; - format!( - "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ - installs need no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(server), - ) -} - -// 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 { - /// No workspace file: create it (root-only `packages` scaffold — pnpm 9 - /// refuses a workspace file with no `packages` field — plus the trust - /// key; the same scaffold shape the vendor backend creates). - Create(String), - /// Workspace file exists without a `trustLockfile:` key: append exactly - /// one line after the last non-empty line, every other byte preserved. - Append(String), - /// Already `trustLockfile: true` — nothing to write. - AlreadyTrue, - /// The user explicitly set `trustLockfile: ` (non-true). Their - /// call is respected — flipping an explicit security setting behind the - /// user's back is worse than a failing install with a clear warning. - UserSet(String), -} - -/// Decide how to ensure `trustLockfile: true` in pnpm-workspace.yaml. -/// Line splices only (never a YAML library), mirroring the vendor backend's -/// workspace surgery: untouched lines stay byte-identical, so a revert can -/// remove exactly what was added. -pub(crate) fn plan_workspace_trust(existing: Option<&str>) -> TrustPlan { - let Some(text) = existing else { - return TrustPlan::Create("packages:\n - '.'\ntrustLockfile: true\n".to_string()); - }; - // Top-level key only: an indented `trustLockfile:` under some other - // mapping is not the setting pnpm reads. - for line in text.split('\n') { - if let Some(rest) = line.strip_prefix("trustLockfile:") { - let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); - if value == "true" { - return TrustPlan::AlreadyTrue; - } - return TrustPlan::UserSet(value.to_string()); - } - } - let mut lines: Vec = text.split('\n').map(str::to_string).collect(); - // After the last non-empty line (no blank separator): a revert removes - // exactly one line and the file's trailing bytes stay put. - let anchor = lines - .iter() - .rposition(|l| !l.trim().is_empty()) - .map(|i| i + 1) - .unwrap_or(lines.len()); - lines.insert(anchor, "trustLockfile: true".to_string()); - TrustPlan::Append(lines.join("\n")) -} - -/// The root npm locks the hosted rewriter edits (`rewrite_npm_lock` rewrites -/// every one present — npm 12 installs from package-lock.json beside a -/// committed shrinkwrap). -pub(crate) const NPM_LOCKS: [&str; 2] = ["npm-shrinkwrap.json", "package-lock.json"]; - -/// The honest-tradeoff + opt-out tail shared by every `allow-remote` -/// warning variant. The tradeoff sentence is a security disclosure, not -/// prose garnish: `allow-remote=all` lifts npm 12's remote-tarball refusal -/// for the WHOLE dependency tree, so it must be stated wherever the setting -/// is written or recommended (the pnpm `trustLockfile` precedent). -const NPM_ALLOW_REMOTE_TRADEOFF: &str = - "Note: allow-remote=all lets npm install ANY url-resolved (remote tarball) \ - dependency, not just the patched ones Socket serves — the per-entry sha512 \ - integrity pins are still enforced. `allow-remote=root` only admits direct \ - dependencies. npm <=11 installs work unchanged (npm 11 already defaults to \ - `all`; npm <=10 has no such setting)"; - -/// The default human form of a `redirect_npm_allow_remote` warning: one -/// line saying whether the project `.npmrc` now carries `allow-remote=all` -/// or the user must set it. `detail` is one of the `npm_allow_remote_*` -/// texts below; `--verbose` and `--json` show it in full. -pub(crate) fn npm_allow_remote_one_line(detail: &str) -> String { - const MORE: &str = "(details: --verbose)"; - if detail.contains("`allow-remote=all` was written to a new") - || detail.contains("`allow-remote=all` was appended to the existing") - { - format!( - "Note: set `allow-remote=all` in .npmrc so npm >=12 installs the hosted \ - patches; commit it with the lockfile {MORE}." - ) - } else if detail.contains("`allow-remote=all` would be") { - format!( - "Note: would set `allow-remote=all` in .npmrc so npm >=12 installs the \ - hosted patches {MORE}." - ) - } else if detail.contains("already sets `allow-remote=all`") { - format!( - "Note: .npmrc already sets `allow-remote=all`, so npm >=12 installs the \ - hosted patches; keep it committed {MORE}." - ) - } else { - format!( - "Warning: npm >=12 refuses the hosted patches until `allow-remote=all` is \ - set (in .npmrc, or `npm ci --allow-remote=all`); it was not set \ - automatically {MORE}." - ) - } -} - -/// The policy preamble shared by every `allow-remote` warning variant: what -/// was repointed, and how npm >= 12 fails without the setting. -fn npm_allow_remote_preamble(hosts: &[&str]) -> String { - format!( - "the npm lockfile now resolves patched dependencies from the hosted patch server ({}); \ - npm >=12 refuses tarballs from any host other than the configured registry by \ - default (`allow-remote=none`, error EALLOWREMOTE)", - hosts.join(", ") - ) -} - -/// The auto-config variant: `allow-remote=all` was (or, on `--dry-run`, -/// would be) written to the project `.npmrc`, so installs need no flags. -pub(crate) fn npm_allow_remote_configured_detail( - hosts: &[&str], - created: bool, - dry_run: bool, -) -> String { - let how = match (created, dry_run) { - (true, false) => "`allow-remote=all` was written to a new", - (false, false) => "`allow-remote=all` was appended to the existing", - (true, true) => "`allow-remote=all` would be written to a new", - (false, true) => "`allow-remote=all` would be appended to the existing", - }; - format!( - "{}, so {how} project .npmrc — commit it alongside the lock; `npm ci` needs no \ - extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}. To keep npm's default instead, re-run \ - with --no-npm-allow-remote-config (SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG) and install \ - with `npm ci --allow-remote=all`", - npm_allow_remote_preamble(hosts), - ) -} - -/// The project `.npmrc` already resolves to `allow-remote=all`. -pub(crate) fn npm_allow_remote_already_detail(hosts: &[&str]) -> String { - format!( - "{}, and the project .npmrc already sets `allow-remote=all` — keep it committed \ - alongside the lock; `npm ci` needs no extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - ) -} - -/// The user explicitly set another value: respected, never flipped (the -/// pnpm `trustLockfile: false` precedent) — the warning names the manual -/// recoveries instead. -pub(crate) fn npm_allow_remote_user_set_detail(hosts: &[&str], value: &str) -> String { - format!( - "{}. The project .npmrc explicitly sets `allow-remote={value}`, which was respected \ - and left untouched — set `allow-remote=all` there yourself (or install with \ - `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ - {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - ) -} - -/// An `npm_config_allow_remote` environment variable sets another value. -/// npm's env layer beats every `.npmrc`, so a project write could not take -/// effect in this environment — and an explicit setting is respected. -pub(crate) fn npm_allow_remote_env_set_detail(hosts: &[&str], var: &str, value: &str) -> String { - format!( - "{}. The environment variable {var}={value} explicitly sets `allow-remote`, which \ - was respected: npm's environment layer overrides every .npmrc, so a project \ - `allow-remote=all` would not take effect here and the project .npmrc was left \ - untouched — unset {var} (or install with `npm ci --allow-remote=all`) so npm >=12 \ - installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - ) -} - -/// A lower npm config layer (user / global / builtin file) explicitly sets -/// another value. A committed project `allow-remote=all` would silently -/// override that machine / org policy on every checkout, so it is -/// respected like a project value and the override is left to the user. -pub(crate) fn npm_allow_remote_outer_set_detail( - hosts: &[&str], - layer: &str, - path: &std::path::Path, - value: &str, -) -> String { - format!( - "{}. The {layer} npm config ({}) explicitly sets `allow-remote={value}`, which was \ - respected: socket-patch does not commit a project .npmrc that overrides it, and \ - the project .npmrc was left untouched — to accept the patched artifacts in this \ - project anyway, set `allow-remote=all` in the project .npmrc yourself (it outranks \ - the {layer} config) or install with `npm ci --allow-remote=all`. \ - {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - path.display(), - ) -} - -/// The opt-out (`--no-npm-allow-remote-config`) variant: nothing written, -/// both manual recoveries spelled out. -pub(crate) fn npm_allow_remote_manual_detail(hosts: &[&str]) -> String { - format!( - "{}. Commit `allow-remote=all` in the project .npmrc (or install with \ - `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ - {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - ) -} - -/// The unreadable/unsafe `.npmrc` fallback: the file exists but could not -/// be read, or is a symlink / non-regular file the atomic writer would -/// replace. Planning a Create here would OVERWRITE the user's registry / -/// auth config, so the auto-config stands down and names the problem. -pub(crate) fn npm_allow_remote_unreadable_detail(hosts: &[&str], why: &str) -> String { - format!( - "{}. The project .npmrc exists but {why}; it was left untouched. Add \ - `allow-remote=all` to it yourself (or install with `npm ci --allow-remote=all`) \ - so npm >=12 installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", - npm_allow_remote_preamble(hosts), - ) -} - -/// The project `.npmrc` read, classified for the allow-remote auto-config: -/// `Ok(Some(text))` — a regular file read fine; `Ok(None)` — ABSENT (the -/// only state where planning a Create is safe); `Err(why)` — present but -/// unreadable, a symlink (the atomic stage+rename writer would replace the -/// link with a detached copy — and the whole-run symlink guard would refuse -/// the redirect), or not a regular file (FIFO-safe: never opened blocking). -fn read_npmrc_for_allow_remote(path: &std::path::Path) -> Result, String> { - match std::fs::symlink_metadata(path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(format!("could not be inspected ({e})")), - Ok(meta) if meta.file_type().is_symlink() => { - return Err("is a symbolic link (socket-patch never writes through one)".into()) - } - Ok(_) => {} - } - socket_patch_core::utils::fs::read_regular_to_string_sync(path) - .map(Some) - .map_err(|e| format!("could not be read ({e})")) -} - /// The hosted-mode JSON error envelope, for bail-outs that return before the /// success envelope at the bottom of [`run_redirect`] is built. When the /// classic scan object (`scan_result`, threaded in from `run`) is present it @@ -537,29 +109,6 @@ fn build_redirect_json_envelope( result } -/// The nested `redirect` block of every hosted `--json` envelope — the ONE -/// spelling of its key set (`mode`, `redirected`, `rewrittenFiles`, -/// `skipped`, `warnings`, `dryRun`), shared by the ≥1-package path here and -/// the zero-discovery arm in `run`, so the two cannot drift by convention. -/// `mode` is `"hosted"`: an additive key so consumers dispatch on the mode without inferring it from which -/// sub-object is present. -pub(crate) fn redirect_json_block( - redirected: usize, - rewritten: Vec, - skipped: Vec, - warnings: Vec, - dry_run: bool, -) -> serde_json::Value { - serde_json::json!({ - "mode": "hosted", - "redirected": redirected, - "rewrittenFiles": rewritten, - "skipped": skipped, - "warnings": warnings, - "dryRun": dry_run, - }) -} - /// The `redirect_prune_ignored` warning object (`--prune` is a no-op in /// hosted mode; see the constants' doc in `run`'s module). pub(super) fn prune_ignored_warning() -> serde_json::Value { @@ -569,29 +118,16 @@ pub(super) fn prune_ignored_warning() -> serde_json::Value { }) } -/// The fail-closed refusal for a symlinked rewrite target (both the general -/// SYMLINK GUARD and the takeover pre-check in [`run_redirect_selected`]): -/// stderr line + `--json` envelope, exit 1. The writers stage next to the -/// path and rename over it, which REPLACES a symbolic link with a detached -/// regular copy — the link target goes stale and a revert restores bytes but -/// never the link — so nothing may be written. -fn refuse_symlinked_file( +/// An engine refusal (nothing was written): `Error (): ` on +/// stderr plus the `--json` error envelope carrying the code, exit 1. +fn refuse( common: &crate::args::GlobalArgs, scan_result: Option, - linked: &str, + refusal: &socket_patch_core::hosted::engine::Refusal, ) -> i32 { - let message = format!( - "{linked} is a symbolic link; socket-patch rewrites files in place with an atomic \ - rename, which would replace the link — replace the link with a regular file (or \ - run socket-patch in the directory it points to) and re-run; nothing was written" - ); - eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); + eprintln!("Error ({}): {}", refusal.code, refusal.message); if common.json { - emit_json_error_with_code( - scan_result, - Some("redirect_symlinked_file_unsupported"), - &message, - ); + emit_json_error_with_code(scan_result, Some(&refusal.code), &refusal.message); } 1 } @@ -1052,85 +588,25 @@ pub(super) async fn run_redirect( .await } -/// How the confirmation probe in [`run_redirect_selected`] settles one -/// candidate: a non-substring rule (a transactional rewriter's own report, a -/// refusal) decides it outright, otherwise it is confirmed iff any of its -/// needles occurs in a final text. -enum ProbeStep { - Decided(bool), - Needles(Vec), - /// [`Self::Needles`], searched in every final text but `vlt-lock.json` - /// (a dep withheld from the vlt rewrite). - NeedlesOutsideVlt(Vec), -} - -/// The substrings whose presence in a final text confirms `dep`'s redirect — -/// the override's own targets: artifact URL; per-dependency registry index -/// URL; fail-closed maven's globally-unique `-socket.` suffixed version -/// (never the `.pom` URL). -/// -/// - The artifact URL in the rewriters' own spellings -/// ([`artifact_url_spellings`](socket_patch_core::patch::redirect::artifact_url_spellings), -/// raw or the `\/`-escaped slashes an old composer.lock spells them with), -/// so a writer's spelling can never be one this probe misses. -/// - The percent-encoded URL: the berry rewriter writes it into the lock's -/// `::__archiveUrl=` binding, so the raw form is absent. -/// - The registry index URL and the maven suffixed version, when present. -fn candidate_presence_needles( - dep: &socket_patch_core::patch::redirect::DepOverride, -) -> Vec { - let artifact_url = dep.artifact_url.as_str(); - let registry = dep.registry_override.as_ref(); - let mut needles: Vec = - socket_patch_core::patch::redirect::artifact_url_spellings(artifact_url).into(); - needles.push(socket_patch_core::utils::uri::encode_uri_component( - artifact_url, - )); - if let Some(o) = registry { - needles.push(o.index_url.clone()); - if let Some(sv) = o.identifiers.maven_suffixed_version.as_deref() { - needles.push(sv.to_string()); - } - } - needles -} - -/// The per-candidate probe [`candidate_presence_needles`] + -/// `groups_present` replaced, kept as the equivalence oracle. -#[cfg(test)] -fn candidate_present_oracle( - final_texts: &[&String], - dep: &socket_patch_core::patch::redirect::DepOverride, -) -> bool { - let artifact_url = dep.artifact_url.as_str(); - let registry = dep.registry_override.as_ref(); - let index_url = registry.map(|o| o.index_url.as_str()); - let suffixed_version = registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref()); - let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); - final_texts.iter().any(|text| { - socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) - || text.contains(encoded.as_str()) - || index_url.is_some_and(|iu| text.contains(iu)) - || suffixed_version.is_some_and(|sv| text.contains(sv)) - }) -} - -/// The hosted-redirect engine over an ALREADY-SELECTED `(purl, uuid)` set: -/// reference grants → DepOverride build → apply lock (wet runs with a grant) -/// → ledger load → vendored→hosted takeover pre-revert (symlink-checked -/// first) → candidate-file read → rewrite → pnpm trust config → npm `.npmrc` -/// allow-remote config → -/// confirmation probe → ledger merge-then-persist → file writes → gem stale -/// probe → warnings → optional VEX. Shared VERBATIM by `scan --mode hosted` -/// (its `--json` arm through the `run_redirect` wrapper, its human arm -/// through [`boxed_run_redirect_selected`] in `scan/mod.rs`; both select via -/// `discover_selected`, with no prompt) and by `get --mode hosted` (which -/// pins the advisory-resolved uuid), so all produce identical on-disk -/// results for the same selection. The redirect ledger is loaded HERE, under the apply -/// lock whenever this run holds one (never handed in pre-loaded: a copy -/// read before the lock could merge over a concurrent writer's edits); a -/// dry run or a zero-grant run reads it strictly but writes nothing, -/// quarantine included. +/// The hosted-redirect flow over an ALREADY-SELECTED `(purl, uuid)` set, +/// on disk. The plan → rewrite → edits core is the shared hosted engine +/// ([`socket_patch_core::hosted::engine`], over a +/// [`ProjectView::Disk`](socket_patch_core::vendor::lock_inventory::ProjectView)); +/// what stays here is what needs the host: reference grants and the other +/// network fetches, the apply lock (wet runs with a grant), the redirect +/// ledger load, the vendored→hosted takeover pre-revert (symlink-checked +/// first), the `pipenv --version` probe, the symlink guard, the ledger +/// merge-then-persist and the file writes, the gem / Python / vlt +/// stale-install probes, and the optional VEX. Shared VERBATIM by `scan +/// --mode hosted` (its `--json` arm through the `run_redirect` wrapper, its +/// human arm through [`boxed_run_redirect_selected`] in `scan/mod.rs`; both +/// select via `discover_selected`, with no prompt) and by `get --mode +/// hosted` (which pins the advisory-resolved uuid), so all produce +/// identical on-disk results for the same selection. The redirect ledger +/// is loaded HERE, under the apply lock whenever this run holds one (never +/// handed in pre-loaded: a copy read before the lock could merge over a +/// concurrent writer's edits); a dry run or a zero-grant run reads it +/// strictly but writes nothing, quarantine included. /// /// `scan_result` must be `Some` exactly when `common.json` is set (the /// human/JSON split keys on `common.json`; a `--json` caller passing `None` @@ -1145,24 +621,14 @@ pub(crate) async fn run_redirect_selected( mut scan_result: Option, npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, ) -> i32 { - use socket_patch_core::manifest::schema::PatchRecord; - use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect_withholding_vlt, + use socket_patch_core::hosted::engine::{ + self, Candidate, CandidateFiles, RewriteOptions, SkippedPatch, }; + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::vendor::lock_inventory::ProjectView; - let mut skipped: Vec = Vec::new(); - /// One granted reference: the purl it was granted for plus the rewriter - /// override built from it. The purl is what the takeover, the skip - /// records and the confirmation probe key on; everything the probe - /// needs AFTER the rewrite to decide whether the dep was actually - /// redirected (artifact URL, registry index URL, fail-closed maven's - /// suffixed version) already rides the override. The single vector is - /// filtered in place by every withhold/refusal step, and the rewriters' - /// `overrides` slice is materialized from it once, after the last filter. - struct Candidate { - purl: String, - dep: DepOverride, - } + let view = ProjectView::Disk(&common.cwd); + let mut skipped: Vec = Vec::new(); let mut candidates: Vec = Vec::new(); // The network phases below (reference grants, wheel metadata, patch // records) would otherwise be silent gaps on a terminal. Inert under @@ -1191,128 +657,18 @@ pub(crate) async fn run_redirect_selected( return 1; } }; - for (sel_purl, sel_uuid) in selected { - let Some(reference) = references.get(sel_uuid) else { - skipped.push(serde_json::json!({ "purl": sel_purl, "uuid": sel_uuid, "reason": "not_found" })); - continue; - }; - if reference.status != "granted" && reference.status != "reused" { - skipped.push(serde_json::json!({ "purl": sel_purl, "uuid": sel_uuid, "reason": reference.status })); - continue; - } - let purl = reference.purl.as_deref().unwrap_or(sel_purl); - let Some((ecosystem, name, version)) = purl_parts(purl) else { - skipped.push( - serde_json::json!({ "purl": purl, "uuid": sel_uuid, "reason": "bad_purl" }), - ); - continue; - }; - let Some(url) = reference.url.clone() else { - skipped.push( - serde_json::json!({ "purl": purl, "uuid": sel_uuid, "reason": "no_url" }), - ); - continue; - }; - let mut integrity = reference - .artifacts - .iter() - .flatten() - .find(|a| a.kind == "tarball") - .map(|a| a.integrity.clone()) - .unwrap_or_default(); - // The yarn-berry cache zip carries the `yarnBerry10c0` checksum the - // berry rewriter pins (berry verifies the zip, not the tarball). - // Merge it in and carry the zip URL (None when not stored yet). - let berry_zip = reference - .artifacts - .iter() - .flatten() - .find(|a| a.kind == "yarn-berry-zip"); - if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { - integrity.yarn_berry10c0 = Some(c); - } - // goproxy: the hosted-Go hash pair rides the override's - // identifiers (the tarball's dirhashH1 is the original-path - // flavor, kept for vendor-mode verification); the golang - // rewriter reads the normalized integrity, so merge — the - // gopatch-flavor zip h1 REPLACES dirhashH1 here. Only both - // together: a half-merged pair would trip the rewriter's - // fail-closed integrity check by design. - if let Some(ov) = reference - .registry_override - .as_ref() - .filter(|o| o.kind == "goproxy") - { - if let (Some(zip_h1), Some(gomod_h1)) = ( - ov.identifiers.go_zip_dirhash_h1.clone(), - ov.identifiers.go_mod_h1.clone(), - ) { - integrity.dirhash_h1 = Some(zip_h1); - integrity.go_mod_h1 = Some(gomod_h1); - } - } - // The grant token is never a top-level reference field — it only - // rides the URLs the reference endpoint hands back, as the path - // level before the patch uuid. Recover it so the rewriters' - // rotation-idempotency guards (which wildcard the token path - // level of a previously-written URL) don't depend on it being - // derivable from the URL alone (an empty token makes the gem - // guard nest a new source block on every re-scan). - let token = reference - .registry_override - .as_ref() - .and_then(|o| { - socket_patch_core::patch::redirect::grant_token_path_segment( - &o.index_url, - sel_uuid, - ) - }) - .or_else(|| { - socket_patch_core::patch::redirect::grant_token_path_segment(&url, sel_uuid) - }) - .unwrap_or_default(); - candidates.push(Candidate { - purl: purl.to_string(), - dep: DepOverride { - ecosystem, - name, - namespace: None, - version, - token, - patch_uuid: sel_uuid.clone(), - artifact_url: url, - berry_zip_url: berry_zip.and_then(|a| a.url.clone()), - registry_override: reference.registry_override.clone(), - integrity, - }, - }); - } + candidates = engine::build_candidates(selected, &references, &mut skipped); } - // Text retains Bun's precedence when both lock spellings are present; - // the takeover reverts rewrite locks in place (never create or remove - // one), so the probe holds for the binary-lock decision below too. - let bun_lock_present = common.cwd.join("bun.lock").exists(); - // Check binary lock symlinks before a mode takeover changes any wiring. - if candidates.iter().any(|c| c.dep.ecosystem == "npm") - && !bun_lock_present - && socket_patch_core::utils::fs::first_symlink(&common.cwd, ["bun.lockb"]) - .await - .is_some() - { - // Atomic replacement cannot preserve a link; previews refuse too. - let message = "bun.lockb is a symbolic link; replace it with a regular file (or run \ - socket-patch in the directory it points to) before patching; nothing \ - was written"; - eprintln!("Error (redirect_symlinked_file_unsupported): {message}"); - if common.json { - emit_json_error_with_code( - scan_result.take(), - Some("redirect_symlinked_file_unsupported"), - message, - ); - } - return 1; + // Check binary lock symlinks before a mode takeover changes any wiring + // (the takeover reverts rewrite locks in place, never create or remove + // one, so the lock-presence probe holds for the rewrite below too). + if engine::bun_lockb_symlinked(&view, &candidates) { + return refuse( + common, + scan_result.take(), + &engine::bun_lockb_symlink_refusal(), + ); } // vlt artifact preflight: before any takeover or rewrite (dry runs @@ -1327,18 +683,11 @@ pub(crate) async fn run_redirect_selected( .collect(); vlt::artifact_preflight(common, api_client, &deps).await }; - if !vlt_preflight.withheld_everywhere.is_empty() { - for (uuid, purl) in &vlt_preflight.withheld_everywhere { - skipped.push(serde_json::json!({ - "purl": purl, "uuid": uuid, "reason": vlt::WITHHELD_REASON, - })); - } - candidates.retain(|c| { - !vlt_preflight - .withheld_everywhere - .contains_key(&c.dep.patch_uuid) - }); - } + engine::withhold_everywhere( + &mut candidates, + &vlt_preflight.withheld_everywhere, + &mut skipped, + ); // The apply lock (see `acquire_hosted_lock`), taken only by a WET run // that holds at least one granted reference — the only runs that can @@ -1371,500 +720,28 @@ pub(crate) async fn run_redirect_selected( // ownership known" for both consumers. let mut vendor_state = socket_patch_core::vendor::load_state(&common.cwd).await; - // Cross-mode takeover: a purl this run is about to redirect may still be - // VENDORED — for cargo a committed `[patch.crates-io]` path entry, a - // detached Cargo.lock entry, a committed copy, and a vendored ledger - // entry; for the npm family a `file:./.socket/vendor/…` lock resolution - // (plus a berry `resolutions` pin) and its committed tarball; for golang - // the vendor-owned go.mod `replace`, its committed module copy, and its - // ledger entry. The hosted rewriters know nothing about that wiring - // (cargo would refuse `--locked` builds over the unused `[patch]` entry; - // yarn classic would hijack a resolution the vendored ledger still - // claims; yarn berry refuses `file:` outright). A takeover must leave the - // project FULLY hosted: revert each such purl's vendored state first (the - // per-purl machinery `vendor --revert` runs), and only then redirect — - // which also hands the redirect the PRISTINE registry lock fragment to - // record as its own revert original. A purl - // whose vendored state cannot be cleanly reverted (revert failure, or - // vendored wiring with a missing/corrupt ledger) is REFUSED — skipped - // with an actionable error — never half-migrated. - let takeover_capable = |p: &str| { - p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") + // Cross-mode takeover of still-vendored purls (see `vendored_takeover`). + let Takeover { + pre_warnings: takeover_pre_warnings, + dry_run: dry_run_takeover, + migrated: takeover_migrated, + files: takeover_files, + previews: dry_run_takeover_urls, + } = match vendored_takeover(common, &mut candidates, &mut vendor_state, &mut skipped).await { + Ok(t) => t, + Err(refusal) => return refuse(common, scan_result.take(), &refusal), }; - let mut takeover_pre_warnings: Vec = Vec::new(); - // Dry-run takeover previews: `(purl, uuid)` pairs whose vendored state - // the wet run would revert and then redirect. Withheld from the - // rewriters (their lock fragments still carry the vendored wiring the - // wet run reverts FIRST) and counted as redirected below, so the - // preview's envelope matches the wet run's outcome. - let mut dry_run_takeover: Vec<(String, String)> = Vec::new(); - // Human output: the purls migrated (or, on --dry-run, to be migrated) - // from vendored to hosted, and the files their revert touches (or would - // touch). Both modes count `rewritten ∪ takeover_files`, so the - // preview's file count matches the wet run's even for wiring files the - // hosted rewriter does not also rewrite (a Gemfile line, a uv source). - let mut takeover_migrated: Vec = Vec::new(); - let mut takeover_files: std::collections::BTreeSet = std::collections::BTreeSet::new(); - // Which root locks each dry-run takeover purl is vendored into (from - // its vendor ledger wiring): the wet run reverts that wiring and then - // splices the hosted URL there, so the install-policy auto-configs - // (npm `.npmrc` allow-remote, pnpm `trustLockfile`) must be PREVIEWED - // for those locks even though the rewriters never see these purls. - let mut dry_run_takeover_locks: std::collections::HashMap> = - std::collections::HashMap::new(); - // `(artifact_url, wired root locks)` of the withheld dry-run takeover - // candidates — filled when they leave the rewrite set below. - let mut dry_run_takeover_urls: Vec<(String, Vec)> = Vec::new(); - if !candidates.iter().any(|c| takeover_capable(&c.purl)) { - // No takeover-capable candidates — nothing to reconcile. - } else { - use socket_patch_core::utils::purl::{canonical_purl as canon, strip_purl_qualifiers}; - // Each takeover-capable candidate with its vendored ledger entry, if - // any (cloned out so the loop can mutate the state). - let takeover: Vec<(&Candidate, Option)> = - candidates - .iter() - .filter(|c| takeover_capable(&c.purl)) - .map(|c| { - let entry = vendor_state - .as_ref() - .ok() - .and_then(|s| { - socket_patch_core::vendor::lookup_entry( - &s.entries, - strip_purl_qualifiers(&c.purl), - ) - }) - .cloned(); - (c, entry) - }) - .collect(); - // Compatibility must be known before the takeover removes a live - // patch. In particular, a v0 workspace can keep an existing local - // tuple even though hosted mode cannot replace it with a URL. Only - // an npm purl WITH a vendored entry can be taken over, so the bun - // locks are read here only when one exists — the candidate-file - // read below covers every other run. - let bun_takeover_refusal = if takeover - .iter() - .any(|(c, entry)| entry.is_some() && c.purl.starts_with("pkg:npm/")) - { - match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("bun.lock")) - .await - { - Ok(content) => { - socket_patch_core::patch::redirect::preflight_bun_hosted(&content).err() - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - match socket_patch_core::utils::fs::read_regular_to_bytes_sync( - &common.cwd.join("bun.lockb"), - ) { - Ok(bytes) => { - socket_patch_core::patch::redirect::preflight_bun_binary(&bytes).err() - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning { - code: "redirect_bun_lockb_invalid".into(), - detail: format!("cannot read bun.lockb: {e}"), - }), - } - } - Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning { - code: "redirect_bun_lock_unsupported".into(), - detail: format!("cannot read bun.lock before mode takeover: {e}"), - }), - } - } else { - None - }; - // Yarn berry twin of the bun gate: the berry rewriter's project-level - // refusals (mixed line endings, cacheKey, `.yarnrc.yml` - // compressionLevel) must be known before the takeover reverts a - // vendored berry purl, or the revert strips the live vendored patch - // and the rewriter then refuses the lock. Only entries the - // vendor ledger wired through the yarn-berry backend are gated (the - // lock is read only when one exists); an unreadable lock is left to - // the revert's own diagnostics. - let berry_entry = |entry: &socket_patch_core::vendor::VendorEntry| { - entry.ecosystem == "npm" && entry.flavor.as_deref() == Some("yarn-berry") - }; - let berry_takeover_refusal = if takeover - .iter() - .any(|(_, entry)| entry.as_ref().is_some_and(berry_entry)) - { - match socket_patch_core::utils::fs::read_regular_to_string( - &common.cwd.join("yarn.lock"), - ) - .await - { - Ok(lock) => { - let yarnrc = socket_patch_core::utils::fs::read_regular_to_string( - &common.cwd.join(".yarnrc.yml"), - ) - .await - .ok(); - socket_patch_core::patch::redirect::preflight_yarn_berry_hosted( - &lock, - yarnrc.as_deref(), - ) - .err() - } - Err(_) => None, - } - } else { - None - }; - // vlt twin: the hosted rewriter's lock-level refusal must be known - // before a vendored vlt entry is reverted, or the revert strips the - // live vendored patch and the rewrite then refuses the lock. - let vlt_entry = |entry: &socket_patch_core::vendor::VendorEntry| { - entry.ecosystem == "npm" && entry.flavor.as_deref() == Some("vlt") - }; - let vlt_takeover_refusal = if takeover - .iter() - .any(|(_, entry)| entry.as_ref().is_some_and(vlt_entry)) - { - match socket_patch_core::utils::fs::read_regular_to_string( - &common - .cwd - .join(socket_patch_core::constants::npm_family::VLT_LOCK), - ) - .await - { - Ok(lock) => { - let files = std::collections::BTreeMap::from([( - socket_patch_core::constants::npm_family::VLT_LOCK.to_string(), - lock, - )]); - socket_patch_core::patch::redirect::vlt::preflight_vlt_hosted(&files).err() - } - Err(_) => None, - } - } else { - None - }; - // The takeover refusal (if any) for one candidate: bun gates every - // npm purl, berry and vlt only their own vendored entries. A refused - // purl is never dispatched (see the loop), so its wiring is not a - // write target here. - let takeover_refusal = - |c: &Candidate, - entry: Option<&socket_patch_core::vendor::VendorEntry>| - -> Option<&socket_patch_core::patch::redirect::RewriteWarning> { - if !c.purl.starts_with("pkg:npm/") { - return None; - } - bun_takeover_refusal - .as_ref() - .or_else(|| { - berry_takeover_refusal - .as_ref() - .filter(|_| entry.is_some_and(berry_entry)) - }) - .or_else(|| { - vlt_takeover_refusal - .as_ref() - .filter(|_| entry.is_some_and(vlt_entry)) - }) - }; - // SYMLINK PRE-CHECK for the takeover reverts — the same rule as the - // SYMLINK GUARD below, applied to each ledger entry's recorded wiring - // (the revert backends also stage and rename over the file). Checked - // BEFORE any revert dispatches (and under --dry-run too) so "nothing - // was written" stays true. - let revert_targets = takeover - .iter() - .filter_map(|(c, entry)| { - entry - .as_ref() - .filter(|e| takeover_refusal(c, Some(e)).is_none()) - }) - .flat_map(|entry| entry.wiring.iter().map(|w| w.file.as_str())); - if let Some(linked) = - socket_patch_core::utils::fs::first_symlink(&common.cwd, revert_targets).await - { - return refuse_symlinked_file(common, scan_result.take(), linked); - } - let mut refused: Vec = Vec::new(); - for (candidate, ledger_entry) in &takeover { - let purl = &candidate.purl; - let uuid = &candidate.dep.patch_uuid; - if let Some(entry) = ledger_entry { - if let Some(warning) = takeover_refusal(candidate, Some(entry)) { - refused.push(purl.clone()); - if !takeover_pre_warnings - .iter() - .any(|w| w["code"] == warning.code) - { - takeover_pre_warnings.push(serde_json::json!(warning)); - } - continue; - } - if common.dry_run { - // Preview through the same per-purl revert machinery the - // wet run dispatches (write-free under dry_run): a - // vendored state the wet run would refuse to revert is - // refused here too, and one it would revert is announced - // as a takeover — never handed to the rewriters, which - // would refuse the still-vendored wiring. - let outcome = - crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, true) - .await; - if !outcome.success { - refused.push(purl.clone()); - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl} is vendored and its vendored state could not be \ - reverted ({}); NOT switched to hosted — run `socket-patch vendor \ - --revert` to clean up, then re-run `scan --mode hosted`", - outcome.error.as_deref().unwrap_or("unknown error") - ), - })); - continue; - } - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_would_revert_vendored", - "detail": format!( - "{purl} is currently vendored; the hosted wiring will \ - revert its vendored wiring, ledger entry, and committed \ - artifact first, then switch to hosted (mode takeover)" - ), - })); - dry_run_takeover.push((purl.clone(), uuid.clone())); - takeover_migrated.push(purl.clone()); - takeover_files.extend(entry.wiring.iter().map(|w| w.file.clone())); - dry_run_takeover_locks.insert( - purl.clone(), - entry.wiring.iter().map(|w| w.file.clone()).collect(), - ); - continue; - } - let outcome = - crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, false).await; - if !outcome.success { - refused.push(purl.clone()); - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl} is vendored and its vendored state could not be \ - reverted ({}); NOT switched to hosted — run `socket-patch vendor \ - --revert` to clean up, then re-run `scan --mode hosted`", - outcome.error.as_deref().unwrap_or("unknown error") - ), - })); - continue; - } - // Drop the reverted entry from the in-memory ledger and - // persist per purl so a crash mid-run leaves a ledger - // matching the on-disk wiring. The entry stays dropped even - // when the save fails: its wiring and artifact ARE gone, so - // a later successful save in this loop writes the truth. - let state = vendor_state - .as_mut() - .expect("a vendored ledger entry was looked up in this state, so it loaded"); - state - .entries - .retain(|k, e| canon(k) != canon(purl) && canon(&e.base_purl) != canon(purl)); - if let Err(e) = socket_patch_core::vendor::save_state(&common.cwd, state).await { - // The wiring is reverted but the ledger still claims it; - // redirecting now would leave a ledger asserting wiring - // that is gone. Fail closed for this purl. - refused.push(purl.clone()); - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl}: vendored wiring reverted but the vendored ledger \ - could not be updated ({e}); NOT switched to hosted — fix \ - .socket/vendor/state.json and re-run" - ), - })); - continue; - } - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_takeover_reverted_vendored", - "detail": format!( - "{purl} was vendored; reverted its vendored wiring, ledger \ - entry, and committed artifact before switching to hosted (mode \ - takeover: the project is now fully hosted for this package)" - ), - })); - takeover_pre_warnings.extend( - outcome - .warnings - .iter() - .filter(|w| { - w.code == socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED - }) - .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })), - ); - takeover_migrated.push(purl.clone()); - takeover_files.extend(entry.wiring.iter().map(|w| w.file.clone())); - } else { - // No usable ledger entry. If socket-owned vendored wiring for - // this crate is nevertheless present, the ledger is missing or - // corrupt — the originals needed to revert are unrecoverable, - // so redirecting on top would wedge the project. Refuse. - // (Cargo-only probe: Socket-owned `[patch.crates-io]` entries - // for exactly this name@version in the root Cargo.toml or a - // legacy `.cargo/config*` — another vendored version of the - // crate has its own ledger entry. An npm purl in this state - // falls through to the rewriters' own per-flavor - // diagnostics.) - let coords = purl - .starts_with("pkg:cargo/") - .then(|| purl_parts(purl).map(|(_, name, version)| (name, version))) - .flatten(); - let wired = match &coords { - Some((n, v)) => { - socket_patch_core::vendor::cargo::socket_wiring_present(&common.cwd, n, v) - .await - } - None => false, - }; - if wired { - refused.push(purl.clone()); - takeover_pre_warnings.push(serde_json::json!({ - "code": "redirect_vendored_revert_failed", - "detail": format!( - "{purl} has socket-owned vendored `[patch.crates-io]` \ - wiring but no usable vendored ledger entry \ - (.socket/vendor/state.json is missing or corrupt); NOT \ - redirected — restore the ledger or remove the vendored \ - wiring manually, then re-run" - ), - })); - } - } - } - for purl in &refused { - if let Some((c, entry)) = takeover.iter().find(|(c, _)| &c.purl == purl) { - let reason = takeover_refusal(c, entry.as_ref()) - .map_or("vendored_revert_failed", |w| w.code.as_str()); - skipped.push(serde_json::json!({ - "purl": purl, "uuid": c.dep.patch_uuid, "reason": reason, - })); - } - } - // Purls leaving the rewrite set: refused takeovers, plus the dry-run - // takeover previews (still vendored on disk — the wet run reverts - // them before the rewriters ever see their files). - let withheld: std::collections::HashSet<&str> = refused - .iter() - .map(String::as_str) - .chain(dry_run_takeover.iter().map(|(p, _)| p.as_str())) - .collect(); - if !withheld.is_empty() { - // Keep the dry-run takeover candidates' URLs (and the root locks - // their purl is vendored into) for the install-policy previews. - for (purl, _) in &dry_run_takeover { - let locks = dry_run_takeover_locks - .get(purl) - .cloned() - .unwrap_or_default(); - for c in candidates.iter().filter(|c| &c.purl == purl) { - dry_run_takeover_urls.push((c.dep.artifact_url.clone(), locks.clone())); - } - } - candidates.retain(|c| !withheld.contains(c.purl.as_str())); - } - } - // The binary lock is read and rewritten directly. - let binary_bun = !bun_lock_present && common.cwd.join("bun.lockb").exists(); - // Read the project's candidate files, run the rewriters. Every read goes - // through the FIFO-safe reader (non-blocking open + fstat regular-file - // check), so a FIFO under a candidate name is skipped like a missing file - // instead of wedging the run in open(2). - // - // Skipped when no candidate survived and no dry-run takeover preview is - // pending (the rewriters do nothing without a dep); everything after the - // rewrite still runs. A dry-run takeover preview still needs the root - // locks for the install-policy previews below. - use socket_patch_core::utils::fs::read_regular_to_string; - let mut files: std::collections::BTreeMap = std::collections::BTreeMap::new(); - // Rush monorepos have no root package.json/lock pair: the single pnpm - // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and - // (when subspaces are enabled) one lock per subspace under - // common/config/subspaces//. Add them under their repo-relative - // keys — the pnpm rewriter is basename-generalized, so nested keys are - // rewritten in place, and the write-back below is already path-generic. - 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.iter() { - if *name == "bun.lockb" { - continue; - } - if *name == socket_patch_core::constants::npm_family::VLT_HIDDEN_LOCK_REL { - if vlt::install_state_present(&common.cwd) { - files.insert((*name).to_string(), String::new()); - } - continue; - } - if let Ok(content) = read_regular_to_string(&common.cwd.join(name)).await { - files.insert((*name).to_string(), content); - } - } - - // Cargo workspace members (and in-root path dependencies) declare - // dependencies of their own: a member's direct `cfg-if = "1"` must - // be pinned alongside the root's, or the redirected lock entry is - // unsatisfiable. Keyed `/Cargo.toml` for the cargo rewriter. - if files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") - { - for rel in socket_patch_core::utils::cargo_workspace::member_manifests(&common.cwd) { - if let Ok(content) = read_regular_to_string(&common.cwd.join(&rel)).await { - files.insert(rel, content); - } - } - } - - if let Ok(paths) = socket_patch_core::utils::python_lock::python_lock_paths(&common.cwd) { - for path in paths { - if let Some(script_path) = - socket_patch_core::utils::python_lock::script_of_lock(&path).map(str::to_string) - { - if let Ok(content) = - read_regular_to_string(&common.cwd.join(&script_path)).await - { - files.insert(script_path, content); - } - } - if let Ok(content) = read_regular_to_string(&common.cwd.join(&path)).await { - files.insert(path, content); - } - } - } - - if common.cwd.join("rush.json").is_file() { - let common_lock = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL; - if let Ok(content) = read_regular_to_string(&common.cwd.join(common_lock)).await { - files.insert(common_lock.to_string(), content); - rush_lock_keys.push(common_lock.to_string()); - } - let subspaces_dir = common.cwd.join("common/config/subspaces"); - if let Ok(read_dir) = std::fs::read_dir(&subspaces_dir) { - // read_dir order is unspecified — sort for deterministic output. - let mut subspace_dirs: Vec = read_dir - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .map(|e| e.path()) - .collect(); - subspace_dirs.sort(); - for dir in subspace_dirs { - let Some(name) = dir.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let key = format!("common/config/subspaces/{name}/pnpm-lock.yaml"); - if let Ok(content) = read_regular_to_string(&dir.join("pnpm-lock.yaml")).await { - files.insert(key.clone(), content); - rush_lock_keys.push(key); - } - } - } - } - } + // Read the project's candidate files. Skipped when no candidate + // survived and no dry-run takeover preview is pending (the rewriters do + // nothing without a dep); everything after the rewrite still runs. A + // dry-run takeover preview still needs the root locks for the + // install-policy previews below. + let read = if !candidates.is_empty() || !dry_run_takeover_urls.is_empty() { + engine::read_candidate_files(&view, &std::collections::BTreeSet::new(), &candidates).await + } else { + CandidateFiles::default() + }; let mut python_metadata = std::collections::BTreeMap::new(); let mut unavailable_python_artifacts = std::collections::BTreeSet::new(); @@ -1872,57 +749,13 @@ pub(crate) async fn run_redirect_selected( // wheel metadata fetch when that probe is certain to be needed. let mut pipenv_probe: Option>> = None; { - use socket_patch_core::utils::python_lock::{ArtifactSource, PythonLockProbe}; - // Each native Python lock is parsed once, on the first dep that - // needs the probe, rather than rewritten per dep just to learn - // whether it would be. - let mut probes: Option> = None; - let mut wheel_deps: Vec<(&DepOverride, &str)> = Vec::new(); - for dep in candidates - .iter() - .map(|c| &c.dep) - .filter(|dep| dep.ecosystem == "pypi") - { - let Some(sha256) = dep.integrity.sha256.as_deref() else { - continue; - }; - if !dep - .artifact_url - .split(['?', '#']) - .next() - .is_some_and(|path| path.ends_with(".whl")) - { - continue; - } - let native_target = probes - .get_or_insert_with(|| { - files - .iter() - .filter(|(path, _)| { - *path == "uv.lock" - || socket_patch_core::utils::python_lock::is_script_lock_name(path) - }) - .map(|(_, text)| PythonLockProbe::new(text)) - .collect() - }) - .iter() - .any(|probe| { - probe.rewrites( - &dep.name, - &dep.version, - ArtifactSource::Url(&dep.artifact_url), - ) - }); - if native_target { - wheel_deps.push((dep, sha256)); - } - } + let wheel_deps = engine::wheel_targets(&candidates, &read.files); // The only candidates the metadata fetch can still drop are those // sharing a fetched wheel's artifact URL. If the rest already // target an entry of Pipfile.lock, the Pipenv probe below is certain // to run: start it now so it overlaps the fetch. if pipenv_probe_certain( - &files, + &read.files, candidates.iter().map(|c| &c.dep), wheel_deps.iter().map(|(dep, _)| dep.artifact_url.as_str()), ) { @@ -1990,664 +823,73 @@ pub(crate) async fn run_redirect_selected( Ok(None) => {} Err(detail) => { unavailable_python_artifacts.insert(dep.artifact_url.clone()); - skipped.push(serde_json::json!({ - "purl": format!("pkg:pypi/{}@{}", dep.name, dep.version), - "uuid": dep.patch_uuid, - "reason": "python_metadata_unavailable", - "detail": detail.replace(&dep.artifact_url, ""), - })); + skipped.push(engine::wheel_metadata_unavailable(dep, &detail)); } } } } status.finish(); candidates.retain(|c| !unavailable_python_artifacts.contains(&c.dep.artifact_url)); - // The rewriters' override slice — materialized ONCE, after the last - // candidate filter, so it can never disagree with `candidates`. - let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); // The Pipfile.lock reference shape depends on the installing Pipenv // (`path` for 7–11, `file` from 2018 on), so the installed release is // probed (`pipenv --version`, up to 10 s) — but only when a pypi patch // actually targets an entry of THIS lock: a stray Pipfile.lock in a uv / // Poetry project, a re-scan with nothing left to do and any non-Python // run must neither spawn Pipenv nor warn about its absence. - let targets_pipenv_lock = - socket_patch_core::patch::redirect::pipenv_lock_targets(&files, &overrides); + let targets_pipenv_lock = engine::pipenv_lock_targets(&read.files, &candidates); let pipenv_major = match (targets_pipenv_lock, pipenv_probe) { (true, Some(probe)) => match probe.await { Ok(major) => major, - Err(err) => std::panic::resume_unwind(err.into_panic()), - }, - (true, None) => socket_patch_core::utils::pipenv::installed_major(&common.cwd).await, - // Unreachable (the early start implies the target), but never leave - // a probe running: aborting drops it, which reaps the child. - (false, Some(probe)) => { - probe.abort(); - None - } - (false, None) => None, - }; - let binary_content = if binary_bun && overrides.iter().any(|o| o.ecosystem == "npm") { - Some( - socket_patch_core::utils::fs::read_regular_to_bytes(&common.cwd.join("bun.lockb")) - .await - .map_err(|e| socket_patch_core::patch::redirect::RewriteWarning { - code: "redirect_bun_lockb_invalid".into(), - detail: format!("cannot read bun.lockb: {e}"), - }) - .and_then(|bytes| { - socket_patch_core::patch::redirect::preflight_bun_binary(&bytes)?; - Ok(bytes) - }), - ) - } else { - None - }; - // A malformed primary lock must not cause edits to stale npm siblings. - let rewrite_overrides: Vec<_> = overrides - .iter() - .filter(|o| !(binary_content.as_ref().is_some_and(Result::is_err) && o.ecosystem == "npm")) - .cloned() - .collect(); - // Pure CPU over every lock text (the independent rewriter groups run - // concurrently inside), so it runs on the blocking pool rather than on a - // runtime worker; `files` comes back for the confirmation probe below. - let bun_lockb_present = common.cwd.join("bun.lockb").exists(); - let withheld_from_vlt = vlt_preflight.withheld_from_vlt.clone(); - // `mut`: the pnpm trustLockfile auto-config below may fold a - // pnpm-workspace.yaml write (plus its ledger edit) into the rewrite set so - // it rides the same atomic-write / ledger-first machinery as the locks. - let (files, mut rewrite) = tokio::task::spawn_blocking(move || { - let rewrite = rewrite_registry_redirect_withholding_vlt( - &files, - &rewrite_overrides, - &python_metadata, - pipenv_major, - bun_lockb_present, - &withheld_from_vlt, - ); - (files, rewrite) - }) - .await - .unwrap_or_else(|e| match e.try_into_panic() { - Ok(payload) => std::panic::resume_unwind(payload), - Err(e) => panic!("hosted rewrite task failed: {e}"), - }); - if let Some(content) = binary_content { - rewrite - .warnings - .retain(|w| w.code != "redirect_npm_no_lockfile"); - match content { - Ok(bytes) => socket_patch_core::patch::redirect::rewrite_bun_binary( - &bytes, - &overrides, - &mut rewrite, - ), - Err(warning) => rewrite.warnings.push(warning), - } - } - - // Unknown installer → the modern `file` shape was chosen; say so only - // when the lock was (or, on --dry-run, would be) rewritten. - if targets_pipenv_lock && pipenv_major.is_none() && rewrite.files.contains_key("Pipfile.lock") { - rewrite.warnings.push(socket_patch_core::patch::redirect::RewriteWarning { - code: "redirect_pipenv_installer_unknown".into(), - detail: format!( - "Pipenv was not found on PATH, so the Pipfile.lock references use the modern `file` form (Pipenv 2018 and later). A project installed with Pipenv 7–11 needs `path` references instead: put that pipenv on PATH or set {}= and re-run `scan --mode hosted`.", - socket_patch_core::utils::pipenv::MAJOR_OVERRIDE_ENV - ), - }); - } - - // Editing a Rush lock outside `rush update` desyncs the - // pnpmShrinkwrapHash recorded in repo-state.json. When - // preventManualShrinkwrapChanges is enabled, `rush install` then - // refuses until `rush update` refreshes that hash — but the redirect - // survives `rush update` (pnpm preserves locked resolutions for - // unchanged specifiers). Warn only when the rewrite actually landed in a - // Rush lock and the repo-state file that carries the hash is present. - if rush_lock_keys - .iter() - .any(|key| rewrite.files.contains_key(key)) - && common - .cwd - .join("common/config/rush/repo-state.json") - .is_file() - { - rush_warnings.push(serde_json::json!({ - "code": "redirect_rush_repo_state_stale", - "detail": - "pnpm-lock.yaml was edited outside `rush update`; if \ - preventManualShrinkwrapChanges is enabled, `rush install` fails until \ - `rush update` refreshes repo-state.json (the hosted wiring survives `rush \ - update`)", - })); - } - - // pnpm >=11 enforces a lockfile supply-chain policy: it compares each - // resolution's tarball URL against the registry's published metadata and - // REFUSES a lock whose URLs differ: pnpm 11 with - // ERR_PNPM_TARBALL_URL_MISMATCH (ERR_PNPM_META_FETCH_FAIL when the - // registry is unreachable), pnpm 12 with - // ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION, whose own text tells users - // to rebuild the lock — which silently discards the redirect, so the - // warning must pre-empt that advice. The working recoveries are - // `pnpm install --trust-lockfile` and the pnpm-workspace.yaml - // `trustLockfile: true` key; the `.npmrc` `trust-lockfile=true` spelling - // is IGNORED by pnpm and must never be recommended. - // - // ZERO-TOUCH DEFAULT: when this run rewrote the ROOT pnpm-lock.yaml and - // its lockfileVersion is >= 9 (5.x/6.0 locks mean pnpm 7/8, which have - // neither the policy nor the flag and get their own guidance), the run - // auto-ensures `trustLockfile: true` in pnpm-workspace.yaml. The same - // auto-config re-engages on a run that spliced NOTHING when the root v9 - // lock already carries a granted hosted artifact URL (HEAL-ON-RERUN - // below). pnpm <=10 ignores the key; the per-entry sha512 pin still fails - // closed on tampered bytes. An explicit user `trustLockfile: ` - // is RESPECTED (never flipped), and `--no-trust-lockfile-config` opts out. - // Rush nested/subspace locks are excluded: rush runs pnpm in common/temp, - // which never reads the repo-root pnpm-workspace.yaml. The warning names - // the host(s) the lock now points at (they follow --api-url). - let mut pnpm_warnings: Vec = Vec::new(); - // The pnpm-workspace.yaml content + ledger edit this run will fold into - // the rewrite set (decided inside the borrow scope, applied after it). - let mut trust_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = - None; - // Human mode only: this run touched nothing pnpm-related (no lock - // spliced, trust already configured), so the full guidance, printed by - // the run that made the change, shrinks to a one-line reminder. - let mut pnpm_rerun_only = false; - { - // pnpm locks spliced THIS run (any depth — the rewriter is - // basename-generalized). - let mut pnpm_lock_texts: Vec<&String> = rewrite - .files - .iter() - .filter(|(key, _)| { - std::path::Path::new(key) - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|name| matches!(name, "pnpm-lock.yaml" | "shrinkwrap.yaml")) - }) - .map(|(_, content)| content) - .collect(); - // HEAL-ON-RERUN: a root v9 lock that ALREADY carries a granted hosted - // artifact URL (spliced by an earlier run) still plans the trust - // config even though this run spliced nothing — so a project that - // missed the config once (opted-out first run, or a crash between the - // lock write and the workspace write) is healed by simply re-running - // the scan. An AlreadyTrue workspace keeps the re-run a byte-stable - // no-op. - let heal_root: Option<&String> = pnpm_heal_root( - rewrite.files.contains_key("pnpm-lock.yaml"), - files.get("pnpm-lock.yaml"), - &overrides, - ); - let spliced_pnpm_locks = pnpm_lock_texts.len(); - if let Some(text) = heal_root { - pnpm_lock_texts.push(text); - } - // A dry-run vendored→hosted takeover of a purl vendored into the - // root pnpm lock: the wet run reverts that wiring and splices the - // hosted URL into it, so the trust config is previewed against the - // root lock (the vendored text carries the same lockfileVersion). - let takeover_pnpm_urls: Vec<&str> = dry_run_takeover_urls - .iter() - .filter(|(_, locks)| locks.iter().any(|l| l == "pnpm-lock.yaml")) - .map(|(url, _)| url.as_str()) - .collect(); - let takeover_root: Option<&String> = if takeover_pnpm_urls.is_empty() - || heal_root.is_some() - || rewrite.files.contains_key("pnpm-lock.yaml") - { - None - } else { - files.get("pnpm-lock.yaml") - }; - if let Some(text) = takeover_root { - pnpm_lock_texts.push(text); - } - if !pnpm_lock_texts.is_empty() { - // Name only the hosts whose artifact URL actually landed in a - // touched pnpm lock's final text (spliced this run, or the - // already-redirected heal root): an npm override may have matched - // only a sibling lock (e.g. package-lock.json), and naming its host - // here would point users at a server the pnpm lock never references. - // Same needles as the confirmation probe below. - let npm_overrides: Vec<_> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); - let groups: Vec> = npm_overrides - .iter() - .map(|o| npm_lock_url_needles(&o.artifact_url)) - .collect(); - let present = socket_patch_core::patch::redirect::presence::groups_present( - &pnpm_lock_texts, - &groups, - ); - let mut hosts: Vec<&str> = npm_overrides - .iter() - .zip(present) - .filter(|(_, present)| *present) - .filter_map(|(o, _)| url_host(&o.artifact_url)) - // Dry-run takeover purls land in the root lock on the wet run. - .chain(takeover_pnpm_urls.iter().filter_map(|url| url_host(url))) - .collect(); - hosts.sort_unstable(); - hosts.dedup(); - let server = if hosts.is_empty() { - "the hosted patch server".to_string() - } else { - format!("the hosted patch server ({})", hosts.join(", ")) - }; - // Root-lock gate (see the block comment above): only the plain - // project lock at lockfileVersion >= 9 gets the auto-config — - // spliced this run, or detected already-redirected (heal path). - let root_lock_v9 = heal_root - .or(takeover_root) - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9) - || rewrite - .files - .get("pnpm-lock.yaml") - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9); - // Every touched pnpm lock is a KNOWN legacy (5.x/6.0) format, - // where `--trust-lockfile` is rejected as an unknown option. An - // unparseable version stays on the manual guidance: never claim - // "no trust step needed" for a lock whose era is unknown. - let all_locks_legacy = pnpm_lock_texts.iter().all(|text| { - pnpm_lock_version_major(text).is_some_and(|major| major < 9) - || text - .lines() - .any(|line| line.starts_with("shrinkwrapVersion:")) - }); - let detail = if all_locks_legacy { - pnpm_trust_legacy_detail(&server) - } else if !root_lock_v9 || common.no_trust_lockfile_config { - pnpm_trust_manual_guidance(&server) - } else { - match read_workspace_for_trust(&common.cwd.join(PNPM_WORKSPACE_REL)) { - // Present but UNREADABLE: never plan a Create (it would - // overwrite the user's workspace file) — fall back to - // warning-only guidance naming the file and the error. - Err(e) => pnpm_trust_workspace_unreadable_detail(&server, &e), - Ok(ws_existing) => match plan_workspace_trust(ws_existing.as_deref()) { - TrustPlan::Create(text) => { - trust_config_write = Some(( - text, - socket_patch_core::patch::redirect::FileEdit { - path: PNPM_WORKSPACE_REL.into(), - kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), - action: "created".into(), - key: Some("trustLockfile".into()), - original: None, - new: Some(serde_json::json!("true")), - }, - )); - pnpm_trust_configured_detail(&server, true, common.dry_run) - } - TrustPlan::Append(text) => { - trust_config_write = Some(( - text, - socket_patch_core::patch::redirect::FileEdit { - path: PNPM_WORKSPACE_REL.into(), - kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), - action: "added".into(), - key: Some("trustLockfile".into()), - original: None, - new: Some(serde_json::json!("true")), - }, - )); - pnpm_trust_configured_detail(&server, false, common.dry_run) - } - TrustPlan::AlreadyTrue => { - pnpm_rerun_only = spliced_pnpm_locks == 0; - format!( - "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ - true` — keep it committed alongside the lock; installs need \ - no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ) - } - TrustPlan::UserSet(value) => format!( - "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ - {value}`, which was respected and left untouched — install \ - with `pnpm install --trust-lockfile`, or set `trustLockfile: \ - true` yourself so every install accepts the patched \ - artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ), - }, - } - }; - // The `--store` spelling only matters to pnpm 1–4, so it is - // named only when a touched lock may be that old. - let store_note = if pnpm_lock_texts - .iter() - .any(|text| pnpm_lock_may_need_store_flag(text)) - { - " (pnpm 1–4 spell the option `--store`)" - } else { - "" - }; - pnpm_warnings.push(serde_json::json!({ - "code": "redirect_pnpm_trust_lockfile", - "detail": format!( - "{}. After a lock-only change, existing node_modules or a warm pnpm store \ - can still contain upstream files. For a reliable reinstall, use a clean \ - node_modules tree and an empty store with \ - `pnpm install --frozen-lockfile --store-dir `\ - {store_note}. Do not rely on `--force`: some versions re-resolve the \ - upstream artifact. Run `socket-patch vex` after installation to verify \ - the patched files.", - detail.trim_end_matches('.') - ), - })); - } - } - // npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every - // tarball whose `resolved` origin is not the configured registry — which - // is exactly what a hosted redirect writes. npm <= 11 installs it - // unchanged; `allow-remote=all` in the project `.npmrc` makes npm 12 - // install the patched bytes with the sha512 pins still enforced (`root` - // only admits DIRECT dependencies, so it is not enough). - // - // ZERO-TOUCH DEFAULT (the npm twin of the pnpm trustLockfile auto-config - // above): whenever a root npm lock ends this run carrying a granted - // hosted artifact URL (spliced now, or already redirected by an earlier - // run — so a missed config heals on re-run), the run ensures - // `allow-remote=all` in the project `.npmrc` — created when absent - // (`action: "created"`), one line appended otherwise (`"added"`), every - // other byte preserved — and records it in the ledger - // (`redirect_npmrc_allow_remote`) so rollback / remove / the vendored - // takeover remove exactly that once no package-lock entry needs it. An - // explicit user `allow-remote=` is RESPECTED (never flipped), an - // unreadable / symlinked `.npmrc` is left alone, and - // `--no-npm-allow-remote-config` opts out entirely; every variant still - // WARNS (`redirect_npm_allow_remote`) with the whole-tree tradeoff. - // Vendored mode is unaffected: its `file:.socket/vendor/…` specs are npm - // `file` specs, gated by `allow-file` (default `all`), not - // `allow-remote`. - let mut npm_warnings: Vec = Vec::new(); - let mut npmrc_config_write: Option<(String, socket_patch_core::patch::redirect::FileEdit)> = - None; - { - let npm_hosts: Vec<&str> = { - let npm_lock_texts: Vec<&String> = NPM_LOCKS - .iter() - .filter_map(|lock| rewrite.files.get(*lock).or_else(|| files.get(*lock))) - .collect(); - let npm_overrides: Vec<_> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); - let groups: Vec<[String; 2]> = npm_overrides - .iter() - .map(|o| { - socket_patch_core::patch::redirect::artifact_url_spellings(&o.artifact_url) - }) - .collect(); - let present = socket_patch_core::patch::redirect::presence::groups_present( - &npm_lock_texts, - &groups, - ); - let mut hosts: Vec<&str> = npm_overrides - .iter() - .zip(present) - .filter(|(_, present)| *present) - .filter_map(|(o, _)| url_host(&o.artifact_url)) - // A dry-run vendored→hosted takeover: the wet run reverts - // the vendored wiring in a root npm lock and splices the - // hosted URL there, so preview the `.npmrc` write too. - .chain( - dry_run_takeover_urls - .iter() - .filter(|(_, locks)| locks.iter().any(|l| NPM_LOCKS.contains(&l.as_str()))) - .filter_map(|(url, _)| url_host(url)), - ) - .collect(); - hosts.sort_unstable(); - hosts.dedup(); - hosts - }; - if !npm_hosts.is_empty() { - use socket_patch_core::patch::redirect::npmrc::{ - plan_npmrc_allow_remote_with, resolve_outer_allow_remote, NpmConfigEnv, NpmrcPlan, - NPMRC_ALLOW_REMOTE_EDIT_KIND, NPMRC_REL, - }; - let edit = |action: &str| socket_patch_core::patch::redirect::FileEdit { - path: NPMRC_REL.into(), - kind: NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), - action: action.into(), - key: Some("allow-remote".into()), - original: None, - new: Some(serde_json::json!("all")), - }; - let npmrc = read_npmrc_for_allow_remote(&common.cwd.join(NPMRC_REL)); - // The npm config layers OUTSIDE the project file, located the - // way npm does: an env `npm_config_allow_remote` beats the - // project file, and an explicit user / global / builtin value is - // a machine / org policy a committed project line would silently - // override — both are respected like a project value. - let outer = resolve_outer_allow_remote(&NpmConfigEnv::from_process(), |path| { - socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok() - }); - let detail = match npmrc { - // Opt-out still reports an explicit / already-set value - // truthfully; only the WRITE is suppressed. - Ok(existing) => match plan_npmrc_allow_remote_with(existing.as_deref(), &outer) { - NpmrcPlan::AlreadyAll => npm_allow_remote_already_detail(&npm_hosts), - NpmrcPlan::UserSet(value) => { - npm_allow_remote_user_set_detail(&npm_hosts, &value) - } - NpmrcPlan::EnvSet { var, value } => { - npm_allow_remote_env_set_detail(&npm_hosts, &var, &value) - } - NpmrcPlan::OuterSet { layer, path, value } => { - npm_allow_remote_outer_set_detail(&npm_hosts, layer, &path, &value) - } - NpmrcPlan::Unsupported(why) => { - npm_allow_remote_unreadable_detail(&npm_hosts, &why) - } - _ if common.no_npm_allow_remote_config => { - npm_allow_remote_manual_detail(&npm_hosts) - } - NpmrcPlan::Create(text) => { - npmrc_config_write = Some((text, edit("created"))); - npm_allow_remote_configured_detail(&npm_hosts, true, common.dry_run) - } - NpmrcPlan::Append(text) => { - npmrc_config_write = Some((text, edit("added"))); - npm_allow_remote_configured_detail(&npm_hosts, false, common.dry_run) - } - }, - Err(why) => npm_allow_remote_unreadable_detail(&npm_hosts, &why), - }; - npm_warnings.push(serde_json::json!({ - "code": "redirect_npm_allow_remote", - "detail": detail, - })); - } - } - if let Some((text, edit)) = trust_config_write { - rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); - // Appended last: `--revert` walks edits in reverse, so the trust key - // is unwound before the lock originals are restored. - rewrite.edits.push(edit); - } - if let Some((text, edit)) = npmrc_config_write { - rewrite.files.insert( - socket_patch_core::patch::redirect::npmrc::NPMRC_REL.to_string(), - text, - ); - // Appended after the lock edits for the same reason: a whole-ledger - // replay unwinds the setting before the lock originals it served. - rewrite.edits.push(edit); - } - let rewritten: Vec = rewrite - .files - .keys() - .chain(rewrite.binary_files.keys()) - .cloned() - .collect(); - - // A dep counts as REDIRECTED only if its hosted-artifact URL (or its - // per-dependency registry index URL) actually landed in the project's - // files — either written by this run or already present from an earlier - // one. A granted reference whose rewriter found nothing to edit (e.g. no - // lockfile) must NOT be recorded or attested: nothing pins the patch. - // A `pdm.lock` that is NOT the PyPI install driver (a `uv.lock` or - // `poetry.lock` sits beside it) is never rewritten, yet can still carry a - // Socket artifact URL from an earlier run. That stale text pins nothing, - // so it must not feed the substring probe below. When pdm DOES drive, - // pypi confirmation keys off `confirmed_pdm_uuids`, so dropping the file - // is always safe. - let pdm_inactive = - files.contains_key("pdm.lock") && !socket_patch_core::patch::redirect::pdm_drives(&files); - // Likewise a `vlt-lock.json` the vlt rewrite was withheld from (its - // artifact failed the preflight beside another npm-family lock) may - // still hold an earlier run's pin: only the sibling lock this run - // rewrote can confirm that dep. - let final_texts: Vec<(&str, &String)> = files - .iter() - .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) - .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) - .chain( - rewrite - .files - .iter() - .filter(|(name, _)| !files.contains_key(*name)) - .map(|(name, content)| (name.as_str(), content)), - ) - .collect(); - // Every non-substring rule decides a candidate outright; the rest are - // confirmed by substring presence of their needles in the final texts. - // All needle groups are answered in ONE multi-needle pass per text - // (`groups_present`), which is the per-candidate `any()` exactly — - // presence does not depend on search order, and `confirmed` keeps - // candidate order. `candidate_present_oracle` is the reference form. - let steps: Vec = candidates - .iter() - .map(|c| { - let purl = c.purl.as_str(); - let uuid = c.dep.patch_uuid.as_str(); - // vlt decides before the binary-bun rule, so `bun.lockb` beside - // a vlt-driven `vlt-lock.json` never confirms an npm purl. - if rewrite.refused_vlt_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - if rewrite.vlt_drives && purl.starts_with("pkg:npm/") { - return ProbeStep::Decided(rewrite.confirmed_vlt_uuids.contains(uuid)); - } - if binary_bun && purl.starts_with("pkg:npm/") { - return ProbeStep::Decided(rewrite.confirmed_bun_binary_uuids.contains(uuid)); - } - if rewrite.refused_pipenv_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - // pdm is transactional like cargo: a refused uuid is never - // confirmed, and when `pdm.lock` is the PyPI install driver - // (no `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY - // by the pdm rewriter's own report — the URL landing in a - // sibling `requirements.txt` the project does not install from - // pins nothing. When uv/poetry drive, their own lock proof - // below still confirms them. This check precedes the hatch - // gate: a PDM project may declare `hatchling` as its build - // backend, which registers every pypi uuid as hatch-owned while - // the lock's presence keeps hatch from confirming any of them. - if rewrite.refused_pdm_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - if purl.starts_with("pkg:pypi/") - && socket_patch_core::patch::redirect::pdm_drives(&files) - { - return ProbeStep::Decided(rewrite.confirmed_pdm_uuids.contains(uuid)); - } - if rewrite.python_lock_uuids.contains(uuid) { - return ProbeStep::Decided( - rewrite.confirmed_python_lock_uuids.contains(uuid) - && !rewrite.refused_python_lock_uuids.contains(uuid), - ); - } - if rewrite.hatch_uuids.contains(uuid) { - return ProbeStep::Decided(rewrite.confirmed_hatch_uuids.contains(uuid)); - } - // A Pipfile.lock rewrite confirms its own uuids (the sibling - // requirements.txt rewriter may have had nothing to do). - if purl.starts_with("pkg:pypi/") { - return ProbeStep::Decided( - rewrite.confirmed_pipenv_uuids.contains(uuid) - || rewrite.confirmed_requirements_uuids.contains(uuid), - ); - } - if rewrite.refused_pnpm_uuids.contains(uuid) { - return ProbeStep::Decided(false); - } - // Cargo is transactional: the rewriter reports exactly which - // patch uuids FULLY landed (manifest pin + lock + registry - // block). Substring presence must never confirm a cargo dep — - // the `[registries.…]` config block contains the index URL while - // pinning nothing, so a config-block-only rewrite would be - // attested with zero enforcement in any build. - if purl.starts_with("pkg:cargo/") { - return ProbeStep::Decided(rewrite.confirmed_cargo_uuids.contains(uuid)); - } - // Golang likewise: the goproxy `indexUrl` is the bare - // patch-server origin (present in any other hosted lock), and - // the socket module's go.sum lines outlive a removed replace. - if purl.starts_with("pkg:golang/") { - return ProbeStep::Decided(rewrite.confirmed_golang_uuids.contains(uuid)); - } - let needles = candidate_presence_needles(&c.dep); - if vlt_preflight.withheld_from_vlt.contains(uuid) { - ProbeStep::NeedlesOutsideVlt(needles) - } else { - ProbeStep::Needles(needles) - } - }) - .collect(); - let groups = |outside_vlt: bool| -> Vec<&[String]> { - steps - .iter() - .filter_map(|step| match step { - ProbeStep::Needles(needles) if !outside_vlt => Some(needles.as_slice()), - ProbeStep::NeedlesOutsideVlt(needles) if outside_vlt => Some(needles.as_slice()), - _ => None, - }) - .collect() + Err(err) => std::panic::resume_unwind(err.into_panic()), + }, + (true, None) => socket_patch_core::utils::pipenv::installed_major(&common.cwd).await, + // Unreachable (the early start implies the target), but never leave + // a probe running: aborting drops it, which reaps the child. + (false, Some(probe)) => { + probe.abort(); + None + } + (false, None) => None, }; - let all_texts: Vec<&String> = final_texts.iter().map(|(_, text)| *text).collect(); - let mut present = - socket_patch_core::patch::redirect::presence::groups_present(&all_texts, &groups(false)) - .into_iter(); - let outside_vlt_groups = groups(true); - let mut present_outside_vlt = if outside_vlt_groups.is_empty() { - Vec::new() - } else { - let texts: Vec<&String> = final_texts - .iter() - .filter(|(name, _)| *name != socket_patch_core::constants::npm_family::VLT_LOCK) - .map(|(_, text)| *text) - .collect(); - socket_patch_core::patch::redirect::presence::groups_present(&texts, &outside_vlt_groups) - } - .into_iter(); - let confirmed: Vec<(String, String)> = candidates - .iter() - .zip(&steps) - .filter(|(_, step)| match step { - ProbeStep::Decided(keep) => *keep, - ProbeStep::Needles(_) => present - .next() - .expect("one presence answer per needle group"), - ProbeStep::NeedlesOutsideVlt(_) => present_outside_vlt - .next() - .expect("one presence answer per needle group"), + // The npm config layers OUTSIDE the project file, located the way npm + // does: an env `npm_config_allow_remote` beats the project file, and an + // explicit user / global / builtin value is a machine / org policy a + // committed project line would silently override — both are respected + // like a project value. + let npm_outer = || { + use socket_patch_core::patch::redirect::npmrc::{resolve_outer_allow_remote, NpmConfigEnv}; + resolve_outer_allow_remote(&NpmConfigEnv::from_process(), |path| { + socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok() }) - .map(|(c, _)| (c.purl.clone(), c.dep.patch_uuid.clone())) - .collect(); + }; + let done = engine::rewrite( + &view, + read, + &candidates, + python_metadata, + &vlt_preflight.withheld_from_vlt, + &dry_run_takeover_urls, + RewriteOptions { + dry_run: common.dry_run, + targets_pipenv_lock, + pipenv_major, + pipenv_unknown_detail: format!( + "Pipenv was not found on PATH, so the Pipfile.lock references use the modern `file` form (Pipenv 2018 and later). A project installed with Pipenv 7–11 needs `path` references instead: put that pipenv on PATH or set {}= and re-run `scan --mode hosted`.", + socket_patch_core::utils::pipenv::MAJOR_OVERRIDE_ENV + ), + trust_lockfile_config: !common.no_trust_lockfile_config, + npm_allow_remote_config: !common.no_npm_allow_remote_config, + npm_outer: &npm_outer, + blocking: true, + }, + ) + .await; // Dry-run mode-takeover previews were withheld from the rewriters (their // lock fragments still carry the vendored wiring the wet run reverts - // first), so the presence probe above cannot see them: the wet run - // reverts then redirects each one, and the preview's `redirected` count - // must report that outcome. Populated only under --dry-run. - let mut confirmed = confirmed; + // first), so the presence probe cannot see them: the wet run reverts + // then redirects each one, and the preview's `redirected` count must + // report that outcome. Populated only under --dry-run. + let mut confirmed = done.confirmed.clone(); confirmed.extend(dry_run_takeover); // Fetch the full patch view (file hashes + vulnerabilities) for each @@ -2657,27 +899,14 @@ pub(crate) async fn run_redirect_selected( // stderr) so CI can detect the attestation gap and re-run. let mut records: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut record_warnings: Vec = Vec::new(); - - // SYMLINK GUARD — fail-closed, whole rewrite, before the ledger and before - // any write (hosted rewrites are transactional). The writer below stages - // next to the path and renames over it, which REPLACES a symbolic link - // with a detached regular copy: the link target goes stale and - // `--revert` restores bytes but never the link. The revert side - // (replay.rs) already refuses linked files, so the write side must too. - // Applies to every ecosystem's files and to dry runs, so a dry run - // predicts the refusal. - if let Some(linked) = socket_patch_core::utils::fs::first_symlink( - &common.cwd, - rewrite - .files - .keys() - .chain(rewrite.binary_files.keys()) - .map(String::as_str), - ) - .await - { - return refuse_symlinked_file(common, scan_result.take(), linked); + let mut record_warnings: Vec = Vec::new(); + + // SYMLINK GUARD (see `engine::guard`) — before the ledger and before any + // write, dry runs included, so a dry run predicts the refusal. The + // revert side (replay.rs) already refuses linked files, so the write + // side must too. + if let Some(refusal) = engine::guard(&view, &done, &candidates) { + return refuse(common, scan_result.take(), &refusal); } if !common.dry_run { @@ -2694,7 +923,9 @@ pub(crate) async fn run_redirect_selected( |(_, uuid)| { hold_back_debug(async move { api_client.fetch_patch(uuid).await.map(|resp| { - resp.map(|resp| crate::commands::get::record_from_patch_response(&resp)) + resp.map(|resp| { + socket_patch_core::manifest::records::record_from_patch_response(&resp) + }) }) }) }, @@ -2709,20 +940,14 @@ pub(crate) async fn run_redirect_selected( records.insert(rec_purl, record); } Ok(None) | Err(_) => { - record_warnings.push(serde_json::json!({ - "code": "record_fetch_failed", - "detail": format!( - "{purl} was switched to hosted, but its patch record could not be \ - fetched; this run's VEX attestation omits it (`socket-patch vex` \ - fetches it again once the API answers)" - ), - })); + record_warnings.push(engine::record_fetch_failed_warning(purl)); } } } status.finish(); } + let rewrite = &done.rewrite; if !common.dry_run { for (rel, content) in rewrite .files @@ -2763,7 +988,8 @@ pub(crate) async fn run_redirect_selected( // purl-coordinate → the PATCHED .gem artifact's sha256 (registry // override identifier, tarball integrity fallback) — judges a // committed vendor/cache archive. - let gem_artifact_shas: std::collections::BTreeMap<(String, String), String> = overrides + let gem_artifact_shas: std::collections::BTreeMap<(String, String), String> = done + .overrides .iter() .filter(|o| o.ecosystem == "gem") .filter_map(|o| { @@ -2811,7 +1037,7 @@ pub(crate) async fn run_redirect_selected( final_lock: rewrite .files .get(lock_key) - .or_else(|| files.get(lock_key)) + .or_else(|| done.files.get(lock_key)) .map(String::as_str), preflight: &vlt_preflight, records: &records, @@ -2929,20 +1155,14 @@ pub(crate) async fn run_redirect_selected( // One merged warning list, in one order, for both channels: the // rewriter's own warnings first (e.g. `no package-lock.json`), then the // record, package-manager, stale-install, takeover and prune warnings. - let mut warnings: Vec = rewrite - .warnings - .iter() - .map(|w| { - serde_json::json!({ - "code": w.code, "detail": w.detail, - }) - }) - .collect(); - warnings.extend(vlt_preflight.warnings.iter().cloned()); - warnings.extend(record_warnings.iter().cloned()); - warnings.extend(rush_warnings.iter().cloned()); - warnings.extend(pnpm_warnings.iter().cloned()); - warnings.extend(npm_warnings.iter().cloned()); + let mut engine_warnings = rewrite.warnings.clone(); + engine_warnings.extend(vlt_preflight.warnings.iter().cloned()); + engine_warnings.extend(record_warnings); + engine_warnings.extend(done.rush_warnings.iter().cloned()); + engine_warnings.extend(done.pnpm_warnings.iter().cloned()); + engine_warnings.extend(done.npm_warnings.iter().cloned()); + let mut warnings: Vec = + socket_patch_core::hosted::render::rewrite_warnings_json(&engine_warnings); warnings.extend(gem_stale.warnings.iter().cloned()); warnings.extend(python_stale.warnings.iter().cloned()); warnings.extend(vlt_stale.warnings.iter().cloned()); @@ -2957,8 +1177,11 @@ pub(crate) async fn run_redirect_selected( // envelope keeps the same top-level scan keys as every other scan. let redirect = redirect_json_block( confirmed.len(), - rewritten, - skipped, + done.rewritten.clone(), + skipped + .iter() + .map(socket_patch_core::hosted::render::skipped_json) + .collect(), warnings, common.dry_run, ); @@ -2999,7 +1222,7 @@ pub(crate) async fn run_redirect_selected( // takeover is withheld from the rewriters, and a wet revert can // touch a wiring file the hosted rewriter never rewrites. The // same union in both modes keeps preview and wet counts equal. - let mut human_files = rewritten.clone(); + let mut human_files = done.rewritten.clone(); human_files.extend(takeover_files.iter().cloned()); human_files.sort(); human_files.dedup(); @@ -3027,12 +1250,7 @@ pub(crate) async fn run_redirect_selected( // would JSON-quote them. let skipped_pairs: Vec<(String, String)> = skipped .iter() - .map(|s| { - ( - s["purl"].as_str().unwrap_or_default().to_string(), - s["reason"].as_str().unwrap_or_default().to_string(), - ) - }) + .map(|s| (s.purl.clone(), s.reason.clone())) .collect(); // Granted, but nothing in the project pins it (no lock entry, // unreadable lock, ...): listed so it never vanishes silently. @@ -3045,11 +1263,7 @@ pub(crate) async fn run_redirect_selected( .iter() .any(|(cp, cu)| *cp == c.purl && *cu == c.dep.patch_uuid) }) - .filter(|c| { - !skipped - .iter() - .any(|s| s["uuid"].as_str() == Some(c.dep.patch_uuid.as_str())) - }) + .filter(|c| !skipped.iter().any(|s| s.uuid == c.dep.patch_uuid)) .map(|c| c.purl.clone()) .collect(); for line in format_unredirected( @@ -3070,7 +1284,7 @@ pub(crate) async fn run_redirect_selected( // `--verbose`. eprintln!("{}", npm_allow_remote_one_line(detail)); continue; - } else if *code == "redirect_pnpm_trust_lockfile" && pnpm_rerun_only { + } else if *code == "redirect_pnpm_trust_lockfile" && done.pnpm_rerun_only { pnpm_trust_rerun_reminder() } else { detail @@ -3112,6 +1326,413 @@ pub(crate) async fn run_redirect_selected( vex_code } +/// Cross-mode takeover: a purl this run is about to redirect may still be +/// VENDORED — for cargo a committed `[patch.crates-io]` path entry, a +/// detached Cargo.lock entry, a committed copy, and a vendored ledger +/// entry; for the npm family a `file:./.socket/vendor/…` lock resolution +/// (plus a berry `resolutions` pin) and its committed tarball; for golang +/// the vendor-owned go.mod `replace`, its committed module copy, and its +/// ledger entry. The hosted rewriters know nothing about that wiring +/// (cargo would refuse `--locked` builds over the unused `[patch]` entry; +/// yarn classic would hijack a resolution the vendored ledger still +/// claims; yarn berry refuses `file:` outright). A takeover must leave the +/// project FULLY hosted: revert each such purl's vendored state first (the +/// per-purl machinery `vendor --revert` runs), and only then redirect — +/// which also hands the redirect the PRISTINE registry lock fragment to +/// record as its own revert original. A purl +/// whose vendored state cannot be cleanly reverted (revert failure, or +/// vendored wiring with a missing/corrupt ledger) is REFUSED — skipped +/// with an actionable error — never half-migrated. +/// +/// Refused purls are moved from `candidates` into `skipped`; dry-run +/// takeover previews leave `candidates` too (see [`Takeover::dry_run`]). +/// `Err` is the symlinked-wiring refusal (nothing was written). +async fn vendored_takeover( + common: &crate::args::GlobalArgs, + candidates: &mut Vec, + vendor_state: &mut std::io::Result, + skipped: &mut Vec, +) -> Result { + use socket_patch_core::hosted::engine::{Candidate, SkippedPatch, TakeoverPreview}; + let mut out = Takeover::default(); + // Which root locks each dry-run takeover purl is vendored into (from + // its vendor ledger wiring): the wet run reverts that wiring and then + // splices the hosted URL there, so the install-policy auto-configs + // (npm `.npmrc` allow-remote, pnpm `trustLockfile`) must be PREVIEWED + // for those locks even though the rewriters never see these purls. + let mut dry_run_locks: std::collections::HashMap> = + std::collections::HashMap::new(); + let takeover_capable = |p: &str| { + p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") + }; + if !candidates.iter().any(|c| takeover_capable(&c.purl)) { + // No takeover-capable candidates — nothing to reconcile. + return Ok(out); + } + use socket_patch_core::utils::purl::{canonical_purl as canon, strip_purl_qualifiers}; + // Each takeover-capable candidate with its vendored ledger entry, if + // any (cloned out so the loop can mutate the state). + let takeover: Vec<(&Candidate, Option)> = candidates + .iter() + .filter(|c| takeover_capable(&c.purl)) + .map(|c| { + let entry = vendor_state + .as_ref() + .ok() + .and_then(|s| { + socket_patch_core::vendor::lookup_entry( + &s.entries, + strip_purl_qualifiers(&c.purl), + ) + }) + .cloned(); + (c, entry) + }) + .collect(); + // Compatibility must be known before the takeover removes a live + // patch. In particular, a v0 workspace can keep an existing local + // tuple even though hosted mode cannot replace it with a URL. Only + // an npm purl WITH a vendored entry can be taken over, so the bun + // locks are read here only when one exists — the candidate-file + // read below covers every other run. + let bun_takeover_refusal = if takeover + .iter() + .any(|(c, entry)| entry.is_some() && c.purl.starts_with("pkg:npm/")) + { + match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("bun.lock")) + .await + { + Ok(content) => socket_patch_core::patch::redirect::preflight_bun_hosted(&content).err(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + match socket_patch_core::utils::fs::read_regular_to_bytes_sync( + &common.cwd.join("bun.lockb"), + ) { + Ok(bytes) => { + socket_patch_core::patch::redirect::preflight_bun_binary(&bytes).err() + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning { + code: "redirect_bun_lockb_invalid".into(), + detail: format!("cannot read bun.lockb: {e}"), + }), + } + } + Err(e) => Some(socket_patch_core::patch::redirect::RewriteWarning { + code: "redirect_bun_lock_unsupported".into(), + detail: format!("cannot read bun.lock before mode takeover: {e}"), + }), + } + } else { + None + }; + // Yarn berry twin of the bun gate: the berry rewriter's project-level + // refusals (mixed line endings, cacheKey, `.yarnrc.yml` + // compressionLevel) must be known before the takeover reverts a + // vendored berry purl, or the revert strips the live vendored patch + // and the rewriter then refuses the lock. Only entries the + // vendor ledger wired through the yarn-berry backend are gated (the + // lock is read only when one exists); an unreadable lock is left to + // the revert's own diagnostics. + let berry_entry = |entry: &socket_patch_core::vendor::VendorEntry| { + entry.ecosystem == "npm" && entry.flavor.as_deref() == Some("yarn-berry") + }; + let berry_takeover_refusal = if takeover + .iter() + .any(|(_, entry)| entry.as_ref().is_some_and(berry_entry)) + { + match socket_patch_core::utils::fs::read_regular_to_string(&common.cwd.join("yarn.lock")) + .await + { + Ok(lock) => { + let yarnrc = socket_patch_core::utils::fs::read_regular_to_string( + &common.cwd.join(".yarnrc.yml"), + ) + .await + .ok(); + socket_patch_core::patch::redirect::preflight_yarn_berry_hosted( + &lock, + yarnrc.as_deref(), + ) + .err() + } + Err(_) => None, + } + } else { + None + }; + // vlt twin: the hosted rewriter's lock-level refusal must be known + // before a vendored vlt entry is reverted, or the revert strips the + // live vendored patch and the rewrite then refuses the lock. + let vlt_entry = |entry: &socket_patch_core::vendor::VendorEntry| { + entry.ecosystem == "npm" && entry.flavor.as_deref() == Some("vlt") + }; + let vlt_takeover_refusal = if takeover + .iter() + .any(|(_, entry)| entry.as_ref().is_some_and(vlt_entry)) + { + match socket_patch_core::utils::fs::read_regular_to_string( + &common + .cwd + .join(socket_patch_core::constants::npm_family::VLT_LOCK), + ) + .await + { + Ok(lock) => { + let files = std::collections::BTreeMap::from([( + socket_patch_core::constants::npm_family::VLT_LOCK.to_string(), + lock, + )]); + socket_patch_core::patch::redirect::vlt::preflight_vlt_hosted(&files).err() + } + Err(_) => None, + } + } else { + None + }; + // The takeover refusal (if any) for one candidate: bun gates every + // npm purl, berry and vlt only their own vendored entries. A refused + // purl is never dispatched (see the loop), so its wiring is not a + // write target here. + let takeover_refusal = |c: &Candidate, + entry: Option<&socket_patch_core::vendor::VendorEntry>| + -> Option<&socket_patch_core::patch::redirect::RewriteWarning> { + if !c.purl.starts_with("pkg:npm/") { + return None; + } + bun_takeover_refusal + .as_ref() + .or_else(|| { + berry_takeover_refusal + .as_ref() + .filter(|_| entry.is_some_and(berry_entry)) + }) + .or_else(|| { + vlt_takeover_refusal + .as_ref() + .filter(|_| entry.is_some_and(vlt_entry)) + }) + }; + // SYMLINK PRE-CHECK for the takeover reverts — the same rule as the + // SYMLINK GUARD below, applied to each ledger entry's recorded wiring + // (the revert backends also stage and rename over the file). Checked + // BEFORE any revert dispatches (and under --dry-run too) so "nothing + // was written" stays true. + let revert_targets = takeover + .iter() + .filter_map(|(c, entry)| { + entry + .as_ref() + .filter(|e| takeover_refusal(c, Some(e)).is_none()) + }) + .flat_map(|entry| entry.wiring.iter().map(|w| w.file.as_str())); + if let Some(linked) = + socket_patch_core::utils::fs::first_symlink(&common.cwd, revert_targets).await + { + return Err(socket_patch_core::hosted::engine::symlink_refusal(linked)); + } + let mut refused: Vec = Vec::new(); + for (candidate, ledger_entry) in &takeover { + let purl = &candidate.purl; + let uuid = &candidate.dep.patch_uuid; + if let Some(entry) = ledger_entry { + if let Some(warning) = takeover_refusal(candidate, Some(entry)) { + refused.push(purl.clone()); + if !out.pre_warnings.iter().any(|w| w["code"] == warning.code) { + out.pre_warnings.push(serde_json::json!(warning)); + } + continue; + } + if common.dry_run { + // Preview through the same per-purl revert machinery the + // wet run dispatches (write-free under dry_run): a + // vendored state the wet run would refuse to revert is + // refused here too, and one it would revert is announced + // as a takeover — never handed to the rewriters, which + // would refuse the still-vendored wiring. + let outcome = + crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, true).await; + if !outcome.success { + refused.push(purl.clone()); + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} is vendored and its vendored state could not be \ + reverted ({}); NOT switched to hosted — run `socket-patch vendor \ + --revert` to clean up, then re-run `scan --mode hosted`", + outcome.error.as_deref().unwrap_or("unknown error") + ), + })); + continue; + } + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_would_revert_vendored", + "detail": format!( + "{purl} is currently vendored; the hosted wiring will \ + revert its vendored wiring, ledger entry, and committed \ + artifact first, then switch to hosted (mode takeover)" + ), + })); + out.dry_run.push((purl.clone(), uuid.clone())); + out.migrated.push(purl.clone()); + out.files + .extend(entry.wiring.iter().map(|w| w.file.clone())); + dry_run_locks.insert( + purl.clone(), + entry.wiring.iter().map(|w| w.file.clone()).collect(), + ); + continue; + } + let outcome = + crate::commands::vendor::dispatch_revert_one(entry, &common.cwd, false).await; + if !outcome.success { + refused.push(purl.clone()); + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} is vendored and its vendored state could not be \ + reverted ({}); NOT switched to hosted — run `socket-patch vendor \ + --revert` to clean up, then re-run `scan --mode hosted`", + outcome.error.as_deref().unwrap_or("unknown error") + ), + })); + continue; + } + // Drop the reverted entry from the in-memory ledger and + // persist per purl so a crash mid-run leaves a ledger + // matching the on-disk wiring. The entry stays dropped even + // when the save fails: its wiring and artifact ARE gone, so + // a later successful save in this loop writes the truth. + let state = vendor_state + .as_mut() + .expect("a vendored ledger entry was looked up in this state, so it loaded"); + state + .entries + .retain(|k, e| canon(k) != canon(purl) && canon(&e.base_purl) != canon(purl)); + if let Err(e) = socket_patch_core::vendor::save_state(&common.cwd, state).await { + // The wiring is reverted but the ledger still claims it; + // redirecting now would leave a ledger asserting wiring + // that is gone. Fail closed for this purl. + refused.push(purl.clone()); + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl}: vendored wiring reverted but the vendored ledger \ + could not be updated ({e}); NOT switched to hosted — fix \ + .socket/vendor/state.json and re-run" + ), + })); + continue; + } + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_takeover_reverted_vendored", + "detail": format!( + "{purl} was vendored; reverted its vendored wiring, ledger \ + entry, and committed artifact before switching to hosted (mode \ + takeover: the project is now fully hosted for this package)" + ), + })); + out.pre_warnings.extend( + outcome + .warnings + .iter() + .filter(|w| w.code == socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED) + .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })), + ); + out.migrated.push(purl.clone()); + out.files + .extend(entry.wiring.iter().map(|w| w.file.clone())); + } else { + // No usable ledger entry. If socket-owned vendored wiring for + // this crate is nevertheless present, the ledger is missing or + // corrupt — the originals needed to revert are unrecoverable, + // so redirecting on top would wedge the project. Refuse. + // (Cargo-only probe: Socket-owned `[patch.crates-io]` entries + // for exactly this name@version in the root Cargo.toml or a + // legacy `.cargo/config*` — another vendored version of the + // crate has its own ledger entry. An npm purl in this state + // falls through to the rewriters' own per-flavor + // diagnostics.) + let coords = purl + .starts_with("pkg:cargo/") + .then(|| purl_parts(purl).map(|(_, name, version)| (name, version))) + .flatten(); + let wired = match &coords { + Some((n, v)) => { + socket_patch_core::vendor::cargo::socket_wiring_present(&common.cwd, n, v).await + } + None => false, + }; + if wired { + refused.push(purl.clone()); + out.pre_warnings.push(serde_json::json!({ + "code": "redirect_vendored_revert_failed", + "detail": format!( + "{purl} has socket-owned vendored `[patch.crates-io]` \ + wiring but no usable vendored ledger entry \ + (.socket/vendor/state.json is missing or corrupt); NOT \ + redirected — restore the ledger or remove the vendored \ + wiring manually, then re-run" + ), + })); + } + } + } + for purl in &refused { + if let Some((c, entry)) = takeover.iter().find(|(c, _)| &c.purl == purl) { + let reason = takeover_refusal(c, entry.as_ref()) + .map_or("vendored_revert_failed", |w| w.code.as_str()); + skipped.push(SkippedPatch::new(purl, &c.dep.patch_uuid, reason)); + } + } + // Purls leaving the rewrite set: refused takeovers, plus the dry-run + // takeover previews (still vendored on disk — the wet run reverts + // them before the rewriters ever see their files). + let withheld: std::collections::HashSet<&str> = refused + .iter() + .map(String::as_str) + .chain(out.dry_run.iter().map(|(p, _)| p.as_str())) + .collect(); + if !withheld.is_empty() { + // Keep the dry-run takeover candidates' URLs (and the root locks + // their purl is vendored into) for the install-policy previews. + for (purl, _) in &out.dry_run { + let locks = dry_run_locks.get(purl).cloned().unwrap_or_default(); + for c in candidates.iter().filter(|c| &c.purl == purl) { + out.previews.push(TakeoverPreview { + artifact_url: c.dep.artifact_url.clone(), + locks: locks.clone(), + }); + } + } + candidates.retain(|c| !withheld.contains(c.purl.as_str())); + } + Ok(out) +} + +/// What [`vendored_takeover`] did (or, on `--dry-run`, would do). +#[derive(Default)] +struct Takeover { + /// Its warnings, reported after the rewriters' own. + pre_warnings: Vec, + /// Dry-run takeover previews: `(purl, uuid)` pairs whose vendored state + /// the wet run would revert and then redirect. Withheld from the + /// rewriters (their lock fragments still carry the vendored wiring the + /// wet run reverts FIRST) and counted as redirected, so the preview's + /// envelope matches the wet run's outcome. + dry_run: Vec<(String, String)>, + /// Human output: the purls migrated (or, on --dry-run, to be migrated) + /// from vendored to hosted. + migrated: Vec, + /// The files their revert touches (or would touch). Both modes count + /// `rewritten ∪ files`, so the preview's file count matches the wet + /// run's even for wiring files the hosted rewriter does not also + /// rewrite (a Gemfile line, a uv source). + files: std::collections::BTreeSet, + /// The withheld dry-run takeover candidates' artifact URLs and wired + /// root locks, for the install-policy previews. + previews: Vec, +} + // ── Human-output formatting ──────────────────────────────────────────────── // // Pure `String` builders for everything the hosted flow prints in human @@ -3457,6 +2078,38 @@ pub(crate) fn boxed_run_redirect_selected<'a>( )) } +/// The default human form of a `redirect_npm_allow_remote` warning: one +/// line saying whether the project `.npmrc` now carries `allow-remote=all` +/// or the user must set it. `detail` is one of the `npm_allow_remote_*` +/// texts below; `--verbose` and `--json` show it in full. +pub(crate) fn npm_allow_remote_one_line(detail: &str) -> String { + const MORE: &str = "(details: --verbose)"; + if detail.contains("`allow-remote=all` was written to a new") + || detail.contains("`allow-remote=all` was appended to the existing") + { + format!( + "Note: set `allow-remote=all` in .npmrc so npm >=12 installs the hosted \ + patches; commit it with the lockfile {MORE}." + ) + } else if detail.contains("`allow-remote=all` would be") { + format!( + "Note: would set `allow-remote=all` in .npmrc so npm >=12 installs the \ + hosted patches {MORE}." + ) + } else if detail.contains("already sets `allow-remote=all`") { + format!( + "Note: .npmrc already sets `allow-remote=all`, so npm >=12 installs the \ + hosted patches; keep it committed {MORE}." + ) + } else { + format!( + "Warning: npm >=12 refuses the hosted patches until `allow-remote=all` is \ + set (in .npmrc, or `npm ci --allow-remote=all`); it was not set \ + automatically {MORE}." + ) + } +} + #[cfg(test)] mod tests { use super::{ @@ -3469,7 +2122,7 @@ mod tests { pnpm_lock_carries_hosted_redirect, pnpm_lock_version_major, pnpm_trust_configured_detail, pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_workspace_unreadable_detail, prune_ignored_warning, read_npmrc_for_allow_remote, - read_workspace_for_trust, redirect_json_block, TrustPlan, REDIRECT_CANDIDATE_FILES, + read_workspace_for_trust, redirect_json_block, TrustPlan, }; use super::{ describe_skip_reason, format_error_line, format_next_steps, format_redirect_summary, @@ -3478,6 +2131,7 @@ mod tests { wrap_tokens, wrap_words, TAKEOVER_INFO_CODES, }; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; + use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use socket_patch_core::patch::redirect::DepOverride; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; @@ -5301,170 +3955,3 @@ mod tests { } } } - -#[cfg(test)] -mod probe_equivalence_tests { - use super::{candidate_presence_needles, candidate_present_oracle, npm_lock_url_needles}; - use socket_patch_core::patch::redirect::presence::groups_present; - use socket_patch_core::patch::redirect::{ - artifact_url_present, artifact_url_spellings, rewrite_registry_redirect, DepOverride, - }; - use socket_patch_core::utils::uri::encode_uri_component; - use std::collections::BTreeMap; - use std::path::{Path, PathBuf}; - - fn golden_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../socket-patch-core/tests/fixtures/redirect") - } - - fn cases(dir: &Path, out: &mut Vec) { - if dir.join("input").is_dir() && dir.join("overrides.json").is_file() { - out.push(dir.to_path_buf()); - return; - } - for entry in std::fs::read_dir(dir).unwrap() { - let p = entry.unwrap().path(); - if p.is_dir() { - cases(&p, out); - } - } - } - - fn read_tree(base: &Path) -> BTreeMap { - fn walk(base: &Path, dir: &Path, out: &mut BTreeMap) { - for entry in std::fs::read_dir(dir).unwrap() { - let p = entry.unwrap().path(); - if p.is_dir() { - walk(base, &p, out); - } else if let Ok(text) = std::fs::read_to_string(&p) { - let rel = p.strip_prefix(base).unwrap().to_string_lossy(); - out.insert(rel.replace('\\', "/"), text); - } - } - } - let mut out = BTreeMap::new(); - if base.is_dir() { - walk(base, base, &mut out); - } - out - } - - /// Every golden fixture (composer `\/`, berry percent-encoded, maven - /// suffixed version, go module path, cargo index url, …): each case's - /// final texts — input overlaid with this CLI's own rewrite, as the probe - /// sees them — plus its authored `expected/` files, probed with EVERY - /// fixture's overrides so hits and misses are both well exercised. - #[test] - fn multi_needle_probe_matches_per_candidate_any_on_golden_fixtures() { - let mut dirs = Vec::new(); - cases(&golden_root(), &mut dirs); - dirs.sort(); - assert!(dirs.len() > 50, "golden fixtures not found: {}", dirs.len()); - - let mut all_overrides: Vec = Vec::new(); - let mut text_sets: Vec> = Vec::new(); - for case in &dirs { - let overrides: Vec = match serde_json::from_str( - &std::fs::read_to_string(case.join("overrides.json")).unwrap(), - ) { - Ok(o) => o, - Err(_) => continue, - }; - let input = read_tree(&case.join("input")); - let rewrite = rewrite_registry_redirect(&input, &overrides); - let finals: Vec = input - .iter() - .map(|(name, text)| rewrite.files.get(name).unwrap_or(text).clone()) - .chain( - rewrite - .files - .iter() - .filter(|(name, _)| !input.contains_key(*name)) - .map(|(_, t)| t.clone()), - ) - .collect(); - text_sets.push(finals); - text_sets.push(read_tree(&case.join("expected")).into_values().collect()); - text_sets.push(input.into_values().collect()); - all_overrides.extend(overrides); - } - text_sets.push(Vec::new()); - // Texts that carry ONE needle kind and nothing else — no golden - // fixture has the maven suffixed version or the registry index URL - // without the artifact URL beside it, so a needle dropped from - // `candidate_presence_needles` would otherwise go unnoticed. - let mut lone_suffixed: Vec = Vec::new(); - for o in all_overrides - .iter() - .filter_map(|d| d.registry_override.as_ref()) - { - text_sets.push(vec![format!("{}\n", o.index_url)]); - if let Some(sv) = o.identifiers.maven_suffixed_version.as_deref() { - lone_suffixed.push(text_sets.len()); - text_sets.push(vec![format!("{sv}\n")]); - } - } - assert!( - !lone_suffixed.is_empty(), - "no maven suffixed-version override" - ); - - let groups: Vec> = all_overrides - .iter() - .map(candidate_presence_needles) - .collect(); - let (mut hits, mut misses) = (0usize, 0usize); - for (set, texts) in text_sets.iter().enumerate() { - let refs: Vec<&String> = texts.iter().collect(); - let fast = groups_present(&refs, &groups); - if lone_suffixed.contains(&set) { - assert!( - fast.iter().any(|hit| *hit), - "a lone suffixed version confirms its maven override" - ); - } - for (dep, got) in all_overrides.iter().zip(&fast) { - let want = candidate_present_oracle(&refs, dep); - assert_eq!( - *got, want, - "{}/{} / {}", - dep.ecosystem, dep.name, dep.artifact_url - ); - if want { - hits += 1; - } else { - misses += 1; - } - } - } - assert!(hits > 100 && misses > 100, "hits={hits} misses={misses}"); - - // The pnpm / npm host filters and the heal probe: the npm lock - // spellings, and the bare `artifact_url_present` pair. - let npm: Vec<&DepOverride> = all_overrides.iter().collect(); - let lock_groups: Vec> = npm - .iter() - .map(|o| npm_lock_url_needles(&o.artifact_url)) - .collect(); - let pair_groups: Vec<[String; 2]> = npm - .iter() - .map(|o| artifact_url_spellings(&o.artifact_url)) - .collect(); - for texts in &text_sets { - let lock_fast = groups_present(texts, &lock_groups); - let pair_fast = groups_present(texts, &pair_groups); - for (i, o) in npm.iter().enumerate() { - let encoded = encode_uri_component(&o.artifact_url); - let pair = texts - .iter() - .any(|t| artifact_url_present(t, &o.artifact_url)); - let lock = texts.iter().any(|t| { - artifact_url_present(t, &o.artifact_url) || t.contains(encoded.as_str()) - }); - assert_eq!(pair_fast[i], pair, "{}", o.artifact_url); - assert_eq!(lock_fast[i], lock, "{}", o.artifact_url); - } - } - } - -} diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs index 9766f156..376526ab 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -6,93 +6,19 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use socket_patch_core::constants::npm_family::{ - BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_HIDDEN_LOCK_REL, VLT_LOCK, VLT_STORE_DIR, -}; +use socket_patch_core::constants::npm_family::{VLT_HIDDEN_LOCK_REL, VLT_LOCK}; +use socket_patch_core::hosted::vlt::{self as hosted_vlt, Preflight}; use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::redirect::vlt_heal::{ self, classify_target, read_install_state, Expected, LedgerTarget, Target, TargetState, }; -use socket_patch_core::patch::redirect::vlt_preflight::{self, OFFLINE_REASON}; -use socket_patch_core::patch::redirect::{redact_grant_token, vlt, DepOverride}; +use socket_patch_core::patch::redirect::vlt_preflight; +use socket_patch_core::patch::redirect::{vlt, DepOverride}; +use socket_patch_core::vendor::lock_inventory::ProjectView; use super::StaleInstallOutcome; pub(super) const REINSTALL_REQUIRED: &str = "redirect_vlt_reinstall_required"; -const ARTIFACT_UNVERIFIABLE: &str = "redirect_vlt_artifact_unverifiable"; - -/// Whether vlt's install state exists: the hidden lock as a regular file, -/// or the store as a real directory. Stat only; the hidden lock can be -/// megabytes and is never read into the rewriter's input. -pub(super) fn install_state_present(cwd: &Path) -> bool { - let is = |rel: &str, dir: bool| { - std::fs::symlink_metadata(cwd.join(rel)).is_ok_and(|m| { - if dir { - m.file_type().is_dir() - } else { - m.file_type().is_file() - } - }) - }; - is(VLT_HIDDEN_LOCK_REL, false) || is(VLT_STORE_DIR, true) -} - -/// What the artifact preflight decided for this run's npm candidates. -#[derive(Debug, Default)] -pub(crate) struct Preflight { - /// Failed while vlt drives, or for a vlt-vendored takeover: withheld - /// from every rewriter. - pub(crate) withheld_everywhere: BTreeMap, - /// Failed while another npm-family lock may drive: kept out of the vlt - /// rewrite only. - pub(crate) withheld_from_vlt: BTreeSet, - pub(crate) passed: BTreeSet, - /// Artifact bytes by URL, for the heal's no-record comparison. - pub(crate) artifacts: BTreeMap>, - pub(crate) warnings: Vec, -} - -/// The files `vlt_drives` and the preflight scope read: `vlt-lock.json` -/// itself, and presence-only entries for the sibling locks and vlt's -/// install state. Empty without a readable `vlt-lock.json`. -async fn vlt_inputs(cwd: &Path) -> BTreeMap { - let mut files = BTreeMap::new(); - let Ok(lock) = socket_patch_core::utils::fs::read_regular_to_string(&cwd.join(VLT_LOCK)).await - else { - return files; - }; - files.insert(VLT_LOCK.to_string(), lock); - for sibling in [NPM_LOCKS[0], NPM_LOCKS[1], "yarn.lock", PNPM_LOCK, BUN_LOCK] { - if std::fs::metadata(cwd.join(sibling)).is_ok_and(|m| m.is_file()) { - files.insert(sibling.to_string(), String::new()); - } - } - if install_state_present(cwd) { - files.insert(VLT_HIDDEN_LOCK_REL.to_string(), String::new()); - } - files -} - -fn unverifiable_detail( - url: &str, - reason: &str, - purl: &str, - already_pinned: bool, - everywhere: bool, -) -> String { - if already_pinned { - format!( - "vlt would fail to verify {url}: {reason}; {purl} was left pinned by an earlier run \ - and `vlt ci` will fail until the artifact verifies" - ) - } else if everywhere { - format!("vlt would fail to verify {url}: {reason}; nothing was written for {purl}") - } else { - format!( - "vlt would fail to verify {url}: {reason}; vlt-lock.json was not changed for {purl}" - ) - } -} /// The uuids of `deps` whose purl a vlt vendored ledger entry claims: a /// hosted takeover reverts them to a registry node before the rewrite. @@ -114,114 +40,32 @@ async fn vlt_vendored_uuids(cwd: &Path, deps: &[(&str, &DepOverride)]) -> BTreeS /// Fetch each in-scope artifact the way vlt does (once per distinct URL, /// `offline` making no request) and decide which deps may be pinned in -/// `vlt-lock.json`. Projects without `vlt-lock.json` make no request. A -/// vlt-vendored dep is probed through its vendored node, before the -/// takeover reverts it, and a failure keeps it vendored. +/// `vlt-lock.json` ([`socket_patch_core::hosted::vlt`]). Projects without +/// `vlt-lock.json` make no request. A vlt-vendored dep is probed through +/// its vendored node, before the takeover reverts it, and a failure keeps +/// it vendored. pub(super) async fn artifact_preflight( common: &crate::args::GlobalArgs, api_client: &socket_patch_core::api::client::ApiClient, deps: &[(&str, &DepOverride)], ) -> Preflight { - let files = vlt_inputs(&common.cwd).await; + let view = ProjectView::Disk(&common.cwd); + let files = hosted_vlt::inputs(&view).await; if files.is_empty() { return Preflight::default(); } - let overrides: Vec = deps.iter().map(|(_, dep)| (*dep).clone()).collect(); let vendored = vlt_vendored_uuids(&common.cwd, deps).await; - let scope = vlt_preflight::preflight_scope(&files, &overrides, &vendored); - if scope.is_empty() { + let Some(plan) = hosted_vlt::plan(&view, &files, deps, &vendored) else { return Preflight::default(); - } - let drives = vlt::vlt_drives(&files, common.cwd.join(BUN_LOCKB).exists()); - let urls: BTreeSet = scope.iter().map(|d| d.artifact_url.clone()).collect(); + }; let probes = if common.offline { BTreeMap::new() } else { - vlt_preflight::probe_artifacts(api_client, &urls).await + vlt_preflight::probe_artifacts(api_client, &plan.urls()).await }; - judge_preflight(&scope, deps, drives, probes) -} - -/// [`artifact_preflight`] for a host with no network (the in-memory hosted -/// engine): every in-scope artifact is judged as `--offline` judges it, so -/// the dep is withheld (`redirect_vlt_artifact_unverifiable`, "offline") -/// rather than pinned in a lock vlt may not be able to install. `files` -/// are the [`vlt_inputs`] entries built from the host's file set. -pub(crate) fn offline_preflight( - files: &BTreeMap, - deps: &[(&str, &DepOverride)], - bun_lockb_present: bool, -) -> Preflight { - if files.is_empty() { - return Preflight::default(); - } - let overrides: Vec = deps.iter().map(|(_, dep)| (*dep).clone()).collect(); - let scope = vlt_preflight::preflight_scope(files, &overrides, &BTreeSet::new()); - if scope.is_empty() { - return Preflight::default(); - } - let drives = vlt::vlt_drives(files, bun_lockb_present); - judge_preflight(&scope, deps, drives, BTreeMap::new()) -} - -/// The preflight's verdicts for `scope` given the `probes` fetched for it -/// (none for a URL that was not fetched: judged offline). -fn judge_preflight( - scope: &[vlt_preflight::PreflightDep], - deps: &[(&str, &DepOverride)], - drives: bool, - probes: BTreeMap, -) -> Preflight { - let mut out = Preflight::default(); - let mut passed_urls: BTreeSet<&str> = BTreeSet::new(); - for dep in scope { - let reason = match probes.get(&dep.artifact_url) { - None => Some(OFFLINE_REASON.to_string()), - Some(probe) => probe.failure(&dep.sha512), - }; - let Some(reason) = reason else { - out.passed.insert(dep.patch_uuid.clone()); - passed_urls.insert(&dep.artifact_url); - continue; - }; - let purl = deps - .iter() - .find(|(_, d)| d.patch_uuid == dep.patch_uuid) - .map_or("", |(purl, _)| *purl); - let everywhere = drives || dep.vendored; - let detail = unverifiable_detail( - &dep.artifact_url, - &reason, - purl, - dep.already_pinned, - everywhere, - ); - out.warnings.push(serde_json::json!({ - "code": ARTIFACT_UNVERIFIABLE, - "detail": redact_grant_token(&detail, &dep.artifact_url, &dep.patch_uuid), - })); - if everywhere { - out.withheld_everywhere - .insert(dep.patch_uuid.clone(), purl.to_string()); - } else { - out.withheld_from_vlt.insert(dep.patch_uuid.clone()); - } - } - // Moved, not copied: every dep has been judged, and the probes are not - // read again, so a verified body is held once. - for (url, probe) in probes { - if passed_urls.contains(url.as_str()) { - if let Some(body) = probe.body { - out.artifacts.insert(url, body); - } - } - } - out + hosted_vlt::judge(&plan, deps, probes) } -/// The skip `reason` of a dep the preflight withheld from every rewriter. -pub(crate) const WITHHELD_REASON: &str = ARTIFACT_UNVERIFIABLE; - fn patch_server_origins(common: &crate::args::GlobalArgs) -> Vec { common .patch_server_url @@ -771,7 +615,7 @@ mod tests { Some("pkg:npm/left-pad@1.3.0") ); assert_eq!( - pre.warnings[0]["detail"], + pre.warnings[0].detail, format!( "vlt would fail to verify {url}: offline; nothing was written for \ pkg:npm/left-pad@1.3.0" @@ -852,7 +696,7 @@ mod tests { assert!(claimed.withheld_from_vlt.is_empty()); assert!(claimed.withheld_everywhere.contains_key(&dep.patch_uuid)); assert_eq!( - claimed.warnings[0]["detail"], + claimed.warnings[0].detail, format!("vlt would fail to verify {url}: http 404; nothing was written for pkg:npm/left-pad@1.3.0") ); } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 8a98c85d..cf1bb5c8 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -15,7 +15,6 @@ use socket_patch_core::api::client::{ use socket_patch_core::api::types::{BatchPackagePatches, BatchSearchResponse, PatchSearchResult}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{ spawn_patch_scan_failed, spawn_patch_scanned, PendingTelemetry, @@ -50,7 +49,10 @@ use self::discovery::{ // pinned entry into the hosted engine, the vendor step + its dry-run // preview, and the PnP layout-refusal warning mapping. `pub(crate)` // re-exports because the submodules themselves stay private to scan. -pub(crate) use self::discovery::unsupported_layout_warnings; +pub(crate) use self::discovery::{ + lockfile_supplement as project_lockfile_supplement, unsupported_layout_warnings, + vendored_ledger_supplement as project_vendored_supplement, +}; use self::gc::gc_json; pub(crate) use self::hosted::boxed_run_redirect_selected; use self::hosted::run_redirect; @@ -845,27 +847,12 @@ fn overlap_from_states( redirect: Option<&socket_patch_core::patch::redirect::RedirectState>, vendor: &VendorState, ) -> Vec { - let Some(redirect) = redirect else { - return Vec::new(); - }; - if vendor.entries.is_empty() || redirect.records.is_empty() { - return Vec::new(); + socket_patch_core::ledgers::Ledgers { + manifest: None, + vendor: Some(vendor), + redirect, } - // Canonicalize both sides (drop qualifiers, percent-decode) so the - // hosted pin's purl matches the vendor entry's base purl — mirrors - // `vendored_ledger_supplement`. - let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); - let mut vendor_purls: std::collections::BTreeSet = std::collections::BTreeSet::new(); - for (key, entry) in &vendor.entries { - vendor_purls.insert(canon(key)); - vendor_purls.insert(canon(&entry.base_purl)); - } - let redirect_purls: std::collections::BTreeSet = - redirect.records.keys().map(|p| canon(p)).collect(); - redirect_purls - .intersection(&vendor_purls) - .cloned() - .collect() + .hosted_vendored_overlap() } /// The overlapping PURLs split by which mode the LIVE lockfile actually wires @@ -1379,6 +1366,10 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // `--vex` side-effect reads the manifest at several terminal returns, // including the early "no packages" exit before the GC block. let manifest_path = args.common.resolved_manifest_path(); + // The stores, lock set and wiring discovery this run reads before it + // writes anything, each loaded at most once (see `ProjectContext`). + let ctx = + crate::commands::context::ProjectContext::rooted(&args.common, args.common.cwd.clone()); let socket_dir = args.common.socket_dir(); let overrides = args.common.api_client_overrides(); @@ -1427,7 +1418,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // that have NO installed copy (fresh clone, partial install). They join // discovery and are flagged "not yet installed". Scoped to the crawled // ecosystems. - let lockfile_only = lockfile_supplement(&args.common, &all_crawled, crawl_scope).await; + let lockfile_only = lockfile_supplement(&ctx, &all_crawled, crawl_scope).await; // Unsupported layouts and malformed binary Bun locks, kept on empty // scans too: an unreadable graph is not evidence of no dependencies. let mut layout_refusals = unsupported_layout_warnings(&lockfile_only.unsupported); @@ -1458,9 +1449,9 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // and update detection. Failure policies differ on purpose: the // supplement falls back to the committed artifacts (fail-closed for the // prune), the key set degrades to empty (fail-open). - let vendor_state = socket_patch_core::vendor::load_state(&args.common.cwd).await; + let vendor_state = &ctx.loaded().await.vendor; let ledger_supplement = - vendored_ledger_supplement(&args.common, &all_crawled, &vendor_state).await; + vendored_ledger_supplement(&args.common, &all_crawled, vendor_state).await; for pkg in &ledger_supplement { if let Some(eco) = Ecosystem::from_purl(&pkg.purl) { *eco_counts.entry(eco).or_insert(0) += 1; @@ -1620,11 +1611,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // (same rule as the ≥1-package path). `wiringLive` is empty // by construction: this run covered zero packages. let redirect_state = (!args.common.is_global()).then_some( - crate::commands::hosted_state_from_lockfiles( - &args.common, - &args.common.cwd, - ) - .await, + crate::commands::hosted_state_from_pins( + &socket_patch_core::patch::redirect::upstream::HostedPin::all( + ctx.discovery().await, + ), + ), ); if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) { result["redirectState"] = state; @@ -1882,7 +1873,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { ); // Read existing manifest once for update detection. - let existing_manifest = read_manifest(&manifest_path).await.ok().flatten(); + let existing_manifest = ctx.ledgers().await.manifest; // Hosted mode records its patches ONLY in the lockfiles (v5 keeps no // hosted ledger) and vendored mode ONLY in its ledger, so the hosted // pins and the vendor ledger's purl→uuid records are folded into update @@ -1891,18 +1882,17 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if args.common.is_global() { Vec::new() } else { - socket_patch_core::patch::redirect::upstream::HostedPin::all( - &crate::commands::discover_wiring(&args.common, &args.common.cwd).await, - ) + socket_patch_core::patch::redirect::upstream::HostedPin::all(ctx.discovery().await) }; - let redirect_state = (!args.common.is_global()) + let hosted_state = (!args.common.is_global()) .then(|| crate::commands::hosted_state_from_pins(&hosted_pin_list)); + let redirect_state = hosted_state.as_ref(); let hosted_pins: Vec<(String, String)> = hosted_pin_list .iter() .map(|pin| (pin.purl.clone(), pin.uuid.clone())) .collect(); let update_manifest = merge_ledger_records_for_updates( - existing_manifest.as_ref(), + existing_manifest, vendor_state.as_ref().ok(), &hosted_pins, ); @@ -1980,10 +1970,10 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { let hosted_retained = if vendor { Vec::new() } else { - hosted_wiring_retained_purls(&args.common, redirect_state.as_ref(), &all_purls).await + hosted_wiring_retained_purls(&args.common, redirect_state, &all_purls).await }; if !vendor { - if let Some(state) = redirect_state_json(redirect_state.as_ref(), &hosted_retained) { + if let Some(state) = redirect_state_json(redirect_state, &hosted_retained) { result["redirectState"] = state; } } @@ -2024,7 +2014,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { if dry { // Synthesize the per-patch outcome without touching disk. let empty_manifest = PatchManifest::new(); - let manifest_for_preview = existing_manifest.as_ref().unwrap_or(&empty_manifest); + let manifest_for_preview = existing_manifest.unwrap_or(&empty_manifest); let mut patches: Vec = selected .iter() .map(|p| { @@ -2622,7 +2612,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // vendored-ownership counterpart is the `[skip]` lines above.) if !vendor && !silent { let hosted_retained = - hosted_wiring_retained_purls(&args.common, redirect_state.as_ref(), &all_purls).await; + hosted_wiring_retained_purls(&args.common, redirect_state, &all_purls).await; if !hosted_retained.is_empty() { eprintln!( "Warning: {}", diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index e5501673..7eed23e6 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -20,7 +20,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use clap::Args; -use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ @@ -959,7 +958,13 @@ async fn generate_vex_from_manifest_path_inner( calm_when_nothing: bool, warnings: &mut Vec, ) -> Result { - let manifest_file = match read_manifest(manifest_path).await { + // One load of the three stores; each keeps vex's strict posture below. + let socket_patch_core::ledgers::LoadedLedgers { + manifest, + vendor, + redirect, + } = socket_patch_core::ledgers::LoadedLedgers::load(&common.cwd, manifest_path).await; + let manifest_file = match manifest { Ok(m) => m, Err(e) => { // Core's text ("Failed to parse manifest JSON: ...") does not @@ -976,8 +981,7 @@ async fn generate_vex_from_manifest_path_inner( // is read (never written) only as an extra local record source for the // pins it still describes, so a malformed one is an advisory: its // records are simply not consulted. - let redirect = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await - { + let redirect = match redirect { Ok(state) => state, Err(corrupt) => { note_warning( @@ -995,7 +999,7 @@ async fn generate_vex_from_manifest_path_inner( None } }; - let vendor = match socket_patch_core::vendor::load_state(&common.cwd).await { + let vendor = match vendor { Ok(state) => state, Err(e) => { let message = format!( diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 4adec636..7904fe33 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -79,7 +79,7 @@ use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::patch::redirect::RedirectState; use socket_patch_core::utils::concurrent::{api_concurrency, ordered_concurrent}; use socket_patch_core::utils::purl::strip_purl_qualifiers; -use socket_patch_core::vendor::state::{lookup_entry_kv, VendorArtifact, VendorEntry, VendorState}; +use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState}; use socket_patch_core::vex::discover::{ canonical_base_purl, vendor_ref, Discovery, LedgerLiveness, PatchedRef, WiringMode, }; @@ -281,7 +281,7 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S let mut notes = Vec::new(); let mut gated: Vec = Vec::new(); - let mut cands = build_candidates(&manifest, &vendor, &redirect_records); + let mut cands = build_candidates(&manifest, &vendor, redirect.as_ref()); let conflicts = wiring_conflicts(&discovery); if !conflicts.is_empty() { // Gate every candidate for a conflicting package BEFORE anything can @@ -550,76 +550,35 @@ fn expected_package(cand: &Cand) -> String { } } -/// One candidate per manifest key, then per unclaimed vendor-ledger key, -/// then per unclaimed redirect-ledger key — the collision rule -/// (the manifest owns a key it records; ledgers fill the rest), in sorted -/// order so the output is deterministic. +/// One candidate per owner key under the shared owner rule +/// ([`socket_patch_core::ledgers::Ledgers::owned`]): the manifest keys, then +/// the unclaimed vendor-ledger keys, then the redirect-ledger keys neither +/// owns, each sorted, with the losing copies' records as `alts`. fn build_candidates( manifest: &PatchManifest, vendor: &VendorState, - redirect_records: &BTreeMap, + redirect: Option<&RedirectState>, ) -> Vec { - let mut cands = Vec::new(); - let mut claimed_entries: HashSet<&str> = HashSet::new(); - let mut manifest_keys: Vec<&String> = manifest.patches.keys().collect(); - manifest_keys.sort(); - for key in manifest_keys { - let record = &manifest.patches[key]; - let entry = lookup_entry_kv(&vendor.entries, key); - let mut alts = Vec::new(); - if let Some((entry_key, e)) = entry { - claimed_entries.insert(entry_key.as_str()); - alts.extend(e.record.clone()); - } - alts.extend(redirect_records.get(key).cloned()); - cands.push(Cand { - key: key.clone(), - uuid: record.uuid.clone(), - record: Some(record.clone()), - alts, - manifest_owned: true, - redirected: redirect_records.contains_key(key), - vendor_entry: entry.map(|(_, e)| e.clone()), - discovered: Vec::new(), - lockfile_only: false, - }); - } - let mut vendor_keys: Vec<&String> = vendor.entries.keys().collect(); - vendor_keys.sort(); - for key in vendor_keys { - if claimed_entries.contains(key.as_str()) || manifest.patches.contains_key(key) { - continue; - } - let entry = &vendor.entries[key]; - cands.push(Cand { - key: key.clone(), - uuid: entry.uuid.clone(), - record: entry.record.clone(), - alts: redirect_records.get(key).cloned().into_iter().collect(), - manifest_owned: false, - redirected: redirect_records.contains_key(key), - vendor_entry: Some(entry.clone()), - discovered: Vec::new(), - lockfile_only: false, - }); - } - for (purl, record) in redirect_records { - if cands.iter().any(|c| c.key == *purl) { - continue; - } - cands.push(Cand { - key: purl.clone(), - uuid: record.uuid.clone(), - record: Some(record.clone()), - alts: Vec::new(), - manifest_owned: false, - redirected: true, - vendor_entry: None, - discovered: Vec::new(), - lockfile_only: false, - }); + use socket_patch_core::ledgers::{Ledgers, Store}; + Ledgers { + manifest: Some(manifest), + vendor: Some(vendor), + redirect, } - cands + .owned() + .into_iter() + .map(|o| Cand { + key: o.key.to_string(), + uuid: o.uuid.to_string(), + record: o.record.cloned(), + alts: o.alts.into_iter().cloned().collect(), + manifest_owned: o.store == Store::Manifest, + redirected: o.hosted, + vendor_entry: o.vendor.map(|(_, e)| e.clone()), + discovered: Vec::new(), + lockfile_only: false, + }) + .collect() } /// Packages the discovered references wire to MORE than one patch uuid diff --git a/crates/socket-patch-cli/src/hosted_memory/redirect.rs b/crates/socket-patch-cli/src/hosted_memory/redirect.rs deleted file mode 100644 index 99bd8e56..00000000 --- a/crates/socket-patch-cli/src/hosted_memory/redirect.rs +++ /dev/null @@ -1,1234 +0,0 @@ -//! One project root's hosted redirect over an in-memory file set: the -//! disk flow's `run_redirect_selected` stages as pure functions -//! (reference → `DepOverride` candidates, candidate-file reads, the -//! rewrite, the pnpm `trustLockfile` and npm `allow-remote` auto-configs, -//! per-ecosystem confirmation, the symlink guard). Everything that needs -//! the host machine (the apply lock, vendored takeover reverts, stale -//! install probes, VEX, telemetry, subprocesses) is left out; a vendored -//! takeover is refused instead of performed. - -use std::collections::{BTreeMap, BTreeSet, HashMap}; - -use socket_patch_core::api::types::PackageVendorResult; -use socket_patch_core::constants::npm_family::{ - BUN_LOCK, NPM_LOCKS as NPM_LOCK_NAMES, PNPM_LOCK, RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, - VLT_HIDDEN_LOCK_REL, VLT_LOCK, VLT_STORE_DIR, -}; -use socket_patch_core::patch::redirect::npmrc::{ - plan_npmrc_allow_remote_with, NpmrcPlan, OuterAllowRemote, NPMRC_ALLOW_REMOTE_EDIT_KIND, - NPMRC_REL, -}; -use socket_patch_core::patch::redirect::{ - rewrite_registry_redirect_withholding_vlt, DepOverride, FileEdit, RewriteResult, - RewriteWarning, -}; -use socket_patch_core::utils::purl::{purl_parts, strip_purl_qualifiers}; -use socket_patch_core::vendor::lock_inventory::{MemoryEntry, MemoryProject}; -use socket_patch_core::vendor::VendorState; - -use super::select::{RUSH_REPO_STATE_REL, VENDOR_STATE_REL}; -use super::types::{ProjectError, SkippedPatch}; -use crate::commands::scan::hosted::{ - npm_allow_remote_already_detail, npm_allow_remote_configured_detail, - npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, - npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, - npm_allow_remote_user_set_detail, plan_workspace_trust, pnpm_heal_root, - pnpm_lock_may_need_store_flag, pnpm_lock_version_major, pnpm_trust_configured_detail, - pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_policy_preamble, - pnpm_trust_workspace_unreadable_detail, url_host, TrustPlan, NPM_LOCKS, - PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, REDIRECT_CANDIDATE_FILES, - REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, -}; - -/// Skip reason / warning code for a candidate the disk flow would migrate -/// from vendored to hosted (the migration reverts committed wiring, which -/// the in-memory engine does not do). -pub(crate) const VENDORED_TAKEOVER_UNSUPPORTED: &str = "vendored_takeover_unsupported_in_memory"; - -/// The disk flow's symlink refusal code. -pub(crate) const SYMLINK_REFUSAL: &str = "redirect_symlinked_file_unsupported"; - -/// A candidate file exists but its content was not provided (oversize, an -/// LFS pointer, presence-only); disk would read and rewrite it. -pub(crate) const UNREADABLE_REFUSAL: &str = "candidate_file_unreadable"; - -/// One granted reference: the purl it was granted for plus its override. -#[derive(Debug, Clone)] -pub(crate) struct Candidate { - pub(crate) purl: String, - pub(crate) dep: DepOverride, -} - -/// The engine-level options the per-root stages read. -#[derive(Debug, Clone, Copy)] -pub(crate) struct StageOptions { - pub(crate) dry_run: bool, - pub(crate) pipenv_major: Option, - pub(crate) trust_lockfile_config: bool, - pub(crate) npm_allow_remote_config: bool, -} - -/// Reference grants → candidates (disk: the loop over `selected` after -/// `fetch_registry_references`). -pub(crate) fn build_candidates( - selected: &[(String, String)], - references: &HashMap, - skipped: &mut Vec, -) -> Vec { - let skip = |purl: &str, uuid: &str, reason: &str| SkippedPatch { - purl: purl.to_string(), - uuid: uuid.to_string(), - reason: reason.to_string(), - detail: None, - }; - let mut candidates = Vec::new(); - for (sel_purl, sel_uuid) in selected { - let Some(reference) = references.get(sel_uuid) else { - skipped.push(skip(sel_purl, sel_uuid, "not_found")); - continue; - }; - if reference.status != "granted" && reference.status != "reused" { - skipped.push(skip(sel_purl, sel_uuid, &reference.status)); - continue; - } - let purl = reference.purl.as_deref().unwrap_or(sel_purl); - let Some((ecosystem, name, version)) = purl_parts(purl) else { - skipped.push(skip(purl, sel_uuid, "bad_purl")); - continue; - }; - let Some(url) = reference.url.clone() else { - skipped.push(skip(purl, sel_uuid, "no_url")); - continue; - }; - let mut integrity = reference - .artifacts - .iter() - .flatten() - .find(|a| a.kind == "tarball") - .map(|a| a.integrity.clone()) - .unwrap_or_default(); - let berry_zip = reference - .artifacts - .iter() - .flatten() - .find(|a| a.kind == "yarn-berry-zip"); - if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { - integrity.yarn_berry10c0 = Some(c); - } - if let Some(ov) = reference - .registry_override - .as_ref() - .filter(|o| o.kind == "goproxy") - { - if let (Some(zip_h1), Some(gomod_h1)) = ( - ov.identifiers.go_zip_dirhash_h1.clone(), - ov.identifiers.go_mod_h1.clone(), - ) { - integrity.dirhash_h1 = Some(zip_h1); - integrity.go_mod_h1 = Some(gomod_h1); - } - } - let token = reference - .registry_override - .as_ref() - .and_then(|o| { - socket_patch_core::patch::redirect::grant_token_path_segment(&o.index_url, sel_uuid) - }) - .or_else(|| { - socket_patch_core::patch::redirect::grant_token_path_segment(&url, sel_uuid) - }) - .unwrap_or_default(); - candidates.push(Candidate { - purl: purl.to_string(), - dep: DepOverride { - ecosystem, - name, - namespace: None, - version, - token, - patch_uuid: sel_uuid.clone(), - artifact_url: url, - berry_zip_url: berry_zip.and_then(|a| a.url.clone()), - registry_override: reference.registry_override.clone(), - integrity, - }, - }); - } - 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); - (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 -/// fetch. -#[derive(Debug)] -pub(crate) struct Planned { - pub(crate) project: MemoryProject, - pub(crate) candidates: Vec, - pub(crate) skipped: Vec, - pub(crate) pre_warnings: Vec, - pub(crate) files: BTreeMap, - pub(crate) rush_lock_keys: Vec, - pub(crate) bun_lock_present: bool, - /// Candidate files the disk flow reads through a symbolic link: their - /// bytes are unknown here, so a project whose candidates could rewrite - /// one is refused like the disk symlink guard refuses the write. - pub(crate) symlinked_reads: Vec, - /// Candidate files that exist without content: a project whose - /// candidates could rewrite (or whose rewrite depends on) one is - /// refused, since the rewriters would treat it as absent. - pub(crate) unreadable_reads: Vec, - /// `(artifact url, sha256)` of every pypi wheel whose metadata a - /// native lock rewrite needs. - pub(crate) wheels: Vec<(String, String)>, - /// The vlt artifact preflight, judged offline (see - /// [`crate::commands::scan::hosted::vlt::offline_preflight`]). - pub(crate) vlt_preflight: crate::commands::scan::hosted::vlt::Preflight, -} - -/// A refused project: its error and whatever was skipped before it. -#[derive(Debug)] -pub(crate) struct Refused { - pub(crate) error: ProjectError, -} - -fn refusal(code: &str, message: String) -> Refused { - Refused { - error: ProjectError { - code: code.to_string(), - message, - }, - } -} - -fn unreadable_refusal(rel: &str) -> Refused { - refusal( - UNREADABLE_REFUSAL, - format!( - "{rel} exists but its content was not provided (too large, an LFS pointer, or \ - not fetched), so it cannot be rewritten alongside the other lockfiles; nothing \ - was written" - ), - ) -} - -fn symlink_refusal(linked: &str) -> Refused { - refusal( - SYMLINK_REFUSAL, - format!( - "{linked} is a symbolic link; socket-patch rewrites files in place with an atomic \ - rename, which would replace the link — replace the link with a regular file (or \ - run socket-patch in the directory it points to) and re-run; nothing was written" - ), - ) -} - -/// The vendored ledger's entries (the disk `vendor::load_state` parse, -/// including its legacy `{mode}`-only shape); `None` when absent or -/// unreadable. -fn vendored_entries(project: &MemoryProject) -> Option { - let bytes: Vec = match project.get(VENDOR_STATE_REL)? { - MemoryEntry::Text(text) => text.as_bytes().to_vec(), - MemoryEntry::Binary(bytes) => bytes.to_vec(), - _ => return None, - }; - match serde_json::from_slice::(&bytes) { - Ok(state) => Some(state), - Err(_) => { - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - (value.get("mode").is_some() && value.get("entries").is_none()).then(VendorState::new) - } - } -} - -/// Whether Socket-owned vendored `[patch.crates-io]` wiring for exactly -/// `name@version` is committed in the root manifest or (legacy) the -/// project's cargo config — the disk `socket_wiring_present` probe over -/// the in-memory files. The config cargo reads is `.cargo/config` when it -/// exists, else `.cargo/config.toml`. -fn cargo_vendored_wiring(files: &MemoryProject, name: &str, version: &str) -> bool { - use socket_patch_core::vendor::cargo_manifest::{ - crates_io_patch_entries, entry_wires, parse_manifest, - }; - let manifest_wired = files - .text("Cargo.toml") - .and_then(|text| parse_manifest(text).ok()) - .is_some_and(|doc| { - crates_io_patch_entries(&doc) - .iter() - .any(|e| entry_wires(e, name, version)) - }); - let config_rel = if files.contains(".cargo/config") { - ".cargo/config" - } else { - ".cargo/config.toml" - }; - let config_wired = files - .text(config_rel) - .and_then(|text| parse_manifest(text).ok()) - .is_some_and(|doc| { - crates_io_patch_entries(&doc) - .iter() - .any(|e| e.source == "crates-io" && entry_wires(e, name, version)) - }); - manifest_wired || config_wired -} - -/// Whether vlt's install state is in the file set: the hidden lock as a -/// file, or the store as a directory (the disk `install_state_present`). -fn vlt_install_state_present(project: &MemoryProject) -> bool { - matches!( - project.get(VLT_HIDDEN_LOCK_REL), - Some(MemoryEntry::Text(_) | MemoryEntry::Binary(_) | MemoryEntry::Present) - ) || project.is_dir(VLT_STORE_DIR) -} - -/// The disk `vlt_inputs` over the file set: `vlt-lock.json` itself, and -/// presence-only entries for the sibling locks and vlt's install state. -/// Empty without a readable `vlt-lock.json`. -fn vlt_inputs(project: &MemoryProject) -> BTreeMap { - let mut files = BTreeMap::new(); - let lock = match project.get(VLT_LOCK) { - Some(MemoryEntry::Text(text)) => text.to_string(), - Some(MemoryEntry::Binary(bytes)) => match std::str::from_utf8(bytes) { - Ok(text) => text.to_string(), - Err(_) => return files, - }, - _ => return files, - }; - files.insert(VLT_LOCK.to_string(), lock); - for sibling in [ - NPM_LOCK_NAMES[0], - NPM_LOCK_NAMES[1], - "yarn.lock", - PNPM_LOCK, - BUN_LOCK, - ] { - if matches!( - project.get(sibling), - Some(MemoryEntry::Text(_) | MemoryEntry::Binary(_) | MemoryEntry::Present) - ) { - files.insert(sibling.to_string(), String::new()); - } - } - if vlt_install_state_present(project) { - files.insert(VLT_HIDDEN_LOCK_REL.to_string(), String::new()); - } - files -} - -/// Everything up to the wheel-metadata fetch. -pub(crate) fn plan( - project: MemoryProject, - unreadable: BTreeSet, - selected: &[(String, String)], - references: &HashMap, -) -> Result { - let mut skipped: Vec = Vec::new(); - let mut candidates = if selected.is_empty() { - Vec::new() - } else { - build_candidates(selected, references, &mut skipped) - }; - - // The disk flow fetches each in-scope artifact the way vlt does before - // anything else; with no network here every one is judged offline, so - // the dep is withheld instead of pinned (`--offline` parity). - let vlt_preflight = { - let deps: Vec<(&str, &DepOverride)> = candidates - .iter() - .filter(|c| c.dep.ecosystem == "npm") - .map(|c| (c.purl.as_str(), &c.dep)) - .collect(); - crate::commands::scan::hosted::vlt::offline_preflight( - &vlt_inputs(&project), - &deps, - project.contains("bun.lockb"), - ) - }; - if !vlt_preflight.withheld_everywhere.is_empty() { - for (uuid, purl) in &vlt_preflight.withheld_everywhere { - skipped.push(SkippedPatch { - purl: purl.clone(), - uuid: uuid.clone(), - reason: crate::commands::scan::hosted::vlt::WITHHELD_REASON.to_string(), - detail: None, - }); - } - candidates.retain(|c| { - !vlt_preflight - .withheld_everywhere - .contains_key(&c.dep.patch_uuid) - }); - } - - let bun_lock_present = project.contains("bun.lock"); - if candidates.iter().any(|c| c.dep.ecosystem == "npm") - && !bun_lock_present - && project.is_symlink("bun.lockb") - { - return Err(refusal( - SYMLINK_REFUSAL, - "bun.lockb is a symbolic link; replace it with a regular file (or run \ - socket-patch in the directory it points to) before patching; nothing was written" - .to_string(), - )); - } - - let mut pre_warnings: Vec = vlt_preflight.warnings.clone(); - let takeover_capable = |p: &str| { - p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") - }; - if candidates.iter().any(|c| takeover_capable(&c.purl)) { - let vendored = vendored_entries(&project); - let mut refused: BTreeSet = BTreeSet::new(); - for candidate in candidates.iter().filter(|c| takeover_capable(&c.purl)) { - let has_entry = vendored.as_ref().is_some_and(|s| { - socket_patch_core::vendor::lookup_entry( - &s.entries, - strip_purl_qualifiers(&candidate.purl), - ) - .is_some() - }); - let cargo_wired = !has_entry - && candidate.purl.starts_with("pkg:cargo/") - && cargo_vendored_wiring(&project, &candidate.dep.name, &candidate.dep.version); - if has_entry || cargo_wired { - refused.insert(candidate.purl.clone()); - } - } - if !refused.is_empty() { - pre_warnings.push(serde_json::json!({ - "code": VENDORED_TAKEOVER_UNSUPPORTED, - "detail": format!( - "{} currently vendored ({}); migrating a vendored package to hosted \ - reverts its committed vendored wiring, which the in-memory hosted scan \ - does not do — run `socket-patch scan --mode hosted` in a checkout to \ - migrate, then re-run", - if refused.len() == 1 { "1 package is" } else { "packages are" }, - refused.iter().cloned().collect::>().join(", ") - ), - })); - for c in candidates.iter().filter(|c| refused.contains(&c.purl)) { - skipped.push(SkippedPatch { - purl: c.purl.clone(), - uuid: c.dep.patch_uuid.clone(), - reason: VENDORED_TAKEOVER_UNSUPPORTED.to_string(), - detail: None, - }); - } - candidates.retain(|c| !refused.contains(&c.purl)); - } - } - - let mut files: BTreeMap = BTreeMap::new(); - let mut rush_lock_keys: Vec = Vec::new(); - let mut symlinked_reads: Vec = Vec::new(); - let mut unreadable_reads: Vec = Vec::new(); - if !candidates.is_empty() { - let mut read = |rel: &str, files: &mut BTreeMap| -> bool { - if project.is_symlink(rel) { - symlinked_reads.push(rel.to_string()); - return false; - } - if unreadable.contains(rel) { - unreadable_reads.push(rel.to_string()); - return false; - } - // Disk reads any UTF-8 regular file; a non-UTF-8 one is absent - // to it as well. - let text = match project.get(rel) { - Some(MemoryEntry::Text(text)) => Some(text.to_string()), - Some(MemoryEntry::Binary(bytes)) => { - std::str::from_utf8(bytes).ok().map(str::to_string) - } - _ => None, - }; - match text { - Some(text) => { - files.insert(rel.to_string(), text); - true - } - None => false, - } - }; - for name in REDIRECT_CANDIDATE_FILES.iter() { - if *name == "bun.lockb" { - continue; - } - // The hidden lock is only the install-state sentinel, never read. - if *name == VLT_HIDDEN_LOCK_REL { - if vlt_install_state_present(&project) { - files.insert((*name).to_string(), String::new()); - } - continue; - } - read(name, &mut files); - } - if files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") - { - let view = socket_patch_core::vendor::lock_inventory::ProjectView::Memory(&project); - for rel in socket_patch_core::utils::cargo_workspace::member_manifests_in(&view) { - read(&rel, &mut files); - } - } - let python_locks: Vec = project - .children("") - .into_iter() - .filter(|(name, is_dir)| { - !is_dir && socket_patch_core::utils::python_lock::is_python_lock_name(name) - }) - .map(|(name, _)| name) - .collect(); - for path in python_locks { - if let Some(script) = socket_patch_core::utils::python_lock::script_of_lock(&path) { - read(script, &mut files); - } - read(&path, &mut files); - } - if project.contains("rush.json") { - if read(RUSH_COMMON_LOCK_REL, &mut files) { - rush_lock_keys.push(RUSH_COMMON_LOCK_REL.to_string()); - } - for (name, is_dir) in project.children(RUSH_SUBSPACES_DIR) { - if !is_dir { - continue; - } - let key = format!("{RUSH_SUBSPACES_DIR}/{name}/pnpm-lock.yaml"); - if read(&key, &mut files) { - rush_lock_keys.push(key); - } - } - } - } - symlinked_reads.sort(); - symlinked_reads.dedup(); - unreadable_reads.sort(); - unreadable_reads.dedup(); - - let mut wheels: Vec<(String, String)> = Vec::new(); - for dep in candidates - .iter() - .map(|c| &c.dep) - .filter(|dep| dep.ecosystem == "pypi") - { - let Some(sha256) = dep.integrity.sha256.as_deref() else { - continue; - }; - if !dep - .artifact_url - .split(['?', '#']) - .next() - .is_some_and(|path| path.ends_with(".whl")) - { - continue; - } - let native_target = files - .iter() - .filter(|(path, _)| { - *path == "uv.lock" - || socket_patch_core::utils::python_lock::is_script_lock_name(path) - }) - .any(|(_, text)| { - socket_patch_core::utils::python_lock::rewrite_python_lock( - text, - &dep.name, - &dep.version, - socket_patch_core::utils::python_lock::ArtifactSource::Url(&dep.artifact_url), - sha256, - ) - .ok() - .flatten() - .is_some() - }); - if native_target { - wheels.push((dep.artifact_url.clone(), sha256.to_string())); - } - } - - Ok(Planned { - project, - candidates, - skipped, - pre_warnings, - files, - rush_lock_keys, - bun_lock_present, - symlinked_reads, - unreadable_reads, - wheels, - vlt_preflight, - }) -} - -/// A project's rewrite, ready for the record fetch and the ledger merge. -#[derive(Debug)] -pub(crate) struct Rewritten { - pub(crate) planned: Planned, - pub(crate) rewrite: RewriteResult, - pub(crate) rewritten: Vec, - pub(crate) confirmed: Vec<(String, String)>, - pub(crate) rush_warnings: Vec, - pub(crate) pnpm_warnings: Vec, - pub(crate) npm_warnings: Vec, -} - -/// The `.npmrc` read the allow-remote planner classifies (disk: -/// `read_npmrc_for_allow_remote`). -fn read_npmrc(project: &MemoryProject) -> Result, String> { - match project.get(NPMRC_REL) { - None => Ok(None), - Some(MemoryEntry::Symlink) => { - Err("is a symbolic link (socket-patch never writes through one)".into()) - } - Some(MemoryEntry::Text(text)) => Ok(Some(text.to_string())), - Some(MemoryEntry::Binary(_)) => { - Err("could not be read (stream did not contain valid UTF-8)".into()) - } - Some(MemoryEntry::Present) => { - Err("could not be read (file content was not provided)".into()) - } - } -} - -/// Wheel metadata → rewrite → install-policy configs → confirmation → -/// symlink guard. -pub(crate) fn rewrite( - mut planned: Planned, - wheel_metadata: &BTreeMap, String>>, - options: StageOptions, -) -> Result { - let project = &planned.project; - let files = &planned.files; - - let mut python_metadata: BTreeMap = BTreeMap::new(); - let mut unavailable: BTreeSet = BTreeSet::new(); - for (url, _) in &planned.wheels { - match wheel_metadata.get(url) { - Some(Ok(Some(metadata))) => { - python_metadata.insert(url.clone(), metadata.clone()); - } - Some(Ok(None)) => {} - Some(Err(detail)) => { - if unavailable.insert(url.clone()) { - for dep in planned - .candidates - .iter() - .map(|c| &c.dep) - .filter(|d| &d.artifact_url == url) - { - planned.skipped.push(SkippedPatch { - purl: format!("pkg:pypi/{}@{}", dep.name, dep.version), - uuid: dep.patch_uuid.clone(), - reason: "python_metadata_unavailable".to_string(), - detail: Some(detail.replace(&dep.artifact_url, "")), - }); - } - } - } - None => { - unavailable.insert(url.clone()); - } - } - } - planned - .candidates - .retain(|c| !unavailable.contains(&c.dep.artifact_url)); - let candidates = &planned.candidates; - let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); - - let targets_pipenv_lock = - socket_patch_core::patch::redirect::pipenv_lock_targets(files, &overrides); - let pipenv_major = if targets_pipenv_lock { - options.pipenv_major - } else { - None - }; - let binary_bun = !planned.bun_lock_present && project.contains("bun.lockb"); - let binary_content: Option, RewriteWarning>> = - if binary_bun && overrides.iter().any(|o| o.ecosystem == "npm") { - let read = match project.get("bun.lockb") { - Some(MemoryEntry::Binary(bytes)) => Ok(bytes.to_vec()), - Some(MemoryEntry::Text(text)) => Ok(text.as_bytes().to_vec()), - _ => Err("file content was not provided".to_string()), - }; - Some( - read.map_err(|e| RewriteWarning { - code: "redirect_bun_lockb_invalid".into(), - detail: format!("cannot read bun.lockb: {e}"), - }) - .and_then(|bytes| { - socket_patch_core::patch::redirect::preflight_bun_binary(&bytes)?; - Ok(bytes) - }), - ) - } else { - None - }; - let rewrite_overrides: Vec = overrides - .iter() - .filter(|o| !(binary_content.as_ref().is_some_and(Result::is_err) && o.ecosystem == "npm")) - .cloned() - .collect(); - let mut rewrite = rewrite_registry_redirect_withholding_vlt( - files, - &rewrite_overrides, - &python_metadata, - pipenv_major, - project.contains("bun.lockb"), - &planned.vlt_preflight.withheld_from_vlt, - ); - if let Some(content) = binary_content { - rewrite - .warnings - .retain(|w| w.code != "redirect_npm_no_lockfile"); - match content { - Ok(bytes) => socket_patch_core::patch::redirect::rewrite_bun_binary( - &bytes, - &overrides, - &mut rewrite, - ), - Err(warning) => rewrite.warnings.push(warning), - } - } - - if targets_pipenv_lock && pipenv_major.is_none() && rewrite.files.contains_key("Pipfile.lock") { - rewrite.warnings.push(RewriteWarning { - code: "redirect_pipenv_installer_unknown".into(), - detail: "The scan did not set `pipenvMajor`, so the Pipfile.lock references use the modern `file` form (Pipenv 2018 and later). A project installed with Pipenv 7–11 needs `path` references instead: re-run the scan with `pipenvMajor` set to that Pipenv major version.".into(), - }); - } - - let mut rush_warnings: Vec = Vec::new(); - if planned - .rush_lock_keys - .iter() - .any(|key| rewrite.files.contains_key(key)) - && (project.contains(RUSH_REPO_STATE_REL)) - { - rush_warnings.push(serde_json::json!({ - "code": "redirect_rush_repo_state_stale", - "detail": - "pnpm-lock.yaml was edited outside `rush update`; if \ - preventManualShrinkwrapChanges is enabled, `rush install` fails until \ - `rush update` refreshes repo-state.json (the hosted wiring survives `rush \ - update`)", - })); - } - - let mut pnpm_warnings: Vec = Vec::new(); - let mut trust_config_write: Option<(String, FileEdit)> = None; - let mut workspace_symlink_write = false; - { - let mut pnpm_lock_texts: Vec<&String> = rewrite - .files - .iter() - .filter(|(key, _)| { - std::path::Path::new(key) - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|name| matches!(name, "pnpm-lock.yaml" | "shrinkwrap.yaml")) - }) - .map(|(_, content)| content) - .collect(); - let heal_root: Option<&String> = pnpm_heal_root( - rewrite.files.contains_key("pnpm-lock.yaml"), - files.get("pnpm-lock.yaml"), - &overrides, - ); - if let Some(text) = heal_root { - pnpm_lock_texts.push(text); - } - if !pnpm_lock_texts.is_empty() { - let mut hosts: Vec<&str> = overrides - .iter() - .filter(|o| o.ecosystem == "npm") - .filter(|o| { - let encoded = - socket_patch_core::utils::uri::encode_uri_component(&o.artifact_url); - pnpm_lock_texts.iter().any(|text| { - socket_patch_core::patch::redirect::artifact_url_present( - text, - &o.artifact_url, - ) || text.contains(encoded.as_str()) - }) - }) - .filter_map(|o| url_host(&o.artifact_url)) - .collect(); - hosts.sort_unstable(); - hosts.dedup(); - let server = if hosts.is_empty() { - "the hosted patch server".to_string() - } else { - format!("the hosted patch server ({})", hosts.join(", ")) - }; - let root_lock_v9 = heal_root - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9) - || rewrite - .files - .get("pnpm-lock.yaml") - .and_then(|text| pnpm_lock_version_major(text)) - .is_some_and(|major| major >= 9); - let all_locks_legacy = pnpm_lock_texts.iter().all(|text| { - pnpm_lock_version_major(text).is_some_and(|major| major < 9) - || text - .lines() - .any(|line| line.starts_with("shrinkwrapVersion:")) - }); - let workspace: Result, std::io::Error> = - match project.get(PNPM_WORKSPACE_REL) { - None => Ok(None), - Some(MemoryEntry::Text(text)) => Ok(Some(text.to_string())), - Some(MemoryEntry::Symlink) => { - workspace_symlink_write = true; - Ok(None) - } - Some(MemoryEntry::Binary(_)) => Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "stream did not contain valid UTF-8", - )), - Some(MemoryEntry::Present) => Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "file content was not provided", - )), - }; - let detail = if all_locks_legacy { - workspace_symlink_write = false; - pnpm_trust_legacy_detail(&server) - } else if !root_lock_v9 || !options.trust_lockfile_config { - workspace_symlink_write = false; - pnpm_trust_manual_guidance(&server) - } else { - match workspace { - Err(e) => pnpm_trust_workspace_unreadable_detail(&server, &e), - Ok(ws_existing) => match plan_workspace_trust(ws_existing.as_deref()) { - TrustPlan::Create(text) => { - trust_config_write = Some(( - text, - FileEdit { - path: PNPM_WORKSPACE_REL.into(), - kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), - action: "created".into(), - key: Some("trustLockfile".into()), - original: None, - new: Some(serde_json::json!("true")), - }, - )); - pnpm_trust_configured_detail(&server, true, options.dry_run) - } - TrustPlan::Append(text) => { - trust_config_write = Some(( - text, - FileEdit { - path: PNPM_WORKSPACE_REL.into(), - kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), - action: "added".into(), - key: Some("trustLockfile".into()), - original: None, - new: Some(serde_json::json!("true")), - }, - )); - pnpm_trust_configured_detail(&server, false, options.dry_run) - } - TrustPlan::AlreadyTrue => format!( - "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ - true` — keep it committed alongside the lock; installs need \ - no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ), - TrustPlan::UserSet(value) => format!( - "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ - {value}`, which was respected and left untouched — install \ - with `pnpm install --trust-lockfile`, or set `trustLockfile: \ - true` yourself so every install accepts the patched \ - artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", - pnpm_trust_policy_preamble(&server), - ), - }, - } - }; - let store_note = if pnpm_lock_texts - .iter() - .any(|text| pnpm_lock_may_need_store_flag(text)) - { - " (pnpm 1–4 spell the option `--store`)" - } else { - "" - }; - pnpm_warnings.push(serde_json::json!({ - "code": "redirect_pnpm_trust_lockfile", - "detail": format!( - "{}. After a lock-only change, existing node_modules or a warm pnpm store \ - can still contain upstream files. For a reliable reinstall, use a clean \ - node_modules tree and an empty store with \ - `pnpm install --frozen-lockfile --store-dir `\ - {store_note}. Do not rely on `--force`: some versions re-resolve the \ - upstream artifact. Run `socket-patch vex` after installation to verify \ - the patched files.", - detail.trim_end_matches('.') - ), - })); - } - } - if workspace_symlink_write { - return Err(symlink_refusal(PNPM_WORKSPACE_REL)); - } - - let mut npm_warnings: Vec = Vec::new(); - let mut npmrc_config_write: Option<(String, FileEdit)> = None; - { - let npm_hosts: Vec<&str> = { - let mut hosts: Vec<&str> = overrides - .iter() - .filter(|o| o.ecosystem == "npm") - .filter(|o| { - NPM_LOCKS.iter().any(|lock| { - rewrite - .files - .get(*lock) - .or_else(|| files.get(*lock)) - .is_some_and(|text| { - socket_patch_core::patch::redirect::artifact_url_present( - text, - &o.artifact_url, - ) - }) - }) - }) - .filter_map(|o| url_host(&o.artifact_url)) - .collect(); - hosts.sort_unstable(); - hosts.dedup(); - hosts - }; - if !npm_hosts.is_empty() { - let edit = |action: &str| FileEdit { - path: NPMRC_REL.into(), - kind: NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), - action: action.into(), - key: Some("allow-remote".into()), - original: None, - new: Some(serde_json::json!("all")), - }; - let outer = OuterAllowRemote::default(); - let detail = match read_npmrc(project) { - Ok(existing) => match plan_npmrc_allow_remote_with(existing.as_deref(), &outer) { - NpmrcPlan::AlreadyAll => npm_allow_remote_already_detail(&npm_hosts), - NpmrcPlan::UserSet(value) => { - npm_allow_remote_user_set_detail(&npm_hosts, &value) - } - NpmrcPlan::EnvSet { var, value } => { - npm_allow_remote_env_set_detail(&npm_hosts, &var, &value) - } - NpmrcPlan::OuterSet { layer, path, value } => { - npm_allow_remote_outer_set_detail(&npm_hosts, layer, &path, &value) - } - NpmrcPlan::Unsupported(why) => { - npm_allow_remote_unreadable_detail(&npm_hosts, &why) - } - _ if !options.npm_allow_remote_config => { - npm_allow_remote_manual_detail(&npm_hosts) - } - NpmrcPlan::Create(text) => { - npmrc_config_write = Some((text, edit("created"))); - npm_allow_remote_configured_detail(&npm_hosts, true, options.dry_run) - } - NpmrcPlan::Append(text) => { - npmrc_config_write = Some((text, edit("added"))); - npm_allow_remote_configured_detail(&npm_hosts, false, options.dry_run) - } - }, - Err(why) => npm_allow_remote_unreadable_detail(&npm_hosts, &why), - }; - npm_warnings.push(serde_json::json!({ - "code": "redirect_npm_allow_remote", - "detail": detail, - })); - } - } - if let Some((text, edit)) = trust_config_write { - rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); - rewrite.edits.push(edit); - } - if let Some((text, edit)) = npmrc_config_write { - rewrite.files.insert(NPMRC_REL.to_string(), text); - rewrite.edits.push(edit); - } - let rewritten: Vec = rewrite - .files - .keys() - .chain(rewrite.binary_files.keys()) - .cloned() - .collect(); - - let pdm_inactive = - files.contains_key("pdm.lock") && !socket_patch_core::patch::redirect::pdm_drives(files); - // A `vlt-lock.json` the vlt rewrite was withheld from may still hold an - // earlier run's pin: only the sibling lock this run rewrote confirms it. - let final_texts: Vec<(&str, &String)> = files - .iter() - .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) - .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) - .chain( - rewrite - .files - .iter() - .filter(|(name, _)| !files.contains_key(*name)) - .map(|(name, content)| (name.as_str(), content)), - ) - .collect(); - let confirmed: Vec<(String, String)> = candidates - .iter() - .filter(|c| { - let purl = c.purl.as_str(); - let uuid = c.dep.patch_uuid.as_str(); - // vlt decides before the binary-bun rule, as on disk. - if rewrite.refused_vlt_uuids.contains(uuid) { - return false; - } - if rewrite.vlt_drives && purl.starts_with("pkg:npm/") { - return rewrite.confirmed_vlt_uuids.contains(uuid); - } - if binary_bun && purl.starts_with("pkg:npm/") { - return rewrite.confirmed_bun_binary_uuids.contains(uuid); - } - if rewrite.refused_pipenv_uuids.contains(uuid) { - return false; - } - if rewrite.refused_pdm_uuids.contains(uuid) { - return false; - } - if purl.starts_with("pkg:pypi/") - && socket_patch_core::patch::redirect::pdm_drives(files) - { - return rewrite.confirmed_pdm_uuids.contains(uuid); - } - if rewrite.python_lock_uuids.contains(uuid) { - return rewrite.confirmed_python_lock_uuids.contains(uuid) - && !rewrite.refused_python_lock_uuids.contains(uuid); - } - if rewrite.hatch_uuids.contains(uuid) { - return rewrite.confirmed_hatch_uuids.contains(uuid); - } - if purl.starts_with("pkg:pypi/") { - return rewrite.confirmed_pipenv_uuids.contains(uuid) - || rewrite.confirmed_requirements_uuids.contains(uuid); - } - if rewrite.refused_pnpm_uuids.contains(uuid) { - return false; - } - if purl.starts_with("pkg:cargo/") { - return rewrite.confirmed_cargo_uuids.contains(uuid); - } - if purl.starts_with("pkg:golang/") { - return rewrite.confirmed_golang_uuids.contains(uuid); - } - let artifact_url = c.dep.artifact_url.as_str(); - let registry = c.dep.registry_override.as_ref(); - let index_url = registry.map(|o| o.index_url.as_str()); - let suffixed_version = - registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref()); - let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url); - let vlt_withheld = planned.vlt_preflight.withheld_from_vlt.contains(uuid); - final_texts.iter().any(|(name, text)| { - if vlt_withheld && *name == VLT_LOCK { - return false; - } - socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url) - || text.contains(encoded.as_str()) - || index_url.is_some_and(|iu| text.contains(iu)) - || suffixed_version.is_some_and(|sv| text.contains(sv)) - }) - }) - .map(|c| (c.purl.clone(), c.dep.patch_uuid.clone())) - .collect(); - - if let Some(linked) = rewrite - .files - .keys() - .chain(rewrite.binary_files.keys()) - .find(|k| project.is_symlink(k)) - { - return Err(symlink_refusal(linked)); - } - let candidate_ecosystems: BTreeSet<&str> = candidates - .iter() - .map(|c| c.dep.ecosystem.as_str()) - .collect(); - if let Some(linked) = planned - .symlinked_reads - .iter() - .find(|rel| file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco))) - { - return Err(symlink_refusal(linked)); - } - if let Some(rel) = planned - .unreadable_reads - .iter() - .find(|rel| { - rewrite.files.contains_key(rel.as_str()) - || file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco)) - }) - .or_else(|| { - rewrite - .files - .keys() - .find(|k| matches!(project.get(k), Some(MemoryEntry::Present))) - }) - { - return Err(unreadable_refusal(rel)); - } - - Ok(Rewritten { - planned, - rewrite, - rewritten, - confirmed, - rush_warnings, - pnpm_warnings, - npm_warnings, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn reference(value: serde_json::Value) -> PackageVendorResult { - serde_json::from_value(value).unwrap() - } - - #[test] - fn candidates_skip_every_unusable_reference() { - let mut refs: HashMap = HashMap::new(); - refs.insert( - "u-pending".into(), - reference(serde_json::json!({"status": "pending_build"})), - ); - refs.insert( - "u-nourl".into(), - reference(serde_json::json!({"status": "granted", "purl": "pkg:npm/b@1"})), - ); - refs.insert( - "u-ok".into(), - reference(serde_json::json!({ - "status": "reused", - "url": "https://patch.example/patch/npm/c/1/tok/u-ok/c-1.tgz", - "purl": "pkg:npm/c@1", - "artifacts": [{"kind": "tarball", "url": null, "integrity": {"sha512": "sha512-x"}}], - "registryOverride": null - })), - ); - let selected = vec![ - ("pkg:npm/a@1".to_string(), "u-missing".to_string()), - ("pkg:npm/p@1".to_string(), "u-pending".to_string()), - ("pkg:npm/b@1".to_string(), "u-nourl".to_string()), - ("pkg:npm/c@1".to_string(), "u-ok".to_string()), - ]; - let mut skipped = Vec::new(); - let candidates = build_candidates(&selected, &refs, &mut skipped); - let reasons: Vec<&str> = skipped.iter().map(|s| s.reason.as_str()).collect(); - assert_eq!(reasons, vec!["not_found", "pending_build", "no_url"]); - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].dep.token, "tok"); - assert_eq!( - candidates[0].dep.integrity.sha512.as_deref(), - Some("sha512-x") - ); - } - - #[test] - fn cargo_wiring_probe_is_scoped_to_the_crate_and_version() { - let mut p = MemoryProject::new(); - p.insert_text( - "Cargo.toml", - "[package]\nname = \"app\"\n\n[dependencies]\nlog = \"0.4\"\ncc = \"1\"\n\n\ - [patch.crates-io]\nopenssl-socket-0123abcd = { package = \"openssl\", path = \ - \".socket/vendor/cargo/0123abcd-0000-4000-8000-000000000000/openssl-0.10.66\" }\n", - ); - assert!(cargo_vendored_wiring(&p, "openssl", "0.10.66")); - assert!(!cargo_vendored_wiring(&p, "openssl", "0.10.65")); - assert!(!cargo_vendored_wiring(&p, "log", "0.4.22")); - assert!(!cargo_vendored_wiring(&p, "cc", "1.1.0")); - - let mut legacy = MemoryProject::new(); - legacy.insert_text("Cargo.toml", "[package]\nname = \"app\"\n"); - let config = "[patch.crates-io]\ncc = { path = \ - \".socket/vendor/cargo/0123abcd-0000-4000-8000-000000000000/cc-1.1.0\" }\n"; - legacy.insert_text(".cargo/config.toml", config); - assert!(cargo_vendored_wiring(&legacy, "cc", "1.1.0")); - // cargo reads the legacy spelling when it exists. - legacy.insert_text(".cargo/config", ""); - assert!(!cargo_vendored_wiring(&legacy, "cc", "1.1.0")); - } - - fn cargo_reference( - uuid: &str, - ) -> (Vec<(String, String)>, HashMap) { - let purl = "pkg:cargo/serde@1.0.190"; - let mut refs = HashMap::new(); - refs.insert( - uuid.to_string(), - reference(serde_json::json!({ - "status": "granted", - "url": format!("https://patch.example/patch/cargo/serde/1.0.190/tok/{uuid}/serde-1.0.190.crate"), - "purl": purl, - "artifacts": [{"kind": "tarball", "url": null, "integrity": {"sha256": "ab"}}], - "registryOverride": null - })), - ); - (vec![(purl.to_string(), uuid.to_string())], refs) - } - - #[test] - fn an_unreadable_candidate_file_refuses_its_ecosystem() { - let (selected, refs) = cargo_reference("u-1"); - let mut p = MemoryProject::new(); - p.insert_text("Cargo.toml", "[dependencies]\nserde = \"1\"\n"); - p.insert_text( - "Cargo.lock", - "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ - source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", - ); - p.insert_present(".cargo/config"); - let options = StageOptions { - dry_run: false, - pipenv_major: None, - trust_lockfile_config: true, - npm_allow_remote_config: true, - }; - let unreadable = BTreeSet::from([".cargo/config".to_string()]); - let planned = plan(p.clone(), unreadable, &selected, &refs) - .unwrap_or_else(|r| panic!("{:?}", r.error)); - assert_eq!(planned.unreadable_reads, vec![".cargo/config"]); - let err = rewrite(planned, &BTreeMap::new(), options).unwrap_err(); - assert_eq!(err.error.code, UNREADABLE_REFUSAL); - - // A non-UTF-8 file is absent to disk too: not a refusal. - let planned = - plan(p, BTreeSet::new(), &selected, &refs).unwrap_or_else(|r| panic!("{:?}", r.error)); - assert!(planned.unreadable_reads.is_empty()); - } - - #[test] - fn file_ecosystems_cover_the_rewrite_targets() { - assert_eq!(file_ecosystem("package-lock.json"), Some("npm")); - assert_eq!( - file_ecosystem("common/config/rush/pnpm-lock.yaml"), - Some("npm") - ); - assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi")); - assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); - assert_eq!(file_ecosystem("build.gradle"), None); - } -} diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 7bc0e4d1..38ad79dd 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -8,7 +8,10 @@ pub mod args; pub mod commands; pub(crate) mod ecosystem_dispatch; -pub mod hosted_memory; +/// The in-memory hosted engine, which lives in core +/// ([`socket_patch_core::hosted::memory`]); re-exported under its old path +/// for the `hosted-bundle` harness and the integration tests. +pub use socket_patch_core::hosted::memory as hosted_memory; pub mod json_envelope; pub mod path_scope; pub mod ui; diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 3add95f0..7bbbb82e 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -24,6 +24,8 @@ sha1 = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +# CancellationToken for the in-memory hosted engine (`hosted::memory`). +tokio-util = { workspace = true } futures-util = { workspace = true } thiserror = { workspace = true } walkdir = { workspace = true } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs new file mode 100644 index 00000000..f6e53cd4 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -0,0 +1,1757 @@ +//! The hosted redirect engine: plan → rewrite → edits over a +//! [`ProjectView`], shared verbatim by `scan`/`get --mode hosted` (a +//! [`ProjectView::Disk`] over the checkout) and the in-memory engine +//! ([`super::memory`], a [`ProjectView::Memory`] over the host's file set). +//! +//! The stages, in the order both callers run them: +//! +//! 1. [`build_candidates`] — reference grants → rewriter overrides. +//! 2. [`bun_lockb_symlinked`] — the binary-lock symlink refusal. +//! 3. vlt artifact preflight ([`super::vlt`]) + [`withhold_everywhere`]. +//! 4. (caller) the apply lock, the ledger, the vendored→hosted takeover. +//! 5. [`read_candidate_files`] → [`wheel_targets`] → (caller) wheel metadata. +//! 6. [`rewrite`] — the rewriters, the pnpm `trustLockfile` and npm +//! `allow-remote` auto-configs, and the per-ecosystem confirmation. +//! 7. [`guard`] — the symlink / unreadable-file refusal before any write. +//! +//! Nothing here writes, spawns, reads the environment or touches the +//! network: every host effect (locking, probes, record fetches, the commit +//! of the rewritten files, the redirect ledger in [`super::ledger`]) stays +//! with the caller. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use serde::{Deserialize, Serialize}; + +use crate::api::types::PackageVendorResult; +use crate::constants::npm_family::{ + RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, VLT_HIDDEN_LOCK_REL, VLT_LOCK, +}; +use crate::patch::redirect::npmrc::{ + plan_npmrc_allow_remote_with, NpmrcPlan, OuterAllowRemote, NPMRC_ALLOW_REMOTE_EDIT_KIND, + NPMRC_REL, +}; +use crate::patch::redirect::presence::groups_present; +use crate::patch::redirect::{ + artifact_url_spellings, rewrite_registry_redirect_withholding_vlt, DepOverride, FileEdit, + RewriteResult, RewriteWarning, +}; +use crate::utils::purl::purl_parts; +use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; + +use super::guidance::{ + npm_allow_remote_already_detail, npm_allow_remote_configured_detail, + npm_allow_remote_env_set_detail, npm_allow_remote_manual_detail, + npm_allow_remote_outer_set_detail, npm_allow_remote_unreadable_detail, + npm_allow_remote_user_set_detail, npm_lock_url_needles, plan_workspace_trust, pnpm_heal_root, + pnpm_lock_may_need_store_flag, pnpm_lock_version_major, pnpm_trust_configured_detail, + pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_policy_preamble, + pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, + url_host, TrustPlan, NPM_LOCKS, PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, + REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, +}; +use super::vlt::bun_lockb_present; + +/// Candidate lockfiles / registry configs the redirect rewriters may touch — +/// read from the project when present and handed to +/// `rewrite_registry_redirect`: the [`crate::formats::registry::HOSTED`] +/// rows of the format registry, in its read order. +pub static REDIRECT_CANDIDATE_FILES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + crate::formats::registry::paths_with(crate::formats::registry::HOSTED) + }); + +/// Refusal code for a rewrite target (or a file the rewrite reads) that is +/// a symbolic link: the writers stage next to the path and rename over it, +/// which would replace the link with a detached copy. +pub const SYMLINK_REFUSAL: &str = "redirect_symlinked_file_unsupported"; + +/// Refusal code for a candidate file that exists but whose content the +/// in-memory host did not provide (oversize, an LFS pointer, +/// presence-only); disk would read and rewrite it. +pub const UNREADABLE_REFUSAL: &str = "candidate_file_unreadable"; + +/// Rush's repo-state file, whose `pnpmShrinkwrapHash` a lock edit +/// outside `rush update` desyncs. +pub const RUSH_REPO_STATE_REL: &str = "common/config/rush/repo-state.json"; + +/// One granted reference: the purl it was granted for plus the rewriter +/// override built from it. The purl is what the takeover, the skip records +/// and the confirmation probe key on; everything the probe needs AFTER the +/// rewrite to decide whether the dep was actually redirected (artifact +/// URL, registry index URL, fail-closed maven's suffixed version) already +/// rides the override. The single vector is filtered in place by every +/// withhold/refusal step, and the rewriters' `overrides` slice is +/// materialized from it once, after the last filter. +#[derive(Debug, Clone)] +pub struct Candidate { + pub purl: String, + pub dep: DepOverride, +} + +/// A selected patch that was not redirected, and why (the `skipped[]` +/// entries of the `redirect` block). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkippedPatch { + pub purl: String, + pub uuid: String, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl SkippedPatch { + pub fn new(purl: &str, uuid: &str, reason: &str) -> Self { + SkippedPatch { + purl: purl.to_string(), + uuid: uuid.to_string(), + reason: reason.to_string(), + detail: None, + } + } +} + +/// A whole-project refusal: nothing is written. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub code: String, + pub message: String, +} + +/// The refusal for a symlinked rewrite target. +pub fn symlink_refusal(linked: &str) -> Refusal { + Refusal { + code: SYMLINK_REFUSAL.to_string(), + message: format!( + "{linked} is a symbolic link; socket-patch rewrites files in place with an atomic \ + rename, which would replace the link — replace the link with a regular file (or \ + run socket-patch in the directory it points to) and re-run; nothing was written" + ), + } +} + +/// The refusal for a symlinked `bun.lockb` (checked before any takeover +/// changes wiring). +pub fn bun_lockb_symlink_refusal() -> Refusal { + Refusal { + code: SYMLINK_REFUSAL.to_string(), + message: "bun.lockb is a symbolic link; replace it with a regular file (or run \ + socket-patch in the directory it points to) before patching; nothing was \ + written" + .to_string(), + } +} + +fn unreadable_refusal(rel: &str) -> Refusal { + Refusal { + code: UNREADABLE_REFUSAL.to_string(), + message: format!( + "{rel} exists but its content was not provided (too large, an LFS pointer, or \ + not fetched), so it cannot be rewritten alongside the other lockfiles; nothing \ + was written" + ), + } +} + +/// Reference grants → candidates. A selection without a usable grant is +/// recorded in `skipped` (`not_found`, the reference status, `bad_purl`, +/// `no_url`). +pub fn build_candidates( + selected: &[(String, String)], + references: &HashMap, + skipped: &mut Vec, +) -> Vec { + let mut candidates = Vec::new(); + for (sel_purl, sel_uuid) in selected { + let Some(reference) = references.get(sel_uuid) else { + skipped.push(SkippedPatch::new(sel_purl, sel_uuid, "not_found")); + continue; + }; + if reference.status != "granted" && reference.status != "reused" { + skipped.push(SkippedPatch::new(sel_purl, sel_uuid, &reference.status)); + continue; + } + let purl = reference.purl.as_deref().unwrap_or(sel_purl); + let Some((ecosystem, name, version)) = purl_parts(purl) else { + skipped.push(SkippedPatch::new(purl, sel_uuid, "bad_purl")); + continue; + }; + let Some(url) = reference.url.clone() else { + skipped.push(SkippedPatch::new(purl, sel_uuid, "no_url")); + continue; + }; + let mut integrity = reference + .artifacts + .iter() + .flatten() + .find(|a| a.kind == "tarball") + .map(|a| a.integrity.clone()) + .unwrap_or_default(); + // The yarn-berry cache zip carries the `yarnBerry10c0` checksum the + // berry rewriter pins (berry verifies the zip, not the tarball). + // Merge it in and carry the zip URL (None when not stored yet). + let berry_zip = reference + .artifacts + .iter() + .flatten() + .find(|a| a.kind == "yarn-berry-zip"); + if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) { + integrity.yarn_berry10c0 = Some(c); + } + // goproxy: the hosted-Go hash pair rides the override's + // identifiers (the tarball's dirhashH1 is the original-path + // flavor, kept for vendor-mode verification); the golang rewriter + // reads the normalized integrity, so merge — the gopatch-flavor zip + // h1 REPLACES dirhashH1 here. Only both together: a half-merged + // pair would trip the rewriter's fail-closed integrity check by + // design. + if let Some(ov) = reference + .registry_override + .as_ref() + .filter(|o| o.kind == "goproxy") + { + if let (Some(zip_h1), Some(gomod_h1)) = ( + ov.identifiers.go_zip_dirhash_h1.clone(), + ov.identifiers.go_mod_h1.clone(), + ) { + integrity.dirhash_h1 = Some(zip_h1); + integrity.go_mod_h1 = Some(gomod_h1); + } + } + // The grant token is never a top-level reference field — it only + // rides the URLs the reference endpoint hands back, as the path + // level before the patch uuid. Recover it so the rewriters' + // rotation-idempotency guards (which wildcard the token path level + // of a previously-written URL) don't depend on it being derivable + // from the URL alone (an empty token makes the gem guard nest a new + // source block on every re-scan). + let token = reference + .registry_override + .as_ref() + .and_then(|o| crate::patch::redirect::grant_token_path_segment(&o.index_url, sel_uuid)) + .or_else(|| crate::patch::redirect::grant_token_path_segment(&url, sel_uuid)) + .unwrap_or_default(); + candidates.push(Candidate { + purl: purl.to_string(), + dep: DepOverride { + ecosystem, + name, + namespace: None, + version, + token, + patch_uuid: sel_uuid.clone(), + artifact_url: url, + berry_zip_url: berry_zip.and_then(|a| a.url.clone()), + registry_override: reference.registry_override.clone(), + integrity, + }, + }); + } + candidates +} + +/// Whether the text `bun.lock` is present (disk: `exists`). Text retains +/// Bun's precedence when both lock spellings are present. +pub fn bun_lock_present(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join("bun.lock").exists(), + ProjectView::Memory(project) => project.contains("bun.lock"), + } +} + +/// Whether an npm candidate would rewrite a `bun.lockb` that is a symbolic +/// link (atomic replacement cannot preserve a link; previews refuse too). +pub fn bun_lockb_symlinked(view: &ProjectView<'_>, candidates: &[Candidate]) -> bool { + candidates.iter().any(|c| c.dep.ecosystem == "npm") + && !bun_lock_present(view) + && view.is_symlink("bun.lockb") +} + +/// Drop the deps the vlt preflight withheld from every rewriter, recording +/// each as skipped. +pub fn withhold_everywhere( + candidates: &mut Vec, + withheld_everywhere: &BTreeMap, + skipped: &mut Vec, +) { + if withheld_everywhere.is_empty() { + return; + } + for (uuid, purl) in withheld_everywhere { + skipped.push(SkippedPatch::new(purl, uuid, super::vlt::WITHHELD_REASON)); + } + candidates.retain(|c| !withheld_everywhere.contains_key(&c.dep.patch_uuid)); +} + +/// The project's candidate files as the rewriters read them. +#[derive(Debug, Default)] +pub struct CandidateFiles { + /// Readable candidate texts, keyed by project-relative path. + pub files: BTreeMap, + /// The Rush locks among `files` (the repo-state warning keys on them). + pub rush_lock_keys: Vec, + /// In memory only: candidate files the disk flow reads through a + /// symbolic link. Their bytes are unknown here, so a project whose + /// candidates could rewrite one is refused like the disk symlink guard + /// refuses the write. + pub symlinked_reads: Vec, + /// In memory only: candidate files that exist without content; a + /// project whose candidates could rewrite (or whose rewrite depends on) + /// one is refused, since the rewriters would treat it as absent. + pub unreadable_reads: Vec, +} + +impl CandidateFiles { + /// Read `rel` into `files`: `true` when it was read. + async fn read( + &mut self, + view: &ProjectView<'_>, + unreadable: &BTreeSet, + rel: &str, + ) -> bool { + let text = match view { + // Every disk read goes through the FIFO-safe reader + // (non-blocking open + fstat regular-file check), so a FIFO + // under a candidate name is skipped like a missing file instead + // of wedging the run in open(2). + ProjectView::Disk(_) | ProjectView::Snapshot(_) => view.read_text(rel).await.ok(), + ProjectView::Memory(project) => { + if project.is_symlink(rel) { + self.symlinked_reads.push(rel.to_string()); + return false; + } + if unreadable.contains(rel) { + self.unreadable_reads.push(rel.to_string()); + return false; + } + // Disk reads any UTF-8 regular file; a non-UTF-8 one is + // absent to it as well. + match project.get(rel) { + Some(MemoryEntry::Text(text)) => Some(text.to_string()), + Some(MemoryEntry::Binary(bytes)) => { + std::str::from_utf8(bytes).ok().map(str::to_string) + } + _ => None, + } + } + }; + match text { + Some(text) => { + self.files.insert(rel.to_string(), text); + true + } + None => false, + } + } +} + +/// The root-level Python lock names (sorted). +async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default(), + ProjectView::Memory(project) => project + .children("") + .into_iter() + .filter(|(name, is_dir)| { + !is_dir && crate::utils::python_lock::is_python_lock_name(name) + }) + .map(|(name, _)| name) + .collect(), + } +} + +/// Whether the project is a Rush monorepo (disk: `rush.json` is a file). +fn rush_repo(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join("rush.json").is_file(), + ProjectView::Memory(project) => project.contains("rush.json"), + } +} + +/// Read the project's candidate files: [`REDIRECT_CANDIDATE_FILES`], the +/// Cargo workspace members (when a cargo candidate meets a root +/// `Cargo.toml`), the Python locks and their scripts, and the Rush locks. +/// `unreadable` are the in-memory paths that exist without content. +pub async fn read_candidate_files( + view: &ProjectView<'_>, + unreadable: &BTreeSet, + candidates: &[Candidate], +) -> CandidateFiles { + let mut out = CandidateFiles::default(); + for name in REDIRECT_CANDIDATE_FILES.iter() { + // The binary lock is read and rewritten directly. + if *name == "bun.lockb" { + continue; + } + // The hidden lock is only the install-state sentinel, never read. + if *name == VLT_HIDDEN_LOCK_REL { + if super::vlt::install_state_present(view) { + out.files.insert((*name).to_string(), String::new()); + } + continue; + } + out.read(view, unreadable, name).await; + } + + // Cargo workspace members (and in-root path dependencies) declare + // dependencies of their own: a member's direct `cfg-if = "1"` must be + // pinned alongside the root's, or the redirected lock entry is + // unsatisfiable. Keyed `/Cargo.toml` for the cargo rewriter. + if out.files.contains_key("Cargo.toml") && candidates.iter().any(|c| c.dep.ecosystem == "cargo") + { + for rel in crate::utils::cargo_workspace::member_manifests_in(view) { + out.read(view, unreadable, &rel).await; + } + } + + for path in python_lock_paths(view).await { + if let Some(script) = crate::utils::python_lock::script_of_lock(&path) { + out.read(view, unreadable, script).await; + } + out.read(view, unreadable, &path).await; + } + + // Rush monorepos have no root package.json/lock pair: the single pnpm + // source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and + // (when subspaces are enabled) one lock per subspace under + // common/config/subspaces//. Added under their repo-relative + // keys — the pnpm rewriter is basename-generalized, so nested keys are + // rewritten in place, and the write-back is path-generic. + if rush_repo(view) { + if out.read(view, unreadable, RUSH_COMMON_LOCK_REL).await { + out.rush_lock_keys.push(RUSH_COMMON_LOCK_REL.to_string()); + } + // Sorted by name: deterministic output. + if let Ok(entries) = view.list_dir(RUSH_SUBSPACES_DIR).await { + for entry in entries.into_iter().filter(|e| e.is_dir) { + let key = format!("{RUSH_SUBSPACES_DIR}/{}/pnpm-lock.yaml", entry.name); + if out.read(view, unreadable, &key).await { + out.rush_lock_keys.push(key); + } + } + } + } + out.symlinked_reads.sort(); + out.symlinked_reads.dedup(); + out.unreadable_reads.sort(); + out.unreadable_reads.dedup(); + out +} + +/// The pypi wheels whose metadata a native lock rewrite needs, in +/// candidate order: `(override, sha256)` of every `.whl` artifact some +/// `uv.lock` / PEP 723 script lock would rewrite. Each native lock is +/// parsed once, on the first dep that needs the probe. +pub fn wheel_targets<'a>( + candidates: &'a [Candidate], + files: &BTreeMap, +) -> Vec<(&'a DepOverride, &'a str)> { + use crate::utils::python_lock::{ArtifactSource, PythonLockProbe}; + let mut probes: Option> = None; + let mut out = Vec::new(); + for dep in candidates + .iter() + .map(|c| &c.dep) + .filter(|dep| dep.ecosystem == "pypi") + { + let Some(sha256) = dep.integrity.sha256.as_deref() else { + continue; + }; + if !dep + .artifact_url + .split(['?', '#']) + .next() + .is_some_and(|path| path.ends_with(".whl")) + { + continue; + } + let native_target = probes + .get_or_insert_with(|| { + files + .iter() + .filter(|(path, _)| { + *path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path) + }) + .map(|(_, text)| PythonLockProbe::new(text)) + .collect() + }) + .iter() + .any(|probe| { + probe.rewrites( + &dep.name, + &dep.version, + ArtifactSource::Url(&dep.artifact_url), + ) + }); + if native_target { + out.push((dep, sha256)); + } + } + out +} + +/// The skip recorded for a pypi dep whose wheel metadata could not be +/// fetched (the grant token in `detail` is redacted to ``). +pub fn wheel_metadata_unavailable(dep: &DepOverride, detail: &str) -> SkippedPatch { + SkippedPatch { + purl: format!("pkg:pypi/{}@{}", dep.name, dep.version), + uuid: dep.patch_uuid.clone(), + reason: "python_metadata_unavailable".to_string(), + detail: Some(detail.replace(&dep.artifact_url, "")), + } +} + +/// Whether a pypi candidate targets an entry of the project's +/// `Pipfile.lock` (only then does the installing Pipenv's major matter). +pub fn pipenv_lock_targets(files: &BTreeMap, candidates: &[Candidate]) -> bool { + if !files.contains_key("Pipfile.lock") { + return false; + } + let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); + crate::patch::redirect::pipenv_lock_targets(files, &overrides) +} + +/// A dry-run vendored→hosted takeover the disk caller withheld from the +/// rewriters: its artifact URL and the root locks its vendored wiring +/// lives in (the wet run splices the hosted URL there, so the +/// install-policy auto-configs are previewed for those locks). +#[derive(Debug, Clone)] +pub struct TakeoverPreview { + pub artifact_url: String, + pub locks: Vec, +} + +/// The host-dependent inputs of [`rewrite`]. +pub struct RewriteOptions<'a> { + pub dry_run: bool, + /// Whether a pypi candidate targets `Pipfile.lock` + /// ([`pipenv_lock_targets`]) and the installing Pipenv's major (`None` + /// when unknown or not targeted). + pub targets_pipenv_lock: bool, + pub pipenv_major: Option, + /// The `redirect_pipenv_installer_unknown` detail (the remedy names the + /// host's own knob). + pub pipenv_unknown_detail: String, + /// `false` under `--no-trust-lockfile-config`. + pub trust_lockfile_config: bool, + /// `false` under `--no-npm-allow-remote-config`. + pub npm_allow_remote_config: bool, + /// The npm config layers outside the project `.npmrc`, resolved only + /// when an npm lock carries a hosted URL. + pub npm_outer: &'a (dyn Fn() -> OuterAllowRemote + Send + Sync), + /// Run the rewriters on the blocking pool (the disk flow: pure CPU over + /// every lock text). + pub blocking: bool, +} + +/// One project's rewrite, ready for the guard, the record fetch and the +/// commit. +#[derive(Debug)] +pub struct Rewritten { + /// The pre-rewrite candidate texts. + pub files: BTreeMap, + pub symlinked_reads: Vec, + pub unreadable_reads: Vec, + /// The rewriters' override slice (the candidates' deps). + pub overrides: Vec, + pub rewrite: RewriteResult, + /// Every file this run writes (text and binary), sorted. + pub rewritten: Vec, + /// `(purl, uuid)` of each candidate whose redirect is pinned by the + /// project's final files, in candidate order. + pub confirmed: Vec<(String, String)>, + /// A `bun.lockb` without a text `bun.lock` drives npm. + pub binary_bun: bool, + pub rush_warnings: Vec, + pub pnpm_warnings: Vec, + pub npm_warnings: Vec, + /// Human mode: this run touched nothing pnpm-related (no lock spliced, + /// trust already configured), so the full guidance shrinks to a + /// one-line reminder. + pub pnpm_rerun_only: bool, + /// In memory only: the trust auto-config would write through a + /// symlinked `pnpm-workspace.yaml`. + pub(crate) workspace_symlinked: bool, +} + +/// The pnpm-workspace.yaml read, classified for the trust auto-config +/// (see [`read_workspace_for_trust`]), plus whether it is an in-memory +/// symbolic link (absent to the planner, refused by [`guard`]). +fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, bool) { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => ( + read_workspace_for_trust(&cwd.join(PNPM_WORKSPACE_REL)), + false, + ), + ProjectView::Memory(project) => match project.get(PNPM_WORKSPACE_REL) { + None => (Ok(None), false), + Some(MemoryEntry::Text(text)) => (Ok(Some(text.to_string())), false), + Some(MemoryEntry::Symlink) => (Ok(None), true), + Some(MemoryEntry::Binary(_)) => ( + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "stream did not contain valid UTF-8", + )), + false, + ), + Some(MemoryEntry::Present) => ( + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "file content was not provided", + )), + false, + ), + }, + } +} + +/// The project `.npmrc` read, classified for the allow-remote planner (see +/// [`read_npmrc_for_allow_remote`]). +fn read_npmrc(view: &ProjectView<'_>) -> Result, String> { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => read_npmrc_for_allow_remote(&cwd.join(NPMRC_REL)), + ProjectView::Memory(project) => match project.get(NPMRC_REL) { + None => Ok(None), + Some(MemoryEntry::Symlink) => { + Err("is a symbolic link (socket-patch never writes through one)".into()) + } + Some(MemoryEntry::Text(text)) => Ok(Some(text.to_string())), + Some(MemoryEntry::Binary(_)) => { + Err("could not be read (stream did not contain valid UTF-8)".into()) + } + Some(MemoryEntry::Present) => { + Err("could not be read (file content was not provided)".into()) + } + }, + } +} + +/// Whether Rush's repo-state file is present (disk: a regular file). +fn rush_repo_state_present(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join(RUSH_REPO_STATE_REL).is_file(), + ProjectView::Memory(project) => project.contains(RUSH_REPO_STATE_REL), + } +} + +/// How the confirmation probe settles one candidate: a non-substring rule +/// (a transactional rewriter's own report, a refusal) decides it outright, +/// otherwise it is confirmed iff any of its needles occurs in a final text. +enum ProbeStep { + Decided(bool), + Needles(Vec), + /// [`Self::Needles`], searched in every final text but `vlt-lock.json` + /// (a dep withheld from the vlt rewrite). + NeedlesOutsideVlt(Vec), +} + +/// The substrings whose presence in a final text confirms `dep`'s redirect — +/// the override's own targets: artifact URL; per-dependency registry index +/// URL; fail-closed maven's globally-unique `-socket.` suffixed version +/// (never the `.pom` URL). +/// +/// - The artifact URL in the rewriters' own spellings +/// ([`artifact_url_spellings`], raw or the `\/`-escaped slashes an old +/// composer.lock spells them with), so a writer's spelling can never be +/// one this probe misses. +/// - The percent-encoded URL: the berry rewriter writes it into the lock's +/// `::__archiveUrl=` binding, so the raw form is absent. +/// - The registry index URL and the maven suffixed version, when present. +pub fn candidate_presence_needles(dep: &DepOverride) -> Vec { + let artifact_url = dep.artifact_url.as_str(); + let registry = dep.registry_override.as_ref(); + let mut needles: Vec = artifact_url_spellings(artifact_url).into(); + needles.push(crate::utils::uri::encode_uri_component(artifact_url)); + if let Some(o) = registry { + needles.push(o.index_url.clone()); + if let Some(sv) = o.identifiers.maven_suffixed_version.as_deref() { + needles.push(sv.to_string()); + } + } + needles +} + +/// Rewrite the candidate files for `candidates`, plan the install-policy +/// auto-configs, and confirm which redirects the final files pin. +/// +/// `python_metadata` maps a wheel's artifact URL to its fetched METADATA; +/// `withheld_from_vlt` are the uuids the vlt preflight kept out of the vlt +/// rewrite; `takeover_previews` are the disk dry run's withheld takeovers. +pub async fn rewrite( + view: &ProjectView<'_>, + read: CandidateFiles, + candidates: &[Candidate], + python_metadata: BTreeMap, + withheld_from_vlt: &BTreeSet, + takeover_previews: &[TakeoverPreview], + options: RewriteOptions<'_>, +) -> Rewritten { + let CandidateFiles { + files, + rush_lock_keys, + symlinked_reads, + unreadable_reads, + } = read; + // The rewriters' override slice — materialized ONCE, after the last + // candidate filter, so it can never disagree with `candidates`. + let overrides: Vec = candidates.iter().map(|c| c.dep.clone()).collect(); + let bun_lockb = bun_lockb_present(view); + let binary_bun = !bun_lock_present(view) && bun_lockb; + let binary_content = if binary_bun && overrides.iter().any(|o| o.ecosystem == "npm") { + Some( + view.read_bytes("bun.lockb") + .await + .map_err(|e| RewriteWarning { + code: "redirect_bun_lockb_invalid".into(), + detail: format!("cannot read bun.lockb: {e}"), + }) + .and_then(|bytes| { + crate::patch::redirect::preflight_bun_binary(&bytes)?; + Ok(bytes) + }), + ) + } else { + None + }; + // A malformed primary lock must not cause edits to stale npm siblings. + let rewrite_overrides: Vec = overrides + .iter() + .filter(|o| !(binary_content.as_ref().is_some_and(Result::is_err) && o.ecosystem == "npm")) + .cloned() + .collect(); + let pipenv_major = options.pipenv_major; + let (files, mut rewrite) = if options.blocking { + // Pure CPU over every lock text (the independent rewriter groups + // run concurrently inside), so it runs on the blocking pool rather + // than on a runtime worker; `files` comes back for the probe below. + let withheld = withheld_from_vlt.clone(); + tokio::task::spawn_blocking(move || { + let rewrite = rewrite_registry_redirect_withholding_vlt( + &files, + &rewrite_overrides, + &python_metadata, + pipenv_major, + bun_lockb, + &withheld, + ); + (files, rewrite) + }) + .await + .unwrap_or_else(|e| match e.try_into_panic() { + Ok(payload) => std::panic::resume_unwind(payload), + Err(e) => panic!("hosted rewrite task failed: {e}"), + }) + } else { + let rewrite = rewrite_registry_redirect_withholding_vlt( + &files, + &rewrite_overrides, + &python_metadata, + pipenv_major, + bun_lockb, + withheld_from_vlt, + ); + (files, rewrite) + }; + if let Some(content) = binary_content { + rewrite + .warnings + .retain(|w| w.code != "redirect_npm_no_lockfile"); + match content { + Ok(bytes) => { + crate::patch::redirect::rewrite_bun_binary(&bytes, &overrides, &mut rewrite) + } + Err(warning) => rewrite.warnings.push(warning), + } + } + + // Unknown installer → the modern `file` shape was chosen; say so only + // when the lock was (or, on --dry-run, would be) rewritten. + if options.targets_pipenv_lock + && pipenv_major.is_none() + && rewrite.files.contains_key("Pipfile.lock") + { + rewrite.warnings.push(RewriteWarning { + code: "redirect_pipenv_installer_unknown".into(), + detail: options.pipenv_unknown_detail.clone(), + }); + } + + // Editing a Rush lock outside `rush update` desyncs the + // pnpmShrinkwrapHash recorded in repo-state.json. When + // preventManualShrinkwrapChanges is enabled, `rush install` then + // refuses until `rush update` refreshes that hash — but the redirect + // survives `rush update` (pnpm preserves locked resolutions for + // unchanged specifiers). Warn only when the rewrite actually landed in + // a Rush lock and the repo-state file that carries the hash is present. + let mut rush_warnings: Vec = Vec::new(); + if rush_lock_keys + .iter() + .any(|key| rewrite.files.contains_key(key)) + && rush_repo_state_present(view) + { + rush_warnings.push(warning( + "redirect_rush_repo_state_stale", + "pnpm-lock.yaml was edited outside `rush update`; if \ + preventManualShrinkwrapChanges is enabled, `rush install` fails until \ + `rush update` refreshes repo-state.json (the hosted wiring survives `rush \ + update`)", + )); + } + + let (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) = pnpm_trust( + view, + &files, + &rewrite, + &overrides, + takeover_previews, + &options, + ); + let (npm_warnings, npmrc_config_write) = npm_allow_remote( + view, + &files, + &rewrite, + &overrides, + takeover_previews, + &options, + ); + if let Some((text, edit)) = trust_config_write { + rewrite.files.insert(PNPM_WORKSPACE_REL.to_string(), text); + // Appended last: `--revert` walks edits in reverse, so the trust key + // is unwound before the lock originals are restored. + rewrite.edits.push(edit); + } + if let Some((text, edit)) = npmrc_config_write { + rewrite.files.insert(NPMRC_REL.to_string(), text); + // Appended after the lock edits for the same reason: a whole-ledger + // replay unwinds the setting before the lock originals it served. + rewrite.edits.push(edit); + } + let rewritten: Vec = rewrite + .files + .keys() + .chain(rewrite.binary_files.keys()) + .cloned() + .collect(); + let confirmed = confirm(&files, &rewrite, candidates, binary_bun, withheld_from_vlt); + Rewritten { + files, + symlinked_reads, + unreadable_reads, + overrides, + rewrite, + rewritten, + confirmed, + binary_bun, + rush_warnings, + pnpm_warnings, + npm_warnings, + pnpm_rerun_only, + workspace_symlinked, + } +} + +type ConfigWrite = Option<(String, FileEdit)>; + +/// pnpm >=11 enforces a lockfile supply-chain policy: it compares each +/// resolution's tarball URL against the registry's published metadata and +/// REFUSES a lock whose URLs differ: pnpm 11 with +/// ERR_PNPM_TARBALL_URL_MISMATCH (ERR_PNPM_META_FETCH_FAIL when the +/// registry is unreachable), pnpm 12 with +/// ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION, whose own text tells users +/// to rebuild the lock — which silently discards the redirect, so the +/// warning must pre-empt that advice. The working recoveries are +/// `pnpm install --trust-lockfile` and the pnpm-workspace.yaml +/// `trustLockfile: true` key; the `.npmrc` `trust-lockfile=true` spelling +/// is IGNORED by pnpm and must never be recommended. +/// +/// ZERO-TOUCH DEFAULT: when this run rewrote the ROOT pnpm-lock.yaml and +/// its lockfileVersion is >= 9 (5.x/6.0 locks mean pnpm 7/8, which have +/// neither the policy nor the flag and get their own guidance), the run +/// auto-ensures `trustLockfile: true` in pnpm-workspace.yaml. The same +/// auto-config re-engages on a run that spliced NOTHING when the root v9 +/// lock already carries a granted hosted artifact URL (HEAL-ON-RERUN). +/// pnpm <=10 ignores the key; the per-entry sha512 pin still fails closed +/// on tampered bytes. An explicit user `trustLockfile: ` is +/// RESPECTED (never flipped), and `--no-trust-lockfile-config` opts out. +/// Rush nested/subspace locks are excluded: rush runs pnpm in common/temp, +/// which never reads the repo-root pnpm-workspace.yaml. The warning names +/// the host(s) the lock now points at (they follow --api-url). +fn pnpm_trust( + view: &ProjectView<'_>, + files: &BTreeMap, + rewrite: &RewriteResult, + overrides: &[DepOverride], + takeover_previews: &[TakeoverPreview], + options: &RewriteOptions<'_>, +) -> (Vec, ConfigWrite, bool, bool) { + let mut pnpm_warnings: Vec = Vec::new(); + let mut trust_config_write: ConfigWrite = None; + let mut pnpm_rerun_only = false; + let mut workspace_symlinked = false; + // pnpm locks spliced THIS run (any depth — the rewriter is + // basename-generalized). + let mut pnpm_lock_texts: Vec<&String> = rewrite + .files + .iter() + .filter(|(key, _)| { + std::path::Path::new(key) + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|name| matches!(name, "pnpm-lock.yaml" | "shrinkwrap.yaml")) + }) + .map(|(_, content)| content) + .collect(); + // HEAL-ON-RERUN: a root v9 lock that ALREADY carries a granted hosted + // artifact URL (spliced by an earlier run) still plans the trust config + // even though this run spliced nothing — so a project that missed the + // config once (opted-out first run, or a crash between the lock write + // and the workspace write) is healed by simply re-running the scan. An + // AlreadyTrue workspace keeps the re-run a byte-stable no-op. + let heal_root: Option<&String> = pnpm_heal_root( + rewrite.files.contains_key("pnpm-lock.yaml"), + files.get("pnpm-lock.yaml"), + overrides, + ); + let spliced_pnpm_locks = pnpm_lock_texts.len(); + if let Some(text) = heal_root { + pnpm_lock_texts.push(text); + } + // A dry-run vendored→hosted takeover of a purl vendored into the root + // pnpm lock: the wet run reverts that wiring and splices the hosted URL + // into it, so the trust config is previewed against the root lock (the + // vendored text carries the same lockfileVersion). + let takeover_pnpm_urls: Vec<&str> = takeover_previews + .iter() + .filter(|t| t.locks.iter().any(|l| l == "pnpm-lock.yaml")) + .map(|t| t.artifact_url.as_str()) + .collect(); + let takeover_root: Option<&String> = if takeover_pnpm_urls.is_empty() + || heal_root.is_some() + || rewrite.files.contains_key("pnpm-lock.yaml") + { + None + } else { + files.get("pnpm-lock.yaml") + }; + if let Some(text) = takeover_root { + pnpm_lock_texts.push(text); + } + if pnpm_lock_texts.is_empty() { + return ( + pnpm_warnings, + trust_config_write, + pnpm_rerun_only, + workspace_symlinked, + ); + } + // Name only the hosts whose artifact URL actually landed in a touched + // pnpm lock's final text (spliced this run, or the already-redirected + // heal root): an npm override may have matched only a sibling lock + // (e.g. package-lock.json), and naming its host here would point users + // at a server the pnpm lock never references. Same needles as the + // confirmation probe. + let npm_overrides: Vec<_> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); + let groups: Vec> = npm_overrides + .iter() + .map(|o| npm_lock_url_needles(&o.artifact_url)) + .collect(); + let present = groups_present(&pnpm_lock_texts, &groups); + let mut hosts: Vec<&str> = npm_overrides + .iter() + .zip(present) + .filter(|(_, present)| *present) + .filter_map(|(o, _)| url_host(&o.artifact_url)) + // Dry-run takeover purls land in the root lock on the wet run. + .chain(takeover_pnpm_urls.iter().filter_map(|url| url_host(url))) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + let server = if hosts.is_empty() { + "the hosted patch server".to_string() + } else { + format!("the hosted patch server ({})", hosts.join(", ")) + }; + // Root-lock gate: only the plain project lock at lockfileVersion >= 9 + // gets the auto-config — spliced this run, or detected + // already-redirected (heal path). + let root_lock_v9 = heal_root + .or(takeover_root) + .and_then(|text| pnpm_lock_version_major(text)) + .is_some_and(|major| major >= 9) + || rewrite + .files + .get("pnpm-lock.yaml") + .and_then(|text| pnpm_lock_version_major(text)) + .is_some_and(|major| major >= 9); + // Every touched pnpm lock is a KNOWN legacy (5.x/6.0) format, where + // `--trust-lockfile` is rejected as an unknown option. An unparseable + // version stays on the manual guidance: never claim "no trust step + // needed" for a lock whose era is unknown. + let all_locks_legacy = pnpm_lock_texts.iter().all(|text| { + pnpm_lock_version_major(text).is_some_and(|major| major < 9) + || text + .lines() + .any(|line| line.starts_with("shrinkwrapVersion:")) + }); + let detail = if all_locks_legacy { + pnpm_trust_legacy_detail(&server) + } else if !root_lock_v9 || !options.trust_lockfile_config { + pnpm_trust_manual_guidance(&server) + } else { + let (workspace, symlinked) = read_workspace(view); + workspace_symlinked = symlinked; + let trust_edit = |action: &str| FileEdit { + path: PNPM_WORKSPACE_REL.into(), + kind: REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND.into(), + action: action.into(), + key: Some("trustLockfile".into()), + original: None, + new: Some(serde_json::json!("true")), + }; + match workspace { + // Present but UNREADABLE: never plan a Create (it would + // overwrite the user's workspace file) — fall back to + // warning-only guidance naming the file and the error. + Err(e) => pnpm_trust_workspace_unreadable_detail(&server, &e), + Ok(ws_existing) => match plan_workspace_trust(ws_existing.as_deref()) { + TrustPlan::Create(text) => { + trust_config_write = Some((text, trust_edit("created"))); + pnpm_trust_configured_detail(&server, true, options.dry_run) + } + TrustPlan::Append(text) => { + trust_config_write = Some((text, trust_edit("added"))); + pnpm_trust_configured_detail(&server, false, options.dry_run) + } + TrustPlan::AlreadyTrue => { + pnpm_rerun_only = spliced_pnpm_locks == 0; + format!( + "{}, and {PNPM_WORKSPACE_REL} already carries `trustLockfile: \ + true` — keep it committed alongside the lock; installs need \ + no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ) + } + TrustPlan::UserSet(value) => format!( + "{}. {PNPM_WORKSPACE_REL} explicitly sets `trustLockfile: \ + {value}`, which was respected and left untouched — install \ + with `pnpm install --trust-lockfile`, or set `trustLockfile: \ + true` yourself so every install accepts the patched \ + artifacts. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(&server), + ), + }, + } + }; + // The `--store` spelling only matters to pnpm 1–4, so it is named only + // when a touched lock may be that old. + let store_note = if pnpm_lock_texts + .iter() + .any(|text| pnpm_lock_may_need_store_flag(text)) + { + " (pnpm 1–4 spell the option `--store`)" + } else { + "" + }; + pnpm_warnings.push(warning( + "redirect_pnpm_trust_lockfile", + format!( + "{}. After a lock-only change, existing node_modules or a warm pnpm store \ + can still contain upstream files. For a reliable reinstall, use a clean \ + node_modules tree and an empty store with \ + `pnpm install --frozen-lockfile --store-dir `\ + {store_note}. Do not rely on `--force`: some versions re-resolve the \ + upstream artifact. Run `socket-patch vex` after installation to verify \ + the patched files.", + detail.trim_end_matches('.') + ), + )); + ( + pnpm_warnings, + trust_config_write, + pnpm_rerun_only, + workspace_symlinked, + ) +} + +/// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every +/// tarball whose `resolved` origin is not the configured registry — which +/// is exactly what a hosted redirect writes. npm <= 11 installs it +/// unchanged; `allow-remote=all` in the project `.npmrc` makes npm 12 +/// install the patched bytes with the sha512 pins still enforced (`root` +/// only admits DIRECT dependencies, so it is not enough). +/// +/// ZERO-TOUCH DEFAULT (the npm twin of the pnpm trustLockfile +/// auto-config): whenever a root npm lock ends this run carrying a granted +/// hosted artifact URL (spliced now, or already redirected by an earlier +/// run — so a missed config heals on re-run), the run ensures +/// `allow-remote=all` in the project `.npmrc` — created when absent +/// (`action: "created"`), one line appended otherwise (`"added"`), every +/// other byte preserved — and records the edit +/// (`redirect_npmrc_allow_remote`). An explicit user +/// `allow-remote=` is RESPECTED (never flipped), an unreadable / +/// symlinked `.npmrc` is left alone, and `--no-npm-allow-remote-config` +/// opts out entirely; every variant still WARNS +/// (`redirect_npm_allow_remote`) with the whole-tree tradeoff. Vendored +/// mode is unaffected: its `file:.socket/vendor/…` specs are npm `file` +/// specs, gated by `allow-file` (default `all`), not `allow-remote`. +fn npm_allow_remote( + view: &ProjectView<'_>, + files: &BTreeMap, + rewrite: &RewriteResult, + overrides: &[DepOverride], + takeover_previews: &[TakeoverPreview], + options: &RewriteOptions<'_>, +) -> (Vec, ConfigWrite) { + let mut npm_warnings: Vec = Vec::new(); + let mut npmrc_config_write: ConfigWrite = None; + let npm_hosts: Vec<&str> = { + let npm_lock_texts: Vec<&String> = NPM_LOCKS + .iter() + .filter_map(|lock| rewrite.files.get(*lock).or_else(|| files.get(*lock))) + .collect(); + let npm_overrides: Vec<_> = overrides.iter().filter(|o| o.ecosystem == "npm").collect(); + let groups: Vec<[String; 2]> = npm_overrides + .iter() + .map(|o| artifact_url_spellings(&o.artifact_url)) + .collect(); + let present = groups_present(&npm_lock_texts, &groups); + let mut hosts: Vec<&str> = npm_overrides + .iter() + .zip(present) + .filter(|(_, present)| *present) + .filter_map(|(o, _)| url_host(&o.artifact_url)) + // A dry-run vendored→hosted takeover: the wet run reverts the + // vendored wiring in a root npm lock and splices the hosted URL + // there, so preview the `.npmrc` write too. + .chain( + takeover_previews + .iter() + .filter(|t| t.locks.iter().any(|l| NPM_LOCKS.contains(&l.as_str()))) + .filter_map(|t| url_host(&t.artifact_url)), + ) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts + }; + if npm_hosts.is_empty() { + return (npm_warnings, npmrc_config_write); + } + let edit = |action: &str| FileEdit { + path: NPMRC_REL.into(), + kind: NPMRC_ALLOW_REMOTE_EDIT_KIND.into(), + action: action.into(), + key: Some("allow-remote".into()), + original: None, + new: Some(serde_json::json!("all")), + }; + let detail = match read_npmrc(view) { + // Opt-out still reports an explicit / already-set value truthfully; + // only the WRITE is suppressed. + Ok(existing) => { + match plan_npmrc_allow_remote_with(existing.as_deref(), &(options.npm_outer)()) { + NpmrcPlan::AlreadyAll => npm_allow_remote_already_detail(&npm_hosts), + NpmrcPlan::UserSet(value) => npm_allow_remote_user_set_detail(&npm_hosts, &value), + NpmrcPlan::EnvSet { var, value } => { + npm_allow_remote_env_set_detail(&npm_hosts, &var, &value) + } + NpmrcPlan::OuterSet { layer, path, value } => { + npm_allow_remote_outer_set_detail(&npm_hosts, layer, &path, &value) + } + NpmrcPlan::Unsupported(why) => npm_allow_remote_unreadable_detail(&npm_hosts, &why), + _ if !options.npm_allow_remote_config => npm_allow_remote_manual_detail(&npm_hosts), + NpmrcPlan::Create(text) => { + npmrc_config_write = Some((text, edit("created"))); + npm_allow_remote_configured_detail(&npm_hosts, true, options.dry_run) + } + NpmrcPlan::Append(text) => { + npmrc_config_write = Some((text, edit("added"))); + npm_allow_remote_configured_detail(&npm_hosts, false, options.dry_run) + } + } + } + Err(why) => npm_allow_remote_unreadable_detail(&npm_hosts, &why), + }; + npm_warnings.push(warning("redirect_npm_allow_remote", detail)); + (npm_warnings, npmrc_config_write) +} + +/// A dep counts as REDIRECTED only if its hosted-artifact URL (or its +/// per-dependency registry index URL) actually landed in the project's +/// files — either written by this run or already present from an earlier +/// one. A granted reference whose rewriter found nothing to edit (e.g. no +/// lockfile) must NOT be recorded or attested: nothing pins the patch. +fn confirm( + files: &BTreeMap, + rewrite: &RewriteResult, + candidates: &[Candidate], + binary_bun: bool, + withheld_from_vlt: &BTreeSet, +) -> Vec<(String, String)> { + use crate::patch::redirect::pdm_drives; + // A `pdm.lock` that is NOT the PyPI install driver (a `uv.lock` or + // `poetry.lock` sits beside it) is never rewritten, yet can still carry + // a Socket artifact URL from an earlier run. That stale text pins + // nothing, so it must not feed the substring probe below. When pdm DOES + // drive, pypi confirmation keys off `confirmed_pdm_uuids`, so dropping + // the file is always safe. + let pdm_inactive = files.contains_key("pdm.lock") && !pdm_drives(files); + // Likewise a `vlt-lock.json` the vlt rewrite was withheld from (its + // artifact failed the preflight beside another npm-family lock) may + // still hold an earlier run's pin: only the sibling lock this run + // rewrote can confirm that dep. + let final_texts: Vec<(&str, &String)> = files + .iter() + .filter(|(name, _)| !(pdm_inactive && name.as_str() == "pdm.lock")) + .map(|(name, content)| (name.as_str(), rewrite.files.get(name).unwrap_or(content))) + .chain( + rewrite + .files + .iter() + .filter(|(name, _)| !files.contains_key(*name)) + .map(|(name, content)| (name.as_str(), content)), + ) + .collect(); + // Every non-substring rule decides a candidate outright; the rest are + // confirmed by substring presence of their needles in the final texts. + // All needle groups are answered in ONE multi-needle pass per text + // (`groups_present`), which is the per-candidate `any()` exactly — + // presence does not depend on search order, and `confirmed` keeps + // candidate order. + let steps: Vec = candidates + .iter() + .map(|c| { + let purl = c.purl.as_str(); + let uuid = c.dep.patch_uuid.as_str(); + // vlt decides before the binary-bun rule, so `bun.lockb` beside + // a vlt-driven `vlt-lock.json` never confirms an npm purl. + if rewrite.refused_vlt_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + if rewrite.vlt_drives && purl.starts_with("pkg:npm/") { + return ProbeStep::Decided(rewrite.confirmed_vlt_uuids.contains(uuid)); + } + if binary_bun && purl.starts_with("pkg:npm/") { + return ProbeStep::Decided(rewrite.confirmed_bun_binary_uuids.contains(uuid)); + } + if rewrite.refused_pipenv_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + // pdm is transactional like cargo: a refused uuid is never + // confirmed, and when `pdm.lock` is the PyPI install driver (no + // `uv.lock` / `poetry.lock`) a pypi dep is confirmed ONLY by the + // pdm rewriter's own report — the URL landing in a sibling + // `requirements.txt` the project does not install from pins + // nothing. When uv/poetry drive, their own lock proof below + // still confirms them. This check precedes the hatch gate: a + // PDM project may declare `hatchling` as its build backend, + // which registers every pypi uuid as hatch-owned while the + // lock's presence keeps hatch from confirming any of them. + if rewrite.refused_pdm_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + if purl.starts_with("pkg:pypi/") && pdm_drives(files) { + return ProbeStep::Decided(rewrite.confirmed_pdm_uuids.contains(uuid)); + } + if rewrite.python_lock_uuids.contains(uuid) { + return ProbeStep::Decided( + rewrite.confirmed_python_lock_uuids.contains(uuid) + && !rewrite.refused_python_lock_uuids.contains(uuid), + ); + } + if rewrite.hatch_uuids.contains(uuid) { + return ProbeStep::Decided(rewrite.confirmed_hatch_uuids.contains(uuid)); + } + // A Pipfile.lock rewrite confirms its own uuids (the sibling + // requirements.txt rewriter may have had nothing to do). + if purl.starts_with("pkg:pypi/") { + return ProbeStep::Decided( + rewrite.confirmed_pipenv_uuids.contains(uuid) + || rewrite.confirmed_requirements_uuids.contains(uuid), + ); + } + if rewrite.refused_pnpm_uuids.contains(uuid) { + return ProbeStep::Decided(false); + } + // Cargo is transactional: the rewriter reports exactly which + // patch uuids FULLY landed (manifest pin + lock + registry + // block). Substring presence must never confirm a cargo dep — + // the `[registries.…]` config block contains the index URL + // while pinning nothing, so a config-block-only rewrite would be + // attested with zero enforcement in any build. + if purl.starts_with("pkg:cargo/") { + return ProbeStep::Decided(rewrite.confirmed_cargo_uuids.contains(uuid)); + } + // Golang likewise: the goproxy `indexUrl` is the bare + // patch-server origin (present in any other hosted lock), and + // the socket module's go.sum lines outlive a removed replace. + if purl.starts_with("pkg:golang/") { + return ProbeStep::Decided(rewrite.confirmed_golang_uuids.contains(uuid)); + } + let needles = candidate_presence_needles(&c.dep); + if withheld_from_vlt.contains(uuid) { + ProbeStep::NeedlesOutsideVlt(needles) + } else { + ProbeStep::Needles(needles) + } + }) + .collect(); + let groups = |outside_vlt: bool| -> Vec<&[String]> { + steps + .iter() + .filter_map(|step| match step { + ProbeStep::Needles(needles) if !outside_vlt => Some(needles.as_slice()), + ProbeStep::NeedlesOutsideVlt(needles) if outside_vlt => Some(needles.as_slice()), + _ => None, + }) + .collect() + }; + let all_texts: Vec<&String> = final_texts.iter().map(|(_, text)| *text).collect(); + let mut present = groups_present(&all_texts, &groups(false)).into_iter(); + let outside_vlt_groups = groups(true); + let mut present_outside_vlt = if outside_vlt_groups.is_empty() { + Vec::new() + } else { + let texts: Vec<&String> = final_texts + .iter() + .filter(|(name, _)| *name != VLT_LOCK) + .map(|(_, text)| *text) + .collect(); + groups_present(&texts, &outside_vlt_groups) + } + .into_iter(); + candidates + .iter() + .zip(&steps) + .filter(|(_, step)| match step { + ProbeStep::Decided(keep) => *keep, + ProbeStep::Needles(_) => present + .next() + .expect("one presence answer per needle group"), + ProbeStep::NeedlesOutsideVlt(_) => present_outside_vlt + .next() + .expect("one presence answer per needle group"), + }) + .map(|(c, _)| (c.purl.clone(), c.dep.patch_uuid.clone())) + .collect() +} + +/// The ecosystem a candidate file's rewriter belongs to (`None` for files +/// no rewriter edits), for the in-memory symlinked/unreadable-read refusal. +fn file_ecosystem(rel: &str) -> Option<&'static str> { + if let Some(eco) = crate::formats::registry::hosted_file_ecosystem(rel) { + return Some(eco); + } + let base = rel.rsplit('/').next().unwrap_or(rel); + (crate::utils::python_lock::is_python_lock_name(base) || base.ends_with(".py")) + .then_some("pypi") +} + +/// SYMLINK GUARD — fail-closed, whole rewrite, before the ledger and before +/// any write (hosted rewrites are transactional). The writer stages next to +/// the path and renames over it, which REPLACES a symbolic link with a +/// detached regular copy: the link target goes stale and a revert restores +/// bytes but never the link. Applies to every ecosystem's files and to dry +/// runs, so a dry run predicts the refusal. +/// +/// In memory, additionally: a candidate file read through a link (its bytes +/// are unknown) or present without content, when a candidate of its +/// ecosystem could rewrite it. +pub fn guard( + view: &ProjectView<'_>, + done: &Rewritten, + candidates: &[Candidate], +) -> Option { + if done.workspace_symlinked { + return Some(symlink_refusal(PNPM_WORKSPACE_REL)); + } + let written = || { + done.rewrite + .files + .keys() + .chain(done.rewrite.binary_files.keys()) + }; + if let Some(linked) = written().find(|k| view.is_symlink(k)) { + return Some(symlink_refusal(linked)); + } + let ProjectView::Memory(project) = view else { + return None; + }; + let candidate_ecosystems: BTreeSet<&str> = candidates + .iter() + .map(|c| c.dep.ecosystem.as_str()) + .collect(); + if let Some(linked) = done + .symlinked_reads + .iter() + .find(|rel| file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco))) + { + return Some(symlink_refusal(linked)); + } + done.unreadable_reads + .iter() + .find(|rel| { + done.rewrite.files.contains_key(rel.as_str()) + || file_ecosystem(rel).is_some_and(|eco| candidate_ecosystems.contains(eco)) + }) + .or_else(|| { + done.rewrite + .files + .keys() + .find(|k| matches!(project.get(k), Some(MemoryEntry::Present))) + }) + .map(|rel| unreadable_refusal(rel)) +} + +/// The `record_fetch_failed` warning for a confirmed redirect whose patch +/// record could not be fetched. +pub fn record_fetch_failed_warning(purl: &str) -> RewriteWarning { + warning( + "record_fetch_failed", + format!( + "{purl} was switched to hosted, but its patch record could not be \ + fetched; this run's VEX attestation omits it (`socket-patch vex` \ + fetches it again once the API answers)" + ), + ) +} + +/// A `{code, detail}` warning. +pub fn warning(code: &str, detail: impl Into) -> RewriteWarning { + RewriteWarning { + code: code.to_string(), + detail: detail.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vendor::lock_inventory::MemoryProject; + + fn reference(value: serde_json::Value) -> PackageVendorResult { + serde_json::from_value(value).unwrap() + } + + #[test] + fn candidates_skip_every_unusable_reference() { + let mut refs: HashMap = HashMap::new(); + refs.insert( + "u-pending".into(), + reference(serde_json::json!({"status": "pending_build"})), + ); + refs.insert( + "u-nourl".into(), + reference(serde_json::json!({"status": "granted", "purl": "pkg:npm/b@1"})), + ); + refs.insert( + "u-ok".into(), + reference(serde_json::json!({ + "status": "reused", + "url": "https://patch.example/patch/npm/c/1/tok/u-ok/c-1.tgz", + "purl": "pkg:npm/c@1", + "artifacts": [{"kind": "tarball", "url": null, "integrity": {"sha512": "sha512-x"}}], + "registryOverride": null + })), + ); + let selected = vec![ + ("pkg:npm/a@1".to_string(), "u-missing".to_string()), + ("pkg:npm/p@1".to_string(), "u-pending".to_string()), + ("pkg:npm/b@1".to_string(), "u-nourl".to_string()), + ("pkg:npm/c@1".to_string(), "u-ok".to_string()), + ]; + let mut skipped = Vec::new(); + let candidates = build_candidates(&selected, &refs, &mut skipped); + let reasons: Vec<&str> = skipped.iter().map(|s| s.reason.as_str()).collect(); + assert_eq!(reasons, vec!["not_found", "pending_build", "no_url"]); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].dep.token, "tok"); + assert_eq!( + candidates[0].dep.integrity.sha512.as_deref(), + Some("sha512-x") + ); + } + + fn cargo_reference( + uuid: &str, + ) -> (Vec<(String, String)>, HashMap) { + let purl = "pkg:cargo/serde@1.0.190"; + let mut refs = HashMap::new(); + refs.insert( + uuid.to_string(), + reference(serde_json::json!({ + "status": "granted", + "url": format!("https://patch.example/patch/cargo/serde/1.0.190/tok/{uuid}/serde-1.0.190.crate"), + "purl": purl, + "artifacts": [{"kind": "tarball", "url": null, "integrity": {"sha256": "ab"}}], + "registryOverride": null + })), + ); + (vec![(purl.to_string(), uuid.to_string())], refs) + } + + #[tokio::test] + async fn an_unreadable_candidate_file_refuses_its_ecosystem() { + let (selected, refs) = cargo_reference("u-1"); + let mut p = MemoryProject::new(); + p.insert_text("Cargo.toml", "[dependencies]\nserde = \"1\"\n"); + p.insert_text( + "Cargo.lock", + "version = 3\n\n[[package]]\nname = \"serde\"\nversion = \"1.0.190\"\n\ + source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + ); + p.insert_present(".cargo/config"); + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + blocking: false, + }; + let mut skipped = Vec::new(); + let candidates = build_candidates(&selected, &refs, &mut skipped); + let view = ProjectView::Memory(&p); + let unreadable = BTreeSet::from([".cargo/config".to_string()]); + let read = read_candidate_files(&view, &unreadable, &candidates).await; + assert_eq!(read.unreadable_reads, vec![".cargo/config"]); + let done = rewrite( + &view, + read, + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + &[], + options, + ) + .await; + assert_eq!( + guard(&view, &done, &candidates).unwrap().code, + UNREADABLE_REFUSAL + ); + + // A non-UTF-8 file is absent to disk too: not a refusal. + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + assert!(read.unreadable_reads.is_empty()); + } + + #[test] + fn file_ecosystems_cover_the_rewrite_targets() { + assert_eq!(file_ecosystem("package-lock.json"), Some("npm")); + assert_eq!( + file_ecosystem("common/config/rush/pnpm-lock.yaml"), + Some("npm") + ); + assert_eq!(file_ecosystem("tool.py.lock"), Some("pypi")); + assert_eq!(file_ecosystem("crates/a/Cargo.toml"), Some("cargo")); + assert_eq!(file_ecosystem("build.gradle"), None); + } +} + +/// The one-pass multi-needle confirmation probe answers exactly what the +/// per-candidate `any()` oracle answers. +#[cfg(test)] +mod probe_equivalence_tests { + use super::candidate_presence_needles; + use crate::hosted::guidance::npm_lock_url_needles; + use crate::patch::redirect::presence::groups_present; + use crate::patch::redirect::{ + artifact_url_present, artifact_url_spellings, rewrite_registry_redirect, DepOverride, + }; + use crate::utils::uri::encode_uri_component; + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + /// The per-candidate probe [`candidate_presence_needles`] + + /// `groups_present` replaced, kept as the equivalence oracle. + fn candidate_present_oracle(final_texts: &[&String], dep: &DepOverride) -> bool { + let artifact_url = dep.artifact_url.as_str(); + let registry = dep.registry_override.as_ref(); + let index_url = registry.map(|o| o.index_url.as_str()); + let suffixed_version = + registry.and_then(|o| o.identifiers.maven_suffixed_version.as_deref()); + let encoded = encode_uri_component(artifact_url); + final_texts.iter().any(|text| { + artifact_url_present(text, artifact_url) + || text.contains(encoded.as_str()) + || index_url.is_some_and(|iu| text.contains(iu)) + || suffixed_version.is_some_and(|sv| text.contains(sv)) + }) + } + + fn golden_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/redirect") + } + + fn cases(dir: &Path, out: &mut Vec) { + if dir.join("input").is_dir() && dir.join("overrides.json").is_file() { + out.push(dir.to_path_buf()); + return; + } + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + cases(&p, out); + } + } + } + + fn read_tree(base: &Path) -> BTreeMap { + fn walk(base: &Path, dir: &Path, out: &mut BTreeMap) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + walk(base, &p, out); + } else if let Ok(text) = std::fs::read_to_string(&p) { + let rel = p.strip_prefix(base).unwrap().to_string_lossy(); + out.insert(rel.replace('\\', "/"), text); + } + } + } + let mut out = BTreeMap::new(); + if base.is_dir() { + walk(base, base, &mut out); + } + out + } + + /// Every golden fixture (composer `\/`, berry percent-encoded, maven + /// suffixed version, go module path, cargo index url, …): each case's + /// final texts — input overlaid with the rewriters' own output, as the probe + /// sees them — plus its authored `expected/` files, probed with EVERY + /// fixture's overrides so hits and misses are both well exercised. + #[test] + fn multi_needle_probe_matches_per_candidate_any_on_golden_fixtures() { + let mut dirs = Vec::new(); + cases(&golden_root(), &mut dirs); + dirs.sort(); + assert!(dirs.len() > 50, "golden fixtures not found: {}", dirs.len()); + + let mut all_overrides: Vec = Vec::new(); + let mut text_sets: Vec> = Vec::new(); + for case in &dirs { + let overrides: Vec = match serde_json::from_str( + &std::fs::read_to_string(case.join("overrides.json")).unwrap(), + ) { + Ok(o) => o, + Err(_) => continue, + }; + let input = read_tree(&case.join("input")); + let rewrite = rewrite_registry_redirect(&input, &overrides); + let finals: Vec = input + .iter() + .map(|(name, text)| rewrite.files.get(name).unwrap_or(text).clone()) + .chain( + rewrite + .files + .iter() + .filter(|(name, _)| !input.contains_key(*name)) + .map(|(_, t)| t.clone()), + ) + .collect(); + text_sets.push(finals); + text_sets.push(read_tree(&case.join("expected")).into_values().collect()); + text_sets.push(input.into_values().collect()); + all_overrides.extend(overrides); + } + text_sets.push(Vec::new()); + // Texts that carry ONE needle kind and nothing else — no golden + // fixture has the maven suffixed version or the registry index URL + // without the artifact URL beside it, so a needle dropped from + // `candidate_presence_needles` would otherwise go unnoticed. + let mut lone_suffixed: Vec = Vec::new(); + for o in all_overrides + .iter() + .filter_map(|d| d.registry_override.as_ref()) + { + text_sets.push(vec![format!("{}\n", o.index_url)]); + if let Some(sv) = o.identifiers.maven_suffixed_version.as_deref() { + lone_suffixed.push(text_sets.len()); + text_sets.push(vec![format!("{sv}\n")]); + } + } + assert!( + !lone_suffixed.is_empty(), + "no maven suffixed-version override" + ); + + let groups: Vec> = all_overrides + .iter() + .map(candidate_presence_needles) + .collect(); + let (mut hits, mut misses) = (0usize, 0usize); + for (set, texts) in text_sets.iter().enumerate() { + let refs: Vec<&String> = texts.iter().collect(); + let fast = groups_present(&refs, &groups); + if lone_suffixed.contains(&set) { + assert!( + fast.iter().any(|hit| *hit), + "a lone suffixed version confirms its maven override" + ); + } + for (dep, got) in all_overrides.iter().zip(&fast) { + let want = candidate_present_oracle(&refs, dep); + assert_eq!( + *got, want, + "{}/{} / {}", + dep.ecosystem, dep.name, dep.artifact_url + ); + if want { + hits += 1; + } else { + misses += 1; + } + } + } + assert!(hits > 100 && misses > 100, "hits={hits} misses={misses}"); + + // The pnpm / npm host filters and the heal probe: the npm lock + // spellings, and the bare `artifact_url_present` pair. + let npm: Vec<&DepOverride> = all_overrides.iter().collect(); + let lock_groups: Vec> = npm + .iter() + .map(|o| npm_lock_url_needles(&o.artifact_url)) + .collect(); + let pair_groups: Vec<[String; 2]> = npm + .iter() + .map(|o| artifact_url_spellings(&o.artifact_url)) + .collect(); + for texts in &text_sets { + let lock_fast = groups_present(texts, &lock_groups); + let pair_fast = groups_present(texts, &pair_groups); + for (i, o) in npm.iter().enumerate() { + let encoded = encode_uri_component(&o.artifact_url); + let pair = texts + .iter() + .any(|t| artifact_url_present(t, &o.artifact_url)); + let lock = texts.iter().any(|t| { + artifact_url_present(t, &o.artifact_url) || t.contains(encoded.as_str()) + }); + assert_eq!(pair_fast[i], pair, "{}", o.artifact_url); + assert_eq!(lock_fast[i], lock, "{}", o.artifact_url); + } + } + } +} diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs new file mode 100644 index 00000000..8473ff64 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -0,0 +1,404 @@ +//! The install-policy guidance of the hosted flow: the pnpm `trustLockfile` +//! and npm `allow-remote` auto-config planners and every warning text they +//! emit, shared verbatim by the disk and in-memory engines. + +/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, NEVER userinfo. +/// For user-facing messages that name where a lockfile now points — the +/// hosted artifact host follows `--api-url`, so hardcoding `patch.socket.dev` +/// would misname it in custom-server environments. The port is kept (it is +/// part of the authority the lock records); credentials are stripped: a +/// credentialed artifact URL (`https://user:secret@host/…`) must never leak +/// `user:secret` into the warning text or the persisted `--json` envelope — +/// both land in CI logs. Split by hand because this crate has no URL-parser +/// dependency (reqwest is dev-only here); per RFC 3986 a raw `@` in the +/// authority can ONLY be the userinfo terminator (it is percent-encoded +/// everywhere else), so the tail after the LAST `@` is exactly host[:port]. +pub fn url_host(url: &str) -> Option<&str> { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + (!host.is_empty()).then_some(host) +} + +/// Repo-relative path of the pnpm workspace manifest the trustLockfile +/// auto-config edits (the same file the vendor backend's override surface +/// uses). +pub const PNPM_WORKSPACE_REL: &str = "pnpm-workspace.yaml"; + +/// `FileEdit.kind` recorded when the hosted flow ensures `trustLockfile: +/// true` in pnpm-workspace.yaml. `action: "created"` — the workspace file +/// itself was created (a revert deletes it); `action: "added"` — the single +/// `trustLockfile: true` line was appended to an existing file (a revert +/// removes exactly that line). Additive ledger vocabulary: older ledgers +/// without it load unchanged (kind is an opaque string to the loader). +pub const REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND: &str = "redirect_pnpm_workspace_trust"; + +/// The honest-tradeoff + don't-rebuild tail shared by every trustLockfile +/// warning variant. The tradeoff sentence is a security disclosure, not +/// prose garnish: `trustLockfile: true` disables pnpm's lockfile +/// re-verification for the WHOLE lock, so it must be stated wherever the +/// setting is written or recommended. +pub const PNPM_TRUST_TRADEOFF_AND_CAUTION: &str = + "Note: trustLockfile makes pnpm skip its lockfile re-verification \ + (minimumReleaseAge / trustPolicy re-checks) for ALL lockfile entries, \ + not just the patched ones — the per-entry sha512 integrity pins are \ + still enforced. Do NOT follow pnpm's advice to rebuild the lockfile \ + (`pnpm clean --lockfile`): that silently discards the hosted patches and \ + reinstalls the vulnerable upstream artifact. pnpm <=10 ignores the \ + setting and installs work unchanged"; + +/// The policy preamble shared by every trustLockfile warning variant: +/// what was repointed, and how pnpm >=11 fails without trust. +pub fn pnpm_trust_policy_preamble(server: &str) -> String { + format!( + "pnpm-lock.yaml was repointed at {server}; pnpm >=11 rejects the \ + rewritten lock (pnpm 11: ERR_PNPM_TARBALL_URL_MISMATCH, pnpm 12: \ + ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION)" + ) +} + +/// The pre-auto-config guidance, kept verbatim for the runs where the +/// auto-config does not apply (legacy 5.x/6.0 locks, Rush nested locks, +/// `--no-trust-lockfile-config`): both verified recoveries, spelled exactly. +pub fn pnpm_trust_manual_guidance(server: &str) -> String { + format!( + "{}. Install with `pnpm install --trust-lockfile`, or commit \ + `trustLockfile: true` in pnpm-workspace.yaml so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the hosted patches and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// The LEGACY-lock variant (lockfileVersion 5.x/6.0 — pnpm 7/8): those +/// majors have neither the pnpm >=11 lockfile trust policy nor any trust +/// flag or setting, so installs consume the redirected lock unchanged and +/// no trust step exists or is needed. Deliberately NEVER mentions +/// `pnpm install --trust-lockfile`: pnpm 7/8 reject the flag as an unknown +/// option, so headlining it here would hand users a command that errors. +pub fn pnpm_trust_legacy_detail(server: &str) -> String { + format!( + "The pnpm lockfile was repointed at {server}. This is a legacy \ + lock read by pnpm 1–8, which have no \ + lockfile trust policy: no trust step exists or is needed. Do NOT regenerate the lockfile \ + (deleting it, or re-resolving on a newer pnpm): that silently \ + discards the hosted patches and reinstalls the vulnerable upstream \ + artifact. If the project later moves to pnpm >=9, re-run \ + `socket-patch scan --mode hosted` so the regenerated lock is \ + switched to hosted (and trust-configured) again" + ) +} + +/// The unreadable-workspace fallback: pnpm-workspace.yaml EXISTS but could +/// not be read (permissions, invalid UTF-8, I/O error). Planning a Create +/// here would OVERWRITE the user's file with the root-only scaffold — +/// destroying their `packages:` globs — so the auto-config stands down and +/// the warning names the file, the error, and both manual recoveries. +pub fn pnpm_trust_workspace_unreadable_detail(server: &str, err: &std::io::Error) -> String { + format!( + "{}. {PNPM_WORKSPACE_REL} exists but could not be read ({err}); it \ + was left untouched — auto-configuring trust would risk overwriting \ + it. Fix the file, then install with `pnpm install --trust-lockfile` \ + or add `trustLockfile: true` to it yourself so every install \ + accepts the patched artifacts. Do NOT follow pnpm's advice to \ + rebuild the lockfile (`pnpm clean --lockfile`): that silently \ + discards the hosted patches and reinstalls the vulnerable upstream \ + artifact. pnpm <=10 installs work unchanged", + pnpm_trust_policy_preamble(server), + ) +} + +/// The pnpm-workspace.yaml read, classified for the trust auto-config: +/// `Ok(Some(text))` — read fine; `Ok(None)` — ABSENT (`ErrorKind::NotFound`, +/// the only state where planning a Create is safe); `Err(e)` — present but +/// unreadable, so the caller must fall back to warning-only guidance +/// (planning a Create would overwrite the user's `packages:` globs). +/// +/// FIFO-safe (`read_regular_to_string_sync`: non-blocking open + fstat): a +/// FIFO planted at the path classifies as unreadable (`InvalidInput`) instead +/// of wedging the run in `open(2)`. +pub fn read_workspace_for_trust(path: &std::path::Path) -> std::io::Result> { + match crate::utils::fs::read_regular_to_string_sync(path) { + Ok(text) => Ok(Some(text)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +/// HEAL-ON-RERUN probe: does this (unspliced) root pnpm-lock.yaml already +/// carry a granted hosted artifact URL from an EARLIER run? Same spelling +/// set as the confirmation probe (raw / `\/`-escaped via +/// `artifact_url_present`, plus the percent-encoded form) so a writer's +/// spelling can never be one this probe misses. Lets an idempotent re-scan +/// plan the trust config for a project that missed it once (opted-out first +/// run, or a crash between the lock write and the workspace write). +pub fn pnpm_lock_carries_hosted_redirect( + lock_text: &str, + overrides: &[crate::patch::redirect::DepOverride], +) -> bool { + let groups: Vec> = overrides + .iter() + .filter(|o| o.ecosystem == "npm") + .map(|o| npm_lock_url_needles(&o.artifact_url)) + .collect(); + crate::patch::redirect::presence::groups_present(&[lock_text], &groups) + .into_iter() + .any(|present| present) +} + +/// The spellings of an npm artifact URL a pnpm lock may carry — raw or +/// `\/`-escaped ([`artifact_url_spellings`](crate::patch::redirect::artifact_url_spellings), +/// the `artifact_url_present` pair) plus the percent-encoded form — searched +/// in one multi-needle pass ([`groups_present`](crate::patch::redirect::presence::groups_present)). +pub fn npm_lock_url_needles(artifact_url: &str) -> Vec { + let mut needles: Vec = + crate::patch::redirect::artifact_url_spellings(artifact_url).into(); + needles.push(crate::utils::uri::encode_uri_component( + artifact_url, + )); + needles +} + +/// The HEAL-ON-RERUN gate: when this run spliced no root pnpm-lock.yaml +/// (`root_spliced` false) but the on-disk root lock is v9 and already +/// carries a granted hosted artifact URL, return its text so the trust +/// block engages anyway. Legacy (<9) and unparseable-version locks stay +/// `None` (fail closed: never write config for a lock era we can't read), +/// as does a root lock this run DID splice (the splice path covers it). +pub fn pnpm_heal_root<'a>( + root_spliced: bool, + disk_root: Option<&'a String>, + overrides: &[crate::patch::redirect::DepOverride], +) -> Option<&'a String> { + if root_spliced { + return None; + } + disk_root.filter(|text| { + pnpm_lock_version_major(text).is_some_and(|major| major >= 9) + && pnpm_lock_carries_hosted_redirect(text, overrides) + }) +} + +/// The auto-config variant: trust was (or, on `--dry-run`, would be) +/// configured in pnpm-workspace.yaml, so installs need no flags. +pub fn pnpm_trust_configured_detail(server: &str, created: bool, dry_run: bool) -> String { + let how = match (created, dry_run) { + (true, false) => "`trustLockfile: true` was written to a new", + (false, false) => "`trustLockfile: true` was merged into the existing", + (true, true) => "`trustLockfile: true` would be written to a new", + (false, true) => "`trustLockfile: true` would be merged into the existing", + }; + format!( + "{}, so {how} {PNPM_WORKSPACE_REL} — commit it alongside the lock; \ + installs need no extra flags. {PNPM_TRUST_TRADEOFF_AND_CAUTION}", + pnpm_trust_policy_preamble(server), + ) +} + +// The pnpm lock-version sniffs live with the format's model. +pub use crate::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 enum TrustPlan { + /// No workspace file: create it (root-only `packages` scaffold — pnpm 9 + /// refuses a workspace file with no `packages` field — plus the trust + /// key; the same scaffold shape the vendor backend creates). + Create(String), + /// Workspace file exists without a `trustLockfile:` key: append exactly + /// one line after the last non-empty line, every other byte preserved. + Append(String), + /// Already `trustLockfile: true` — nothing to write. + AlreadyTrue, + /// The user explicitly set `trustLockfile: ` (non-true). Their + /// call is respected — flipping an explicit security setting behind the + /// user's back is worse than a failing install with a clear warning. + UserSet(String), +} + +/// Decide how to ensure `trustLockfile: true` in pnpm-workspace.yaml. +/// Line splices only (never a YAML library), mirroring the vendor backend's +/// workspace surgery: untouched lines stay byte-identical, so a revert can +/// remove exactly what was added. +pub fn plan_workspace_trust(existing: Option<&str>) -> TrustPlan { + let Some(text) = existing else { + return TrustPlan::Create("packages:\n - '.'\ntrustLockfile: true\n".to_string()); + }; + // Top-level key only: an indented `trustLockfile:` under some other + // mapping is not the setting pnpm reads. + for line in text.split('\n') { + if let Some(rest) = line.strip_prefix("trustLockfile:") { + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + if value == "true" { + return TrustPlan::AlreadyTrue; + } + return TrustPlan::UserSet(value.to_string()); + } + } + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + // After the last non-empty line (no blank separator): a revert removes + // exactly one line and the file's trailing bytes stay put. + let anchor = lines + .iter() + .rposition(|l| !l.trim().is_empty()) + .map(|i| i + 1) + .unwrap_or(lines.len()); + lines.insert(anchor, "trustLockfile: true".to_string()); + TrustPlan::Append(lines.join("\n")) +} + +/// The root npm locks the hosted rewriter edits (`rewrite_npm_lock` rewrites +/// every one present — npm 12 installs from package-lock.json beside a +/// committed shrinkwrap). +pub const NPM_LOCKS: [&str; 2] = ["npm-shrinkwrap.json", "package-lock.json"]; + +/// The honest-tradeoff + opt-out tail shared by every `allow-remote` +/// warning variant. The tradeoff sentence is a security disclosure, not +/// prose garnish: `allow-remote=all` lifts npm 12's remote-tarball refusal +/// for the WHOLE dependency tree, so it must be stated wherever the setting +/// is written or recommended (the pnpm `trustLockfile` precedent). +const NPM_ALLOW_REMOTE_TRADEOFF: &str = + "Note: allow-remote=all lets npm install ANY url-resolved (remote tarball) \ + dependency, not just the patched ones Socket serves — the per-entry sha512 \ + integrity pins are still enforced. `allow-remote=root` only admits direct \ + dependencies. npm <=11 installs work unchanged (npm 11 already defaults to \ + `all`; npm <=10 has no such setting)"; + +/// The policy preamble shared by every `allow-remote` warning variant: what +/// was repointed, and how npm >= 12 fails without the setting. +fn npm_allow_remote_preamble(hosts: &[&str]) -> String { + format!( + "the npm lockfile now resolves patched dependencies from the hosted patch server ({}); \ + npm >=12 refuses tarballs from any host other than the configured registry by \ + default (`allow-remote=none`, error EALLOWREMOTE)", + hosts.join(", ") + ) +} + +/// The auto-config variant: `allow-remote=all` was (or, on `--dry-run`, +/// would be) written to the project `.npmrc`, so installs need no flags. +pub fn npm_allow_remote_configured_detail( + hosts: &[&str], + created: bool, + dry_run: bool, +) -> String { + let how = match (created, dry_run) { + (true, false) => "`allow-remote=all` was written to a new", + (false, false) => "`allow-remote=all` was appended to the existing", + (true, true) => "`allow-remote=all` would be written to a new", + (false, true) => "`allow-remote=all` would be appended to the existing", + }; + format!( + "{}, so {how} project .npmrc — commit it alongside the lock; `npm ci` needs no \ + extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}. To keep npm's default instead, re-run \ + with --no-npm-allow-remote-config (SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG) and install \ + with `npm ci --allow-remote=all`", + npm_allow_remote_preamble(hosts), + ) +} + +/// The project `.npmrc` already resolves to `allow-remote=all`. +pub fn npm_allow_remote_already_detail(hosts: &[&str]) -> String { + format!( + "{}, and the project .npmrc already sets `allow-remote=all` — keep it committed \ + alongside the lock; `npm ci` needs no extra flags. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The user explicitly set another value: respected, never flipped (the +/// pnpm `trustLockfile: false` precedent) — the warning names the manual +/// recoveries instead. +pub fn npm_allow_remote_user_set_detail(hosts: &[&str], value: &str) -> String { + format!( + "{}. The project .npmrc explicitly sets `allow-remote={value}`, which was respected \ + and left untouched — set `allow-remote=all` there yourself (or install with \ + `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// An `npm_config_allow_remote` environment variable sets another value. +/// npm's env layer beats every `.npmrc`, so a project write could not take +/// effect in this environment — and an explicit setting is respected. +pub fn npm_allow_remote_env_set_detail(hosts: &[&str], var: &str, value: &str) -> String { + format!( + "{}. The environment variable {var}={value} explicitly sets `allow-remote`, which \ + was respected: npm's environment layer overrides every .npmrc, so a project \ + `allow-remote=all` would not take effect here and the project .npmrc was left \ + untouched — unset {var} (or install with `npm ci --allow-remote=all`) so npm >=12 \ + installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// A lower npm config layer (user / global / builtin file) explicitly sets +/// another value. A committed project `allow-remote=all` would silently +/// override that machine / org policy on every checkout, so it is +/// respected like a project value and the override is left to the user. +pub fn npm_allow_remote_outer_set_detail( + hosts: &[&str], + layer: &str, + path: &std::path::Path, + value: &str, +) -> String { + format!( + "{}. The {layer} npm config ({}) explicitly sets `allow-remote={value}`, which was \ + respected: socket-patch does not commit a project .npmrc that overrides it, and \ + the project .npmrc was left untouched — to accept the patched artifacts in this \ + project anyway, set `allow-remote=all` in the project .npmrc yourself (it outranks \ + the {layer} config) or install with `npm ci --allow-remote=all`. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + path.display(), + ) +} + +/// The opt-out (`--no-npm-allow-remote-config`) variant: nothing written, +/// both manual recoveries spelled out. +pub fn npm_allow_remote_manual_detail(hosts: &[&str]) -> String { + format!( + "{}. Commit `allow-remote=all` in the project .npmrc (or install with \ + `npm ci --allow-remote=all`) so npm >=12 installs the patched artifacts. \ + {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The unreadable/unsafe `.npmrc` fallback: the file exists but could not +/// be read, or is a symlink / non-regular file the atomic writer would +/// replace. Planning a Create here would OVERWRITE the user's registry / +/// auth config, so the auto-config stands down and names the problem. +pub fn npm_allow_remote_unreadable_detail(hosts: &[&str], why: &str) -> String { + format!( + "{}. The project .npmrc exists but {why}; it was left untouched. Add \ + `allow-remote=all` to it yourself (or install with `npm ci --allow-remote=all`) \ + so npm >=12 installs the patched artifacts. {NPM_ALLOW_REMOTE_TRADEOFF}", + npm_allow_remote_preamble(hosts), + ) +} + +/// The project `.npmrc` read, classified for the allow-remote auto-config: +/// `Ok(Some(text))` — a regular file read fine; `Ok(None)` — ABSENT (the +/// only state where planning a Create is safe); `Err(why)` — present but +/// unreadable, a symlink (the atomic stage+rename writer would replace the +/// link with a detached copy — and the whole-run symlink guard would refuse +/// the redirect), or not a regular file (FIFO-safe: never opened blocking). +pub fn read_npmrc_for_allow_remote(path: &std::path::Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("could not be inspected ({e})")), + Ok(meta) if meta.file_type().is_symlink() => { + return Err("is a symbolic link (socket-patch never writes through one)".into()) + } + Ok(_) => {} + } + crate::utils::fs::read_regular_to_string_sync(path) + .map(Some) + .map_err(|e| format!("could not be read ({e})")) +} diff --git a/crates/socket-patch-cli/src/hosted_memory/discover.rs b/crates/socket-patch-core/src/hosted/memory/discover.rs similarity index 97% rename from crates/socket-patch-cli/src/hosted_memory/discover.rs rename to crates/socket-patch-core/src/hosted/memory/discover.rs index 43572ece..1a7758c8 100644 --- a/crates/socket-patch-cli/src/hosted_memory/discover.rs +++ b/crates/socket-patch-core/src/hosted/memory/discover.rs @@ -12,12 +12,12 @@ use std::sync::{Arc, Mutex}; use std::task::Poll; use std::time::Duration; -use socket_patch_core::api::client::{ApiError, ApiFuture, PatchApi}; -use socket_patch_core::api::ranking::cmp_search_results; -use socket_patch_core::api::types::{ +use crate::api::client::{ApiError, ApiFuture, PatchApi}; +use crate::api::ranking::cmp_search_results; +use crate::api::types::{ BatchPackagePatches, PackageVendorResult, PatchResponse, PatchSearchResult, SearchResponse, }; -use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; +use crate::utils::purl::{normalize_purl, strip_purl_qualifiers}; use super::types::MAX_REFERENCE_BATCH; @@ -102,11 +102,11 @@ impl Provider { pub(crate) async fn search_patches_batch( &self, purls: &[String], - ) -> Result { + ) -> Result { let mut response = self .call("searchPatchesBatch", self.api.search_patches_batch(purls)) .await?; - socket_patch_core::api::client::sort_batch_response(&mut response); + crate::api::client::sort_batch_response(&mut response); Ok(response) } @@ -355,7 +355,7 @@ pub(crate) async fn fetch_wheel_metadata( .download_artifact(url, max_bytes) .await .map_err(|error| format!("cannot fetch hosted wheel metadata: {error}"))?; - socket_patch_core::vendor::pypi::decode_hosted_wheel_metadata(&bytes, sha256) + crate::vendor::pypi::decode_hosted_wheel_metadata(&bytes, sha256) }) }, ) diff --git a/crates/socket-patch-cli/src/hosted_memory/limits.rs b/crates/socket-patch-core/src/hosted/memory/limits.rs similarity index 99% rename from crates/socket-patch-cli/src/hosted_memory/limits.rs rename to crates/socket-patch-core/src/hosted/memory/limits.rs index 4bcdb635..f84dbbed 100644 --- a/crates/socket-patch-cli/src/hosted_memory/limits.rs +++ b/crates/socket-patch-core/src/hosted/memory/limits.rs @@ -295,7 +295,7 @@ impl SessionBuilder { #[cfg(test)] mod tests { use super::*; - use crate::hosted_memory::types::HostedScanLimits; + use crate::hosted::memory::types::HostedScanLimits; fn options(limits: HostedScanLimits) -> HostedScanOptions { HostedScanOptions { diff --git a/crates/socket-patch-cli/src/hosted_memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs similarity index 92% rename from crates/socket-patch-cli/src/hosted_memory/mod.rs rename to crates/socket-patch-core/src/hosted/memory/mod.rs index bd6b4a7d..cc760463 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -38,20 +38,20 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::Arc; use std::time::Instant; -use socket_patch_core::api::client::PatchApi; -use socket_patch_core::api::types::{PatchResponse, PatchSearchResult}; -use socket_patch_core::crawlers::Ecosystem; -use socket_patch_core::utils::cargo_workspace::member_manifests_in; -use socket_patch_core::vendor::lock_inventory::{ +use crate::api::client::PatchApi; +use crate::api::types::{PatchResponse, PatchSearchResult}; +use crate::crawlers::Ecosystem; +use crate::utils::cargo_workspace::member_manifests_in; +use crate::vendor::lock_inventory::{ inventory_project_diagnosed_in, MemoryEntry, MemoryProject, ProjectView, }; use tokio_util::sync::CancellationToken; pub(crate) mod discover; pub mod limits; -pub(crate) mod redirect; pub(crate) mod roots; pub mod select; +pub(crate) mod stages; pub mod types; pub use limits::SessionBuilder; @@ -59,7 +59,7 @@ pub use select::{candidate_files, safe_repo_path, select_paths}; pub use types::*; use discover::Provider; -use redirect::{Planned, Refused, Rewritten, StageOptions}; +use stages::{Planned, RewriteRefused, Rewritten, StageOptions}; /// `"+"`; the sha comes from the /// `SOCKET_PATCH_GIT_SHA` build-time variable. @@ -110,7 +110,7 @@ struct RootState { unreadable: BTreeSet, purls: Vec, summary: ProjectSummary, - packages: Vec, + packages: Vec, selected: Vec<(String, String)>, skipped: Vec, error: Option, @@ -421,7 +421,9 @@ async fn engine( }; let (entries, unsupported) = inventory_project_diagnosed_in(&ProjectView::Memory(project)).await; - for (code, detail) in crate::commands::scan::unsupported_layout_warnings(&unsupported) { + for (code, detail) in + crate::vendor::lock_inventory::unsupported_layout_warnings(&unsupported) + { warnings.push(EngineWarning::new(code, detail, Some(&state.root))); } unsupported_ecosystem_warnings(&state.root, project, ecosystems, &mut warnings); @@ -572,9 +574,9 @@ async fn engine( continue; }; let unreadable = std::mem::take(&mut state.unreadable); - match redirect::plan(project, unreadable, &state.selected, &references) { + match stages::plan(project, unreadable, &state.selected, &references).await { Ok(plan) => planned.push((index, plan)), - Err(Refused { error }) => state.error = Some(error), + Err(refusal) => state.error = Some(ProjectError::from(refusal)), } } let wheels: BTreeSet<(String, String)> = planned @@ -597,12 +599,11 @@ async fn engine( let mut rewritten: Vec<(usize, Rewritten)> = Vec::new(); for (index, plan) in planned { checkpoint(&cancel).await?; - let skipped_before = plan.skipped.clone(); - match redirect::rewrite(plan, &wheel_metadata, stage) { + match stages::rewrite(plan, &wheel_metadata, stage).await { Ok(done) => rewritten.push((index, done)), - Err(Refused { error }) => { - states[index].skipped = skipped_before; - states[index].error = Some(error); + Err(RewriteRefused { refusal, skipped }) => { + states[index].skipped = skipped; + states[index].error = Some(ProjectError::from(refusal)); } } } @@ -613,7 +614,7 @@ async fn engine( } else { rewritten .iter() - .flat_map(|(_, r)| r.confirmed.iter().map(|(_, u)| u.clone())) + .flat_map(|(_, r)| r.done.confirmed.iter().map(|(_, u)| u.clone())) .collect() }; let records: BTreeMap> = if record_uuids.is_empty() { @@ -647,7 +648,7 @@ async fn engine( } let redirect = match &state.error { Some(_) => serde_json::json!({ "mode": "hosted" }), - None => crate::commands::scan::hosted::redirect_json_block( + None => crate::hosted::render::redirect_json_block( 0, Vec::new(), Vec::new(), @@ -711,37 +712,36 @@ fn finish_root( warnings: &mut Vec, ) -> ProjectResult { let Rewritten { - planned, + project, + skipped, + pre_warnings, + done, + } = done; + let crate::hosted::engine::Rewritten { rewrite, rewritten, confirmed, rush_warnings, pnpm_warnings, npm_warnings, + .. } = done; let root = state.root.clone(); // No ledger keeps the records; the fetch mirrors the disk flow's, so a // record the API cannot serve warns the same way. - let mut record_warnings: Vec = Vec::new(); + let mut record_warnings: Vec = Vec::new(); if !dry_run { for (purl, uuid) in &confirmed { match records.get(uuid) { Some(Some(_)) => {} - _ => record_warnings.push(serde_json::json!({ - "code": "record_fetch_failed", - "detail": format!( - "{purl} was switched to hosted, but its patch record could not be \ - fetched; this run's VEX attestation omits it (`socket-patch vex` \ - fetches it again once the API answers)" - ), - })), + _ => record_warnings.push(crate::hosted::engine::record_fetch_failed_warning(purl)), } } } let mut project_changes: Vec<(String, String)> = Vec::new(); for (rel, content) in &rewrite.files { - if planned.project.text(rel) != Some(content.as_str()) { + if project.text(rel) != Some(content.as_str()) { project_changes.push((rel.clone(), content.clone())); } } @@ -754,7 +754,7 @@ fn finish_root( .iter() .filter(|(rel, bytes)| { !matches!( - planned.project.get(rel.as_str()), + project.get(rel.as_str()), Some(MemoryEntry::Binary(existing)) if existing.as_ref() == bytes.as_slice() ) }) @@ -789,7 +789,7 @@ fn finish_root( redirect: serde_json::json!({ "mode": "hosted" }), summary: state.summary.clone(), redirected: Vec::new(), - skipped: planned.skipped, + skipped, error: Some(ProjectError { code: "conflicting_write".into(), message, @@ -807,22 +807,19 @@ fn finish_root( .or_insert_with(|| (root.clone(), bytes)); } - let mut redirect_warnings: Vec = rewrite - .warnings + // One typed list in the envelope's order; JSON only at the boundary. + let mut warnings = rewrite.warnings.clone(); + warnings.extend(record_warnings); + warnings.extend(rush_warnings); + warnings.extend(pnpm_warnings); + warnings.extend(npm_warnings); + warnings.extend(pre_warnings); + let redirect_warnings = crate::hosted::render::rewrite_warnings_json(&warnings); + let skipped_values: Vec = skipped .iter() - .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })) + .map(crate::hosted::render::skipped_json) .collect(); - redirect_warnings.extend(record_warnings); - redirect_warnings.extend(rush_warnings); - redirect_warnings.extend(pnpm_warnings); - redirect_warnings.extend(npm_warnings); - redirect_warnings.extend(planned.pre_warnings.iter().cloned()); - let skipped_values: Vec = planned - .skipped - .iter() - .map(|s| serde_json::to_value(s).unwrap_or(serde_json::Value::Null)) - .collect(); - let redirect = crate::commands::scan::hosted::redirect_json_block( + let redirect = crate::hosted::render::redirect_json_block( confirmed.len(), rewritten, skipped_values, @@ -837,7 +834,7 @@ fn finish_root( .into_iter() .map(|(purl, uuid)| RedirectedPatch { purl, uuid }) .collect(), - skipped: planned.skipped, + skipped, error: None, } } @@ -845,7 +842,7 @@ fn finish_root( #[cfg(test)] mod tests { use super::*; - use socket_patch_core::patch::redirect::{FileEdit, RewriteResult}; + use crate::patch::redirect::{FileEdit, RewriteResult}; fn state(root: &str, project: MemoryProject) -> RootState { RootState { @@ -862,8 +859,6 @@ mod tests { } fn rewritten(files: &[(&str, &str)]) -> Rewritten { - let planned = redirect::plan(MemoryProject::new(), BTreeSet::new(), &[], &HashMap::new()) - .unwrap_or_else(|r| panic!("{:?}", r.error)); let mut rewrite = RewriteResult::default(); for (rel, content) in files { rewrite @@ -879,13 +874,24 @@ mod tests { }); } Rewritten { - planned, - rewrite, - rewritten: files.iter().map(|(rel, _)| (*rel).to_string()).collect(), - confirmed: vec![("pkg:cargo/serde@1.0.190".into(), "u".into())], - rush_warnings: Vec::new(), - pnpm_warnings: Vec::new(), - npm_warnings: Vec::new(), + project: MemoryProject::new(), + skipped: Vec::new(), + pre_warnings: Vec::new(), + done: crate::hosted::engine::Rewritten { + files: BTreeMap::new(), + symlinked_reads: Vec::new(), + unreadable_reads: Vec::new(), + overrides: Vec::new(), + rewrite, + rewritten: files.iter().map(|(rel, _)| (*rel).to_string()).collect(), + confirmed: vec![("pkg:cargo/serde@1.0.190".into(), "u".into())], + binary_bun: false, + rush_warnings: Vec::new(), + pnpm_warnings: Vec::new(), + npm_warnings: Vec::new(), + pnpm_rerun_only: false, + workspace_symlinked: false, + }, } } @@ -943,7 +949,8 @@ mod tests { changed_binary.insert("web/bun.lockb".to_string(), ("".to_string(), vec![1u8])); let mut warnings = Vec::new(); let mut done = rewritten(&[]); - done.rewrite + done.done + .rewrite .binary_files .insert("bun.lockb".to_string(), vec![2u8]); let result = finish_root( diff --git a/crates/socket-patch-cli/src/hosted_memory/roots.rs b/crates/socket-patch-core/src/hosted/memory/roots.rs similarity index 98% rename from crates/socket-patch-cli/src/hosted_memory/roots.rs rename to crates/socket-patch-core/src/hosted/memory/roots.rs index 053cf801..c715f85a 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -7,8 +7,8 @@ use std::collections::{BTreeMap, BTreeSet}; -use socket_patch_core::formats::registry; -use socket_patch_core::utils::python_lock::is_python_lock_name; +use crate::formats::registry; +use crate::utils::python_lock::is_python_lock_name; use super::types::IgnoredPath; diff --git a/crates/socket-patch-cli/src/hosted_memory/select.rs b/crates/socket-patch-core/src/hosted/memory/select.rs similarity index 97% rename from crates/socket-patch-cli/src/hosted_memory/select.rs rename to crates/socket-patch-core/src/hosted/memory/select.rs index b7d939af..76750320 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -8,17 +8,18 @@ use std::collections::{BTreeMap, BTreeSet}; -use socket_patch_core::constants::npm_family::{ +use crate::constants::npm_family::{ BUN_LOCKB, PNP_MARKERS, RUSH_COMMON_LOCK_REL, RUSH_SUBSPACES_DIR, }; -use socket_patch_core::patch::redirect::npmrc::NPMRC_REL; -use socket_patch_core::utils::python_lock::is_python_lock_name; +use crate::patch::redirect::npmrc::NPMRC_REL; +use crate::utils::python_lock::is_python_lock_name; use super::roots::{ detect_roots, split_path, strip_root, EXCLUDED_ROOT_SEGMENTS, UNSUPPORTED_MARKERS, }; use super::types::{IgnoredPath, PathSelection, SelectOptions, TreeEntryInput}; -use crate::commands::scan::hosted::{PNPM_WORKSPACE_REL, REDIRECT_CANDIDATE_FILES}; +use crate::hosted::engine::{REDIRECT_CANDIDATE_FILES, RUSH_REPO_STATE_REL}; +use crate::hosted::guidance::PNPM_WORKSPACE_REL; /// Most entries [`PathSelection::ignored_sample`] carries. pub const IGNORED_SAMPLE_MAX: usize = 100; @@ -32,9 +33,6 @@ const MAX_PATH_DEPTH: usize = 64; /// The vendored-mode ledger (its presence refuses a vendored takeover). pub(crate) const VENDOR_STATE_REL: &str = ".socket/vendor/state.json"; -/// Rush's repo-state file (presence feeds the stale-hash warning). -pub(crate) const RUSH_REPO_STATE_REL: &str = "common/config/rush/repo-state.json"; - /// Root-relative text files read beyond `REDIRECT_CANDIDATE_FILES`. const EXTRA_TEXT_FILES: [&str; 3] = [PNPM_WORKSPACE_REL, NPMRC_REL, VENDOR_STATE_REL]; diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs new file mode 100644 index 00000000..a2b8d403 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -0,0 +1,347 @@ +//! One project root's hosted redirect over an in-memory file set: the +//! shared engine ([`crate::hosted::engine`]) over a +//! [`ProjectView::Memory`], split at the two provider round-trips the +//! engine batches across roots (wheel metadata, then patch records). +//! Everything that needs the host machine (the apply lock, vendored +//! takeover reverts, stale install probes, VEX, telemetry, subprocesses) is +//! left out; a vendored takeover is refused instead of performed. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use crate::api::types::PackageVendorResult; +use crate::hosted::engine::{ + self, Candidate, CandidateFiles, Refusal, RewriteOptions, SkippedPatch, +}; +use crate::hosted::vlt::Preflight; +use crate::patch::redirect::npmrc::OuterAllowRemote; +use crate::patch::redirect::DepOverride; +use crate::utils::purl::strip_purl_qualifiers; +use crate::vendor::lock_inventory::{MemoryEntry, MemoryProject, ProjectView}; +use crate::vendor::VendorState; + +use super::select::VENDOR_STATE_REL; + +/// Skip reason / warning code for a candidate the disk flow would migrate +/// from vendored to hosted (the migration reverts committed wiring, which +/// the in-memory engine does not do). +pub(crate) const VENDORED_TAKEOVER_UNSUPPORTED: &str = "vendored_takeover_unsupported_in_memory"; + +/// The engine-level options the per-root stages read. +#[derive(Debug, Clone, Copy)] +pub(crate) struct StageOptions { + pub(crate) dry_run: bool, + pub(crate) pipenv_major: Option, + pub(crate) trust_lockfile_config: bool, + pub(crate) npm_allow_remote_config: bool, +} + +/// The vendored ledger's entries (the disk `vendor::load_state` parse, +/// including its legacy `{mode}`-only shape); `None` when absent or +/// unreadable. +fn vendored_entries(project: &MemoryProject) -> Option { + let bytes: Vec = match project.get(VENDOR_STATE_REL)? { + MemoryEntry::Text(text) => text.as_bytes().to_vec(), + MemoryEntry::Binary(bytes) => bytes.to_vec(), + _ => return None, + }; + match serde_json::from_slice::(&bytes) { + Ok(state) => Some(state), + Err(_) => { + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + (value.get("mode").is_some() && value.get("entries").is_none()).then(VendorState::new) + } + } +} + +/// Whether Socket-owned vendored `[patch.crates-io]` wiring for exactly +/// `name@version` is committed in the root manifest or (legacy) the +/// project's cargo config — the disk `socket_wiring_present` probe over +/// the in-memory files. The config cargo reads is `.cargo/config` when it +/// exists, else `.cargo/config.toml`. +fn cargo_vendored_wiring(files: &MemoryProject, name: &str, version: &str) -> bool { + use crate::vendor::cargo_manifest::{crates_io_patch_entries, entry_wires, parse_manifest}; + let manifest_wired = files + .text("Cargo.toml") + .and_then(|text| parse_manifest(text).ok()) + .is_some_and(|doc| { + crates_io_patch_entries(&doc) + .iter() + .any(|e| entry_wires(e, name, version)) + }); + let config_rel = if files.contains(".cargo/config") { + ".cargo/config" + } else { + ".cargo/config.toml" + }; + let config_wired = files + .text(config_rel) + .and_then(|text| parse_manifest(text).ok()) + .is_some_and(|doc| { + crates_io_patch_entries(&doc) + .iter() + .any(|e| e.source == "crates-io" && entry_wires(e, name, version)) + }); + manifest_wired || config_wired +} + +/// Refuse (skip, with one warning) every candidate the disk flow would +/// take over from vendored mode. +fn refuse_takeovers( + project: &MemoryProject, + candidates: &mut Vec, + skipped: &mut Vec, + pre_warnings: &mut Vec, +) { + let takeover_capable = |p: &str| { + p.starts_with("pkg:cargo/") || p.starts_with("pkg:npm/") || p.starts_with("pkg:golang/") + }; + if !candidates.iter().any(|c| takeover_capable(&c.purl)) { + return; + } + let vendored = vendored_entries(project); + let mut refused: BTreeSet = BTreeSet::new(); + for candidate in candidates.iter().filter(|c| takeover_capable(&c.purl)) { + let has_entry = vendored.as_ref().is_some_and(|s| { + crate::vendor::lookup_entry(&s.entries, strip_purl_qualifiers(&candidate.purl)) + .is_some() + }); + let cargo_wired = !has_entry + && candidate.purl.starts_with("pkg:cargo/") + && cargo_vendored_wiring(project, &candidate.dep.name, &candidate.dep.version); + if has_entry || cargo_wired { + refused.insert(candidate.purl.clone()); + } + } + if refused.is_empty() { + return; + } + pre_warnings.push(engine::warning( + VENDORED_TAKEOVER_UNSUPPORTED, + format!( + "{} currently vendored ({}); migrating a vendored package to hosted \ + reverts its committed vendored wiring, which the in-memory hosted scan \ + does not do — run `socket-patch scan --mode hosted` in a checkout to \ + migrate, then re-run", + if refused.len() == 1 { + "1 package is" + } else { + "packages are" + }, + refused.iter().cloned().collect::>().join(", ") + ), + )); + for c in candidates.iter().filter(|c| refused.contains(&c.purl)) { + skipped.push(SkippedPatch::new( + &c.purl, + &c.dep.patch_uuid, + VENDORED_TAKEOVER_UNSUPPORTED, + )); + } + candidates.retain(|c| !refused.contains(&c.purl)); +} + +/// A project's state between the reference grants and the wheel-metadata +/// fetch. +#[derive(Debug)] +pub(crate) struct Planned { + pub(crate) project: MemoryProject, + pub(crate) candidates: Vec, + pub(crate) skipped: Vec, + pub(crate) pre_warnings: Vec, + pub(crate) read: CandidateFiles, + /// `(artifact url, sha256)` of every pypi wheel whose metadata a + /// native lock rewrite needs. + pub(crate) wheels: Vec<(String, String)>, + /// The vlt artifact preflight, judged offline (no network here, so + /// every in-scope dep is withheld instead of pinned: `--offline` + /// parity). + pub(crate) vlt_preflight: Preflight, +} + +/// Everything up to the wheel-metadata fetch. `unreadable` are the paths +/// that exist without content. +pub(crate) async fn plan( + project: MemoryProject, + unreadable: BTreeSet, + selected: &[(String, String)], + references: &HashMap, +) -> Result { + let mut skipped: Vec = Vec::new(); + let mut candidates = if selected.is_empty() { + Vec::new() + } else { + engine::build_candidates(selected, references, &mut skipped) + }; + let view = ProjectView::Memory(&project); + if engine::bun_lockb_symlinked(&view, &candidates) { + return Err(engine::bun_lockb_symlink_refusal()); + } + let vlt_preflight = { + let deps: Vec<(&str, &DepOverride)> = candidates + .iter() + .filter(|c| c.dep.ecosystem == "npm") + .map(|c| (c.purl.as_str(), &c.dep)) + .collect(); + crate::hosted::vlt::offline(&view, &deps).await + }; + engine::withhold_everywhere( + &mut candidates, + &vlt_preflight.withheld_everywhere, + &mut skipped, + ); + let mut pre_warnings = vlt_preflight.warnings.clone(); + refuse_takeovers(&project, &mut candidates, &mut skipped, &mut pre_warnings); + + let read = if candidates.is_empty() { + CandidateFiles::default() + } else { + engine::read_candidate_files(&view, &unreadable, &candidates).await + }; + let wheels = engine::wheel_targets(&candidates, &read.files) + .into_iter() + .map(|(dep, sha256)| (dep.artifact_url.clone(), sha256.to_string())) + .collect(); + Ok(Planned { + project, + candidates, + skipped, + pre_warnings, + read, + wheels, + vlt_preflight, + }) +} + +/// A project's rewrite, ready for the record fetch and the ledger merge. +#[derive(Debug)] +pub(crate) struct Rewritten { + pub(crate) project: MemoryProject, + pub(crate) skipped: Vec, + pub(crate) pre_warnings: Vec, + pub(crate) done: engine::Rewritten, +} + +/// A refused rewrite: its refusal and the skips recorded before the +/// wheel-metadata step. +#[derive(Debug)] +pub(crate) struct RewriteRefused { + pub(crate) refusal: Refusal, + pub(crate) skipped: Vec, +} + +/// Wheel metadata → the engine's rewrite → the guard. +pub(crate) async fn rewrite( + planned: Planned, + wheel_metadata: &BTreeMap, String>>, + options: StageOptions, +) -> Result { + let Planned { + project, + mut candidates, + mut skipped, + pre_warnings, + read, + wheels, + vlt_preflight, + } = planned; + let skipped_before = skipped.clone(); + let mut python_metadata: BTreeMap = BTreeMap::new(); + let mut unavailable: BTreeSet = BTreeSet::new(); + for (url, _) in &wheels { + match wheel_metadata.get(url) { + Some(Ok(Some(metadata))) => { + python_metadata.insert(url.clone(), metadata.clone()); + } + Some(Ok(None)) => {} + Some(Err(detail)) => { + if unavailable.insert(url.clone()) { + for dep in candidates + .iter() + .map(|c| &c.dep) + .filter(|d| &d.artifact_url == url) + { + skipped.push(engine::wheel_metadata_unavailable(dep, detail)); + } + } + } + None => { + unavailable.insert(url.clone()); + } + } + } + candidates.retain(|c| !unavailable.contains(&c.dep.artifact_url)); + + let view = ProjectView::Memory(&project); + let targets_pipenv_lock = engine::pipenv_lock_targets(&read.files, &candidates); + // The in-memory host sees no user / global npm config. + let npm_outer = OuterAllowRemote::default; + let done = engine::rewrite( + &view, + read, + &candidates, + python_metadata, + &vlt_preflight.withheld_from_vlt, + &[], + RewriteOptions { + dry_run: options.dry_run, + targets_pipenv_lock, + pipenv_major: if targets_pipenv_lock { + options.pipenv_major + } else { + None + }, + pipenv_unknown_detail: "The scan did not set `pipenvMajor`, so the Pipfile.lock \ + references use the modern `file` form (Pipenv 2018 and later). A project \ + installed with Pipenv 7–11 needs `path` references instead: re-run the scan \ + with `pipenvMajor` set to that Pipenv major version." + .to_string(), + trust_lockfile_config: options.trust_lockfile_config, + npm_allow_remote_config: options.npm_allow_remote_config, + npm_outer: &npm_outer, + blocking: false, + }, + ) + .await; + if let Some(refusal) = engine::guard(&view, &done, &candidates) { + return Err(RewriteRefused { + refusal, + skipped: skipped_before, + }); + } + Ok(Rewritten { + project, + skipped, + pre_warnings, + done, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cargo_wiring_probe_is_scoped_to_the_crate_and_version() { + let mut p = MemoryProject::new(); + p.insert_text( + "Cargo.toml", + "[package]\nname = \"app\"\n\n[dependencies]\nlog = \"0.4\"\ncc = \"1\"\n\n\ + [patch.crates-io]\nopenssl-socket-0123abcd = { package = \"openssl\", path = \ + \".socket/vendor/cargo/0123abcd-0000-4000-8000-000000000000/openssl-0.10.66\" }\n", + ); + assert!(cargo_vendored_wiring(&p, "openssl", "0.10.66")); + assert!(!cargo_vendored_wiring(&p, "openssl", "0.10.65")); + assert!(!cargo_vendored_wiring(&p, "log", "0.4.22")); + assert!(!cargo_vendored_wiring(&p, "cc", "1.1.0")); + + let mut legacy = MemoryProject::new(); + legacy.insert_text("Cargo.toml", "[package]\nname = \"app\"\n"); + let config = "[patch.crates-io]\ncc = { path = \ + \".socket/vendor/cargo/0123abcd-0000-4000-8000-000000000000/cc-1.1.0\" }\n"; + legacy.insert_text(".cargo/config.toml", config); + assert!(cargo_vendored_wiring(&legacy, "cc", "1.1.0")); + // cargo reads the legacy spelling when it exists. + legacy.insert_text(".cargo/config", ""); + assert!(!cargo_vendored_wiring(&legacy, "cc", "1.1.0")); + } +} diff --git a/crates/socket-patch-cli/src/hosted_memory/types.rs b/crates/socket-patch-core/src/hosted/memory/types.rs similarity index 97% rename from crates/socket-patch-cli/src/hosted_memory/types.rs rename to crates/socket-patch-core/src/hosted/memory/types.rs index ed8e3a84..f29c5777 100644 --- a/crates/socket-patch-cli/src/hosted_memory/types.rs +++ b/crates/socket-patch-core/src/hosted/memory/types.rs @@ -200,15 +200,7 @@ pub struct RedirectedPatch { pub uuid: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SkippedPatch { - pub purl: String, - pub uuid: String, - pub reason: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option, -} +pub use crate::hosted::engine::SkippedPatch; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -217,6 +209,15 @@ pub struct ProjectError { pub message: String, } +impl From for ProjectError { + fn from(refusal: crate::hosted::engine::Refusal) -> Self { + ProjectError { + code: refusal.code, + message: refusal.message, + } + } +} + /// `ProjectResult`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/socket-patch-core/src/hosted/mod.rs b/crates/socket-patch-core/src/hosted/mod.rs new file mode 100644 index 00000000..1a195bb9 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/mod.rs @@ -0,0 +1,19 @@ +//! Hosted mode: rewrite ONLY the patched dependencies' lockfile / +//! registry-config entries to point at Socket's hosted patched artifacts. +//! +//! - [`engine`] — the one plan → rewrite → edits engine, over a +//! [`ProjectView`](crate::vendor::lock_inventory::ProjectView), shared by +//! the disk flow (`scan`/`get --mode hosted` in the CLI) and the +//! in-memory engine. +//! - [`guidance`] — the pnpm `trustLockfile` / npm `allow-remote` +//! auto-config planners and their warning texts. +//! - [`vlt`] — the vlt artifact preflight. +//! - [`memory`] — the engine over an in-memory repository (no filesystem, +//! subprocesses, environment or telemetry), embedded by the Node addon +//! (`socket-patch-node`) and the CLI's hidden `hosted-bundle` harness. + +pub mod engine; +pub mod guidance; +pub mod memory; +pub mod render; +pub mod vlt; diff --git a/crates/socket-patch-core/src/hosted/render.rs b/crates/socket-patch-core/src/hosted/render.rs new file mode 100644 index 00000000..51ccd080 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/render.rs @@ -0,0 +1,43 @@ +//! The hosted engine's results as the JSON the CLI envelope and the +//! in-memory engine return. The engine itself produces typed values +//! ([`RewriteWarning`], [`SkippedPatch`]); only this adapter spells them as +//! JSON, so the disk and memory paths cannot drift in key names. + +use super::engine::SkippedPatch; +use crate::patch::redirect::RewriteWarning; + +/// A `skipped[]` entry (`{purl, uuid, reason[, detail]}`). +pub fn skipped_json(skipped: &SkippedPatch) -> serde_json::Value { + serde_json::to_value(skipped).expect("SkippedPatch is plain strings: serialization cannot fail") +} + +/// Warnings as `{code, detail}` JSON. +pub fn rewrite_warnings_json(warnings: &[RewriteWarning]) -> Vec { + warnings + .iter() + .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })) + .collect() +} + +/// The nested `redirect` block of every hosted `--json` envelope — the ONE +/// spelling of its key set (`mode`, `redirected`, `rewrittenFiles`, +/// `skipped`, `warnings`, `dryRun`), shared by every hosted path (disk +/// scan, its zero-discovery arm, and the in-memory engine), so the two cannot drift by convention. +/// `mode` is `"hosted"`: an additive key so consumers dispatch on the mode without inferring it from which +/// sub-object is present. +pub fn redirect_json_block( + redirected: usize, + rewritten: Vec, + skipped: Vec, + warnings: Vec, + dry_run: bool, +) -> serde_json::Value { + serde_json::json!({ + "mode": "hosted", + "redirected": redirected, + "rewrittenFiles": rewritten, + "skipped": skipped, + "warnings": warnings, + "dryRun": dry_run, + }) +} diff --git a/crates/socket-patch-core/src/hosted/vlt.rs b/crates/socket-patch-core/src/hosted/vlt.rs new file mode 100644 index 00000000..e2e83aaf --- /dev/null +++ b/crates/socket-patch-core/src/hosted/vlt.rs @@ -0,0 +1,217 @@ +//! The vlt artifact preflight of the hosted flow, over a [`ProjectView`]: +//! before any takeover or rewrite, each in-scope artifact is judged the way +//! vlt fetches it. The disk flow probes the artifacts over the network (or +//! judges them offline under `--offline`); the in-memory engine has no +//! network and judges every one offline. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::constants::npm_family::{ + BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_HIDDEN_LOCK_REL, VLT_LOCK, VLT_STORE_DIR, +}; +use crate::patch::redirect::vlt_preflight::{self, ArtifactProbe, OFFLINE_REASON}; +use crate::patch::redirect::{redact_grant_token, vlt, DepOverride}; +use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; + +/// The warning code (and skip reason) of a dep whose artifact vlt would +/// fail to verify. +pub const ARTIFACT_UNVERIFIABLE: &str = "redirect_vlt_artifact_unverifiable"; + +/// The skip `reason` of a dep the preflight withheld from every rewriter. +pub const WITHHELD_REASON: &str = ARTIFACT_UNVERIFIABLE; + +/// Whether vlt's install state exists: the hidden lock as a regular file, +/// or the store as a real directory. Stat only; the hidden lock can be +/// megabytes and is never read into the rewriter's input. +pub fn install_state_present(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => { + let is = |rel: &str, dir: bool| { + std::fs::symlink_metadata(cwd.join(rel)).is_ok_and(|m| { + if dir { + m.file_type().is_dir() + } else { + m.file_type().is_file() + } + }) + }; + is(VLT_HIDDEN_LOCK_REL, false) || is(VLT_STORE_DIR, true) + } + ProjectView::Memory(project) => { + matches!( + project.get(VLT_HIDDEN_LOCK_REL), + Some(MemoryEntry::Text(_) | MemoryEntry::Binary(_) | MemoryEntry::Present) + ) || project.is_dir(VLT_STORE_DIR) + } + } +} + +/// Whether `bun.lockb` is present (disk: `exists`, which follows links). +pub(crate) fn bun_lockb_present(view: &ProjectView<'_>) -> bool { + match view { + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join(BUN_LOCKB).exists(), + ProjectView::Memory(project) => project.contains(BUN_LOCKB), + } +} + +/// What the artifact preflight decided for this run's npm candidates. +#[derive(Debug, Default)] +pub struct Preflight { + /// Failed while vlt drives, or for a vlt-vendored takeover: withheld + /// from every rewriter. + pub withheld_everywhere: BTreeMap, + /// Failed while another npm-family lock may drive: kept out of the vlt + /// rewrite only. + pub withheld_from_vlt: BTreeSet, + pub passed: BTreeSet, + /// Artifact bytes by URL, for the heal's no-record comparison. + pub artifacts: BTreeMap>, + pub warnings: Vec, +} + +/// The files `vlt_drives` and the preflight scope read: `vlt-lock.json` +/// itself, and presence-only entries for the sibling locks and vlt's +/// install state. Empty without a readable `vlt-lock.json`. +pub async fn inputs(view: &ProjectView<'_>) -> BTreeMap { + let mut files = BTreeMap::new(); + let Ok(lock) = view.read_text(VLT_LOCK).await else { + return files; + }; + files.insert(VLT_LOCK.to_string(), lock); + for sibling in [NPM_LOCKS[0], NPM_LOCKS[1], "yarn.lock", PNPM_LOCK, BUN_LOCK] { + if view.is_file(sibling) { + files.insert(sibling.to_string(), String::new()); + } + } + if install_state_present(view) { + files.insert(VLT_HIDDEN_LOCK_REL.to_string(), String::new()); + } + files +} + +/// The deps a preflight must judge, and whether vlt drives the install. +pub struct PreflightPlan { + pub scope: Vec, + pub drives: bool, +} + +impl PreflightPlan { + /// The distinct artifact URLs to probe. + pub fn urls(&self) -> BTreeSet { + self.scope.iter().map(|d| d.artifact_url.clone()).collect() + } +} + +/// The preflight's scope over the project's [`inputs`] (`files`, never +/// empty): `None` when no dep is in scope. `vendored` are the uuids whose +/// purl a vlt vendored ledger entry claims. +pub fn plan( + view: &ProjectView<'_>, + files: &BTreeMap, + deps: &[(&str, &DepOverride)], + vendored: &BTreeSet, +) -> Option { + let overrides: Vec = deps.iter().map(|(_, dep)| (*dep).clone()).collect(); + let scope = vlt_preflight::preflight_scope(files, &overrides, vendored); + if scope.is_empty() { + return None; + } + let drives = vlt::vlt_drives(files, bun_lockb_present(view)); + Some(PreflightPlan { scope, drives }) +} + +fn unverifiable_detail( + url: &str, + reason: &str, + purl: &str, + already_pinned: bool, + everywhere: bool, +) -> String { + if already_pinned { + format!( + "vlt would fail to verify {url}: {reason}; {purl} was left pinned by an earlier run \ + and `vlt ci` will fail until the artifact verifies" + ) + } else if everywhere { + format!("vlt would fail to verify {url}: {reason}; nothing was written for {purl}") + } else { + format!( + "vlt would fail to verify {url}: {reason}; vlt-lock.json was not changed for {purl}" + ) + } +} + +/// The preflight's verdicts for `plan` given the `probes` fetched for it +/// (none for a URL that was not fetched: judged offline). +pub fn judge( + plan: &PreflightPlan, + deps: &[(&str, &DepOverride)], + probes: BTreeMap, +) -> Preflight { + let mut out = Preflight::default(); + let mut passed_urls: BTreeSet<&str> = BTreeSet::new(); + for dep in &plan.scope { + let reason = match probes.get(&dep.artifact_url) { + None => Some(OFFLINE_REASON.to_string()), + Some(probe) => probe.failure(&dep.sha512), + }; + let Some(reason) = reason else { + out.passed.insert(dep.patch_uuid.clone()); + passed_urls.insert(&dep.artifact_url); + continue; + }; + let purl = deps + .iter() + .find(|(_, d)| d.patch_uuid == dep.patch_uuid) + .map_or("", |(purl, _)| *purl); + let everywhere = plan.drives || dep.vendored; + let detail = unverifiable_detail( + &dep.artifact_url, + &reason, + purl, + dep.already_pinned, + everywhere, + ); + out.warnings.push(crate::hosted::engine::warning( + ARTIFACT_UNVERIFIABLE, + redact_grant_token(&detail, &dep.artifact_url, &dep.patch_uuid), + )); + if everywhere { + out.withheld_everywhere + .insert(dep.patch_uuid.clone(), purl.to_string()); + } else { + out.withheld_from_vlt.insert(dep.patch_uuid.clone()); + } + } + // Moved, not copied: every dep has been judged, and the probes are not + // read again, so a verified body is held once. + for (url, probe) in probes { + if passed_urls.contains(url.as_str()) { + if let Some(body) = probe.body { + out.artifacts.insert(url, body); + } + } + } + out +} + +/// The preflight for a host with no network: every in-scope artifact is +/// judged as `--offline` judges it, so the dep is withheld +/// (`redirect_vlt_artifact_unverifiable`, "offline") rather than pinned in +/// a lock vlt may not be able to install. +pub async fn offline(view: &ProjectView<'_>, deps: &[(&str, &DepOverride)]) -> Preflight { + let files = inputs(view).await; + if files.is_empty() { + return Preflight::default(); + } + match plan(view, &files, deps, &BTreeSet::new()) { + Some(plan) => judge(&plan, deps, BTreeMap::new()), + None => Preflight::default(), + } +} diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs new file mode 100644 index 00000000..5ad5e73d --- /dev/null +++ b/crates/socket-patch-core/src/ledgers.rs @@ -0,0 +1,525 @@ +//! The project's patch stores read as one view: the agent manifest +//! (`.socket/manifest.json`), the vendored ledger +//! (`.socket/vendor/state.json`) and the hosted records. v5 hosted mode +//! keeps no ledger, so the hosted records are the lockfiles' hosted pins +//! (shaped as uuid-only records) or a run's fetched records, plus a pre-v5 +//! redirect ledger (`.socket/vendor/redirect-state.json`) read, never +//! written, for migration. +//! +//! One owner-precedence rule decides which store owns a purl every reader +//! merges ([`Ledgers::owned`], [`Ledgers::listed`], [`Ledgers::matching`]): +//! +//! 1. A manifest key is owned by the manifest. It claims every vendored +//! entry filed under that key or whose `base_purl` it names; a claimed +//! entry's embedded record is a fallback copy. The group's vendored +//! entry is the one filed under the key, else the lowest-keyed entry +//! naming it as `base_purl` (so `HashMap` order never decides). +//! 2. Every unclaimed vendored entry owns its ledger key. +//! 3. A hosted record owns its key when neither of the above does. +//! +//! So: manifest > vendored > hosted, by ledger key. The losing copies stay +//! reachable as alternates ([`Owned::alts`]) for readers that decide by +//! lockfile evidence (`vex`). The hosted records only ever join as the +//! last store. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::manifest::schema::{PatchManifest, PatchRecord}; +use crate::patch::redirect::{CorruptRedirectState, RedirectState}; +use crate::utils::purl::{normalize_purl, patch_matches, strip_purl_qualifiers}; +use crate::vendor::{VendorEntry, VendorState}; + +/// A patch store, in owner-precedence order (a lower store wins a key). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Store { + /// `.socket/manifest.json` (agent mode). + Manifest, + /// `.socket/vendor/state.json` (vendored mode). + Vendored, + /// The hosted records (lockfile pins, a run's fetched records, or a + /// pre-v5 redirect ledger read for migration). + Hosted, +} + +/// The three stores as loaded, each with its own outcome so every caller +/// keeps its error posture (strict, lenient, per-leg). +pub struct LoadedLedgers { + /// `Ok(None)`: absent; `Err(InvalidData)`: unparseable; other `Err`: I/O. + pub manifest: std::io::Result>, + /// Absent is `Ok(empty)`; `Err` is unreadable or malformed. + pub vendor: std::io::Result, + /// The pre-v5 redirect ledger, read only (absent is `Ok(None)`). + pub redirect: Result, CorruptRedirectState>, +} + +impl LoadedLedgers { + /// Load the manifest at `manifest_path`, the vendored ledger and any + /// pre-v5 redirect ledger of `project_root`, concurrently. + pub async fn load(project_root: &Path, manifest_path: &Path) -> Self { + let (manifest, vendor, redirect) = tokio::join!( + crate::manifest::operations::read_manifest(manifest_path), + crate::vendor::load_state(project_root), + crate::patch::redirect::load_redirect_state(project_root), + ); + Self { + manifest, + vendor, + redirect, + } + } + + /// The readable stores (a failed store reads as absent). + pub fn view(&self) -> Ledgers<'_> { + Ledgers { + manifest: self.manifest.as_ref().ok().and_then(Option::as_ref), + vendor: self.vendor.as_ref().ok(), + redirect: self.redirect.as_ref().ok().and_then(Option::as_ref), + } + } +} + +/// A borrowed view over whichever stores a caller has. +#[derive(Clone, Copy, Default)] +pub struct Ledgers<'a> { + pub manifest: Option<&'a PatchManifest>, + pub vendor: Option<&'a VendorState>, + pub redirect: Option<&'a RedirectState>, +} + +/// One owner key with the store that owns it and every copy under it. +#[derive(Debug, Clone)] +pub struct Owned<'a> { + pub key: &'a str, + pub store: Store, + pub uuid: &'a str, + /// The owner's record; `None` for a legacy vendored entry that embeds + /// none. + pub record: Option<&'a PatchRecord>, + /// The vendored entry in this group (the owner itself, or the one the + /// manifest key claimed), with its ledger key. + pub vendor: Option<(&'a str, &'a VendorEntry)>, + /// Whether the hosted records hold one under this key. + pub hosted: bool, + /// The losing copies' records, in precedence order. + pub alts: Vec<&'a PatchRecord>, +} + +/// One copy [`Ledgers::listed`] shows. +#[derive(Debug, Clone, Copy)] +pub struct Listed<'a> { + pub key: &'a str, + pub record: &'a PatchRecord, + pub store: Store, +} + +/// Every store's entries a remove/rollback identifier matches, each sorted +/// by key. +#[derive(Debug, Clone, Default)] +pub struct Matches { + pub manifest: Vec, + pub vendor: Vec<(String, VendorEntry)>, + pub hosted: Vec, +} + +impl Matches { + pub fn is_empty(&self) -> bool { + self.manifest.is_empty() && self.vendor.is_empty() && self.hosted.is_empty() + } +} + +fn sorted_keys(map: &HashMap) -> Vec<&String> { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + keys +} + +impl<'a> Ledgers<'a> { + /// Every vendored entry grouped under the manifest key that claims it + /// (rule 1 of the module docs), each group sorted by ledger key. Built + /// in one pass over the sorted ledger, so the views stay O(V log V + M). + fn claims(&self) -> HashMap<&'a str, Vec<(&'a String, &'a VendorEntry)>> { + let mut claims: HashMap<&'a str, Vec<(&'a String, &'a VendorEntry)>> = HashMap::new(); + if let Some(vendor) = self.vendor { + for key in sorted_keys(&vendor.entries) { + let entry = &vendor.entries[key]; + if let Some(owner) = self.claimant(key, entry) { + claims.entry(owner).or_default().push((key, entry)); + } + } + } + claims + } + + /// A claim group's representative entry: the one filed under the + /// manifest key itself, else the lowest-keyed. + fn primary<'c>( + key: &str, + group: &'c [(&'a String, &'a VendorEntry)], + ) -> Option<&'c (&'a String, &'a VendorEntry)> { + group + .iter() + .find(|(k, _)| k.as_str() == key) + .or_else(|| group.first()) + } + + /// The manifest key that claims the vendored entry filed under `key` + /// (rule 1 of the module docs): `key` itself, else its `base_purl`. + fn claimant(&self, key: &str, entry: &VendorEntry) -> Option<&'a str> { + let manifest = self.manifest?; + manifest + .patches + .get_key_value(key) + .or_else(|| manifest.patches.get_key_value(&entry.base_purl)) + .map(|(k, _)| k.as_str()) + } + + /// Whether the vendored entry filed under `key` has a manifest owner. + pub fn vendor_claimed(&self, key: &str) -> bool { + self.vendor + .and_then(|v| v.entries.get(key)) + .is_some_and(|entry| self.claimant(key, entry).is_some()) + } + + /// One group per owner key: the manifest's keys (sorted), then the + /// unclaimed vendored keys (sorted), then the hosted keys neither owns + /// (sorted). + pub fn owned(&self) -> Vec> { + let hosted_records = self.redirect.map(|r| &r.records); + let hosted_record = |key: &str| hosted_records.and_then(|r| r.get(key)); + let mut out: Vec> = Vec::new(); + let claims = self.claims(); + if let Some(manifest) = self.manifest { + for key in sorted_keys(&manifest.patches) { + let record = &manifest.patches[key]; + let group = claims + .get(key.as_str()) + .map(Vec::as_slice) + .unwrap_or_default(); + let vendor = Self::primary(key, group).copied(); + let mut alts: Vec<&'a PatchRecord> = Vec::new(); + alts.extend(vendor.and_then(|(_, e)| e.record.as_ref())); + // Any further entry this key claims (another variant of + // the same base purl) is a fallback copy too. + alts.extend( + group + .iter() + .filter(|(k, _)| vendor.is_none_or(|(vk, _)| vk != *k)) + .filter_map(|(_, e)| e.record.as_ref()), + ); + alts.extend(hosted_record(key)); + out.push(Owned { + key, + store: Store::Manifest, + uuid: &record.uuid, + record: Some(record), + vendor: vendor.map(|(k, e)| (k.as_str(), e)), + hosted: hosted_record(key).is_some(), + alts, + }); + } + } + if let Some(vendor) = self.vendor { + for key in sorted_keys(&vendor.entries) { + let entry = &vendor.entries[key]; + if self.claimant(key, entry).is_some() { + continue; + } + out.push(Owned { + key, + store: Store::Vendored, + uuid: &entry.uuid, + record: entry.record.as_ref(), + vendor: Some((key.as_str(), entry)), + hosted: hosted_record(key).is_some(), + alts: hosted_record(key).into_iter().collect(), + }); + } + } + if let Some(records) = hosted_records { + let taken: HashSet<&str> = out.iter().map(|o| o.key).collect(); + for (key, record) in records { + if taken.contains(key.as_str()) { + continue; + } + out.push(Owned { + key, + store: Store::Hosted, + uuid: &record.uuid, + record: Some(record), + vendor: None, + hosted: true, + alts: Vec::new(), + }); + } + } + out + } + + /// Every record copy worth showing, sorted by key then store: all + /// manifest and hosted records, and every vendored record except a + /// claimed non-`detached` entry's (a standalone `vendor` entry's + /// fallback copy of the manifest record it is claimed by). A `detached` + /// entry is the only copy of its record, so it shows even beside a + /// manifest entry — coexistence is real state, labeled apart. + pub fn listed(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + if let Some(manifest) = self.manifest { + out.extend(manifest.patches.iter().map(|(key, record)| Listed { + key, + record, + store: Store::Manifest, + })); + } + if let Some(vendor) = self.vendor { + out.extend(vendor.entries.iter().filter_map(|(key, entry)| { + let record = entry.record.as_ref()?; + (entry.detached || self.claimant(key, entry).is_none()).then_some(Listed { + key, + record, + store: Store::Vendored, + }) + })); + } + if let Some(redirect) = self.redirect { + out.extend(redirect.records.iter().map(|(key, record)| Listed { + key, + record, + store: Store::Hosted, + })); + } + out.sort_by(|a, b| a.key.cmp(b.key).then(a.store.cmp(&b.store))); + out + } + + /// Every entry a remove/rollback `identifier` (purl or uuid) matches: + /// manifest and hosted records by [`patch_matches`] on their key, + /// vendored entries by [`VendorEntry::matches_identifier`] (key or base + /// purl). + pub fn matching(&self, identifier: &str) -> Matches { + let mut manifest: Vec = self + .manifest + .into_iter() + .flat_map(|m| m.patches.iter()) + .filter(|(key, rec)| patch_matches(key, &rec.uuid, identifier)) + .map(|(key, _)| key.clone()) + .collect(); + manifest.sort(); + let mut vendor: Vec<(String, VendorEntry)> = self + .vendor + .into_iter() + .flat_map(|s| s.entries.iter()) + .filter(|(key, entry)| entry.matches_identifier(key, identifier)) + .map(|(k, e)| (k.clone(), e.clone())) + .collect(); + vendor.sort_by(|a, b| a.0.cmp(&b.0)); + let hosted: Vec = self + .redirect + .into_iter() + .flat_map(|r| r.records.iter()) + .filter(|(key, rec)| patch_matches(key, &rec.uuid, identifier)) + .map(|(key, _)| key.clone()) + .collect(); + Matches { + manifest, + vendor, + hosted, + } + } + + /// The purls both the hosted records and the vendored ledger claim, + /// canonical (qualifiers dropped, percent-decoded), sorted: each is + /// stale in exactly one store, which only the live lockfile can tell. + pub fn hosted_vendored_overlap(&self) -> Vec { + let (Some(redirect), Some(vendor)) = (self.redirect, self.vendor) else { + return Vec::new(); + }; + if vendor.entries.is_empty() { + return Vec::new(); + } + // Canonicalize both sides (drop qualifiers, percent-decode) so the API + // purl form the redirect records carry matches the vendor entry's base + // purl — mirrors `vendored_ledger_supplement`. + let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned(); + let mut vendor_purls: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for (key, entry) in &vendor.entries { + vendor_purls.insert(canon(key)); + vendor_purls.insert(canon(&entry.base_purl)); + } + let redirect_purls: std::collections::BTreeSet = + redirect.records.keys().map(|p| canon(p)).collect(); + redirect_purls + .intersection(&vendor_purls) + .cloned() + .collect() + } +} + +/// A uuid-only stand-in record (a legacy vendored entry that embeds no +/// record, or a lockfile pin with no store record). +pub fn uuid_only_record(uuid: &str) -> PatchRecord { + PatchRecord { + uuid: uuid.to_string(), + exported_at: String::new(), + files: HashMap::new(), + vulnerabilities: HashMap::new(), + description: String::new(), + license: String::new(), + tier: String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(uuid: &str) -> PatchRecord { + uuid_only_record(uuid) + } + + fn entry(uuid: &str, base: &str, detached: bool, rec: Option<&str>) -> VendorEntry { + let mut value = serde_json::json!({ + "uuid": uuid, + "basePurl": base, + "ecosystem": "npm", + "artifact": { "path": "", "sha256": "" }, + "wiring": [], + "detached": detached, + }); + if let Some(r) = rec { + value["record"] = serde_json::to_value(record(r)).unwrap(); + } + serde_json::from_value(value).expect("vendor entry fixture") + } + + fn manifest(pairs: &[(&str, &str)]) -> PatchManifest { + let mut m = PatchManifest::new(); + for (k, u) in pairs { + m.patches.insert(k.to_string(), record(u)); + } + m + } + + fn vendor(pairs: Vec<(&str, VendorEntry)>) -> VendorState { + let mut s = VendorState::new(); + for (k, e) in pairs { + s.entries.insert(k.to_string(), e); + } + s + } + + fn redirect(pairs: &[(&str, &str)]) -> RedirectState { + let mut r = RedirectState::new(); + for (k, u) in pairs { + r.records.insert(k.to_string(), record(u)); + } + r + } + + #[test] + fn manifest_beats_vendored_beats_hosted() { + let m = manifest(&[("pkg:npm/a@1", "m")]); + let v = vendor(vec![ + ("pkg:npm/a@1", entry("v", "pkg:npm/a@1", true, Some("v"))), + ("pkg:npm/b@1", entry("vb", "pkg:npm/b@1", true, Some("vb"))), + ]); + let r = redirect(&[ + ("pkg:npm/a@1", "h"), + ("pkg:npm/b@1", "hb"), + ("pkg:npm/c@1", "hc"), + ]); + let l = Ledgers { + manifest: Some(&m), + vendor: Some(&v), + redirect: Some(&r), + }; + let owned = l.owned(); + let got: Vec<(&str, Store, &str, Vec<&str>)> = owned + .iter() + .map(|o| { + ( + o.key, + o.store, + o.uuid, + o.alts.iter().map(|a| a.uuid.as_str()).collect(), + ) + }) + .collect(); + assert_eq!( + got, + vec![ + ("pkg:npm/a@1", Store::Manifest, "m", vec!["v", "h"]), + ("pkg:npm/b@1", Store::Vendored, "vb", vec!["hb"]), + ("pkg:npm/c@1", Store::Hosted, "hc", vec![]), + ] + ); + } + + #[test] + fn a_manifest_key_claims_the_entry_naming_it_as_base_purl() { + let m = manifest(&[("pkg:npm/a@1", "m")]); + let v = vendor(vec![ + ( + "pkg:npm/a@1?x=2", + entry("v2", "pkg:npm/a@1", false, Some("v2")), + ), + ( + "pkg:npm/a@1?x=1", + entry("v1", "pkg:npm/a@1", false, Some("v1")), + ), + ]); + let l = Ledgers { + manifest: Some(&m), + vendor: Some(&v), + redirect: None, + }; + let owned = l.owned(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].vendor.map(|(k, _)| k), Some("pkg:npm/a@1?x=1")); + let alts: Vec<&str> = owned[0].alts.iter().map(|a| a.uuid.as_str()).collect(); + assert_eq!(alts, vec!["v1", "v2"]); + assert!(l.vendor_claimed("pkg:npm/a@1?x=1")); + assert!(l.vendor_claimed("pkg:npm/a@1?x=2")); + // Claimed fallback copies are not listed. + let listed: Vec<(&str, Store)> = l.listed().iter().map(|e| (e.key, e.store)).collect(); + assert_eq!(listed, vec![("pkg:npm/a@1", Store::Manifest)]); + } + + #[test] + fn a_detached_claimed_entry_still_lists() { + let m = manifest(&[("pkg:npm/a@1", "m")]); + let v = vendor(vec![( + "pkg:npm/a@1", + entry("v", "pkg:npm/a@1", true, Some("v")), + )]); + let l = Ledgers { + manifest: Some(&m), + vendor: Some(&v), + redirect: None, + }; + assert_eq!(l.listed().len(), 2); + assert_eq!(l.owned().len(), 1); + } + + #[test] + fn matching_spans_every_store() { + let m = manifest(&[("pkg:npm/a@1", "m")]); + let v = vendor(vec![( + "pkg:npm/a@1?q=1", + entry("v", "pkg:npm/a@1", true, None), + )]); + let r = redirect(&[("pkg:npm/a@1", "h"), ("pkg:npm/b@1", "hb")]); + let l = Ledgers { + manifest: Some(&m), + vendor: Some(&v), + redirect: Some(&r), + }; + let found = l.matching("pkg:npm/a@1"); + assert_eq!(found.manifest, vec!["pkg:npm/a@1"]); + assert_eq!(found.vendor.len(), 1); + assert_eq!(found.hosted, vec!["pkg:npm/a@1"]); + assert!(l.matching("nope").is_empty()); + assert_eq!(l.matching("hb").hosted, vec!["pkg:npm/b@1"]); + } +} diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index bb42fcfc..196beafe 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -3,6 +3,8 @@ pub mod constants; pub mod crawlers; pub mod formats; pub mod hash; +pub mod hosted; +pub mod ledgers; pub mod manifest; pub mod patch; pub mod telemetry; diff --git a/crates/socket-patch-core/src/manifest/mod.rs b/crates/socket-patch-core/src/manifest/mod.rs index 06c3ccf4..0d408845 100644 --- a/crates/socket-patch-core/src/manifest/mod.rs +++ b/crates/socket-patch-core/src/manifest/mod.rs @@ -1,3 +1,4 @@ pub mod cleanup_blobs; pub mod operations; +pub mod records; pub mod schema; diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs new file mode 100644 index 00000000..453e0ab2 --- /dev/null +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -0,0 +1,84 @@ +//! Manifest records built from fetched patch views: the ONE rule every +//! flow (get, scan, vendor, repair, hosted) uses to turn a `PatchResponse` +//! into the `PatchRecord` it persists. + +use std::collections::HashMap; + +use crate::api::types::{PatchResponse, VulnerabilityResponse}; +use crate::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; + +/// Convert the API-shaped vulnerability map on `PatchResponse` into the +/// serialization-shaped map stored in the manifest. +pub fn vulnerabilities_for_manifest( + vulns: &HashMap, +) -> HashMap { + vulns + .iter() + .map(|(id, v)| { + ( + id.clone(), + VulnerabilityInfo { + cves: v.cves.clone(), + summary: v.summary.clone(), + severity: v.severity.clone(), + description: v.description.clone(), + }, + ) + }) + .collect() +} + +/// Build the `PatchRecord` that will be inserted into the manifest for +/// `patch`. `files` is the (purl-keyed) before/after-hash map the +/// caller built — semantics for what counts as a "patchable file" differ +/// between the get and download flows, so the caller owns that decision. +pub fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { + PatchRecord { + uuid: patch.uuid.clone(), + exported_at: patch.published_at.clone(), + files, + vulnerabilities: vulnerabilities_for_manifest(&patch.vulnerabilities), + description: patch.description.clone(), + license: patch.license.clone(), + tier: patch.tier.clone(), + } +} + +/// Build the manifest-shaped `files` map from a fetched patch view, +/// keeping EVERY file the patch touches — including net-new files the +/// patch ADDS, which carry an `afterHash` but no `beforeHash`. A new +/// file is recorded with an empty-string `beforeHash` sentinel, the same +/// convention `save_and_apply_patch`'s by-uuid path relies on: apply +/// treats an empty `beforeHash` as "create this file" and +/// [`select_installed_variants`](crate::patch::apply::select_installed_variants) treats it as non-discriminating. +/// +/// This is the shared record-building rule for the scan/download/vendor +/// flows AND the single-uuid apply path, so `get ` and +/// `scan`/`apply`/`vendor` all record and write the same set of files. +/// A both-hashes rule here would drop every added file (e.g. a whole-crate +/// cargo export where ALL files lack a `beforeHash`, recorded as `files:{}` +/// while reporting `applied:1`). +pub fn files_for_manifest(patch: &PatchResponse) -> HashMap { + let mut files = HashMap::new(); + for (file_path, file_info) in &patch.files { + if let Some(after) = &file_info.after_hash { + files.insert( + file_path.clone(), + PatchFileInfo { + before_hash: file_info.before_hash.clone().unwrap_or_default(), + after_hash: after.clone(), + }, + ); + } + } + files +} + +/// `(purl, manifest record)` from a fetched patch view — retains +/// patch-added new files via [`files_for_manifest`]. +pub fn record_from_patch_response(patch: &PatchResponse) -> (String, PatchRecord) { + ( + patch.purl.clone(), + build_patch_record(patch, files_for_manifest(patch)), + ) +} diff --git a/crates/socket-patch-core/src/utils/cargo_workspace.rs b/crates/socket-patch-core/src/utils/cargo_workspace.rs index 3721f0de..38749c36 100644 --- a/crates/socket-patch-core/src/utils/cargo_workspace.rs +++ b/crates/socket-patch-core/src/utils/cargo_workspace.rs @@ -37,7 +37,10 @@ pub fn member_manifests(root: &Path) -> Vec { /// under it; symbolic links are never directories). pub fn member_manifests_in(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(root) => member_manifests(root), + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + member_manifests(root) + } ProjectView::Memory(project) => member_manifests_with(&MemoryTree(project)), } } 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 37caa503..4f4095b5 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs @@ -41,7 +41,11 @@ pub(super) async fn inventory_cargo_lock_raw_in( view: &ProjectView<'_>, ) -> Option> { let doc: std::sync::Arc = match view { - ProjectView::Disk(project_root) => { + ProjectView::Disk(project_root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: project_root, + .. + }) => { crate::vendor::cargo_lock::read_lock(project_root) .await .ok()? 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 1cacfe2e..78c68fb5 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -65,8 +65,8 @@ pub(crate) mod npm_family; pub(crate) mod pnpm; pub(crate) mod pypi; pub(crate) mod recover; -pub(crate) mod vlt; pub mod view; +pub(crate) mod vlt; pub(crate) mod wired; pub(crate) mod yarn; @@ -75,7 +75,7 @@ pub(crate) use self::npm::{npm_lock_nodes, NpmLockNode}; pub(crate) use self::npm_family::inventory_npm_lock; pub(crate) use self::pypi::pipfile_lock_entries; pub use self::recover::recover_lock_entry; -pub use self::view::{MemoryEntry, MemoryProject, ProjectView}; +pub use self::view::{DiskSnapshot, MemoryEntry, MemoryProject, ProjectView}; pub use self::wired::wired_vendor_integrity; // The per-format views `inventory_project_diagnosed` unions (and the test @@ -203,6 +203,39 @@ pub struct UnsupportedNpmLayout { pub detail: String, } +/// Map a core npm-layout refusal onto scan's warning channel as +/// `(code, detail)`. The yarn code matches apply's refusal errorCode +/// (`yarn_pnp_unsupported`) so consumers key on ONE name across commands; +/// the pnpm twin gets the parallel spelling. Details are scan-phrased (what +/// was NOT scanned + remedy) rather than the probe's vendor-phrased text. +pub fn unsupported_layout_warnings(unsupported: &[UnsupportedNpmLayout]) -> Vec<(String, String)> { + unsupported + .iter() + .map(|diag| match diag.code { + "vendor_yarn_berry_unsupported" => ( + "yarn_pnp_unsupported".to_string(), + "this project uses yarn Plug'n'Play (a `.pnp.*` loader is present): its npm \ + packages live inside `.yarn/cache/*.zip`, not `node_modules/`, so socket-patch \ + cannot discover or patch them in ANY mode (agent, hosted, or vendored) — npm \ + dependencies were NOT scanned. Use `yarn patch ` to patch them instead." + .to_string(), + ), + "vendor_pnpm_pnp_unsupported" => ( + "pnpm_pnp_unsupported".to_string(), + "this project uses pnpm's Plug'n'Play linker (`node-linker=pnp` in .npmrc): \ + lockfile discovery is skipped under this layout, so lockfile-only npm \ + dependencies were NOT scanned. Switch .npmrc to `node-linker=isolated`, run \ + `pnpm install`, and re-run — or use `socket-patch scan --mode hosted`, which \ + edits pnpm-lock.yaml in place." + .to_string(), + ), + // Forward-compat: a new refusal code surfaces verbatim rather + // than being swallowed back into silence. + other => (other.to_string(), diag.detail.clone()), + }) + .collect() +} + /// Match a manifest/API purl (possibly percent-encoded, possibly carrying /// qualifiers) against the inventory: components decode via /// [`crate::utils::purl::normalize_purl`], so `pkg:npm/%40scope/x@1` 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 27aefcef..f477d9cb 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs @@ -90,7 +90,11 @@ fn pnpm_lock_text_inventory(text: &str) -> Option> { /// it comes back empty. pub(super) async fn inventory_rush_pnpm_locks_in(view: &ProjectView<'_>) -> Vec { let project = match view { - ProjectView::Disk(project_root) => return inventory_rush_pnpm_locks(project_root).await, + ProjectView::Disk(project_root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: project_root, + .. + }) => return inventory_rush_pnpm_locks(project_root).await, ProjectView::Memory(project) => *project, }; if !project.contains("rush.json") { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs index 6bd242b5..4960652e 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -188,7 +188,10 @@ pub(super) async fn inventory_pypi_locks_in(view: &ProjectView<'_>) -> Option) -> std::io::Result> { match view { - ProjectView::Disk(root) => crate::utils::python_lock::python_lock_paths(root), + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + crate::utils::python_lock::python_lock_paths(root) + } ProjectView::Memory(project) => Ok(project .children("") .into_iter() 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 7161b358..db5105bf 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/view.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/view.rs @@ -2,7 +2,10 @@ //! ([`ProjectView::Disk`]) or an in-memory file map //! ([`ProjectView::Memory`]) handed in by a host that never materializes //! the repository (the hosted in-memory engine). The disk variant calls the -//! plain FIFO-safe filesystem readers. +//! plain FIFO-safe filesystem readers; the snapshot variant +//! ([`ProjectView::Snapshot`]) is the disk variant with each file's +//! content read at most once, so every reader of one run (the lock +//! inventory, lockfile discovery) sees the same bytes. use std::collections::{BTreeMap, BTreeSet}; use std::io; @@ -191,19 +194,118 @@ pub struct DirEntryInfo { pub is_dir: bool, } +/// Cached reads by root-relative path: the content, or the error kind and +/// message of the failed read. +type ReadCache = std::collections::HashMap, (io::ErrorKind, String)>>; + +/// A read-through cache over the files under `root`: the first read of a +/// path hits the disk, later ones return the same content (or the same +/// error). Everything that is not a content read (existence, file type, +/// directory listings, the disk-only probes) goes to the disk directly. +/// [`DiskSnapshot::invalidate`] drops what a write may have changed. +#[derive(Debug)] +pub struct DiskSnapshot<'a> { + pub root: &'a Path, + reads: std::sync::Mutex, +} + +impl<'a> DiskSnapshot<'a> { + pub fn new(root: &'a Path) -> Self { + Self { + root, + reads: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + /// Forget every cached read (after a write under `root`). + pub fn invalidate(&self) { + self.lock().clear(); + } + + fn lock(&self) -> std::sync::MutexGuard<'_, ReadCache> { + self.reads + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn cached(&self, rel: &str) -> Option>> { + self.lock().get(rel).map(|r| match r { + Ok(bytes) => Ok(Arc::clone(bytes)), + Err((kind, msg)) => Err(io::Error::new(*kind, msg.clone())), + }) + } + + fn remember(&self, rel: &str, read: &io::Result>) { + let entry = match read { + Ok(bytes) => Ok(Arc::<[u8]>::from(bytes.as_slice())), + Err(e) => Err((e.kind(), e.to_string())), + }; + self.lock().insert(rel.to_string(), entry); + } + + async fn read_bytes(&self, rel: &str) -> io::Result> { + if let Some(hit) = self.cached(rel) { + return hit.map(|b| b.to_vec()); + } + let read = read_regular_to_bytes(&self.root.join(rel)).await; + self.remember(rel, &read); + read + } + + fn read_bytes_sync(&self, rel: &str) -> io::Result> { + if let Some(hit) = self.cached(rel) { + return hit.map(|b| b.to_vec()); + } + let read = crate::utils::fs::read_regular_to_bytes_sync(&self.root.join(rel)); + self.remember(rel, &read); + read + } +} + +fn utf8(bytes: Vec) -> io::Result { + String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + /// Where the registry views read the project from. #[derive(Debug, Clone, Copy)] pub enum ProjectView<'a> { Disk(&'a Path), Memory(&'a MemoryProject), + /// The disk under a per-run read cache. + Snapshot(&'a DiskSnapshot<'a>), } impl ProjectView<'_> { + /// The checkout root when the view reads a real filesystem (the disk + /// and snapshot variants), for the probes that only exist on disk. + pub fn disk_root(&self) -> Option<&Path> { + match self { + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { + Some(root) + } + ProjectView::Memory(_) => None, + } + } + /// FIFO-safe regular-file text read. pub async fn read_text(&self, rel: &str) -> io::Result { match self { ProjectView::Disk(root) => read_regular_to_string(&root.join(rel)).await, ProjectView::Memory(project) => project.read_text(rel), + ProjectView::Snapshot(snap) => match snap.cached(rel) { + Some(hit) => hit.and_then(|b| utf8(b.to_vec())), + None => { + let read = read_regular_to_string(&snap.root.join(rel)).await; + snap.remember( + rel, + &read + .as_ref() + .map(|t| t.as_bytes().to_vec()) + .map_err(|e| io::Error::new(e.kind(), e.to_string())), + ); + read + } + }, } } @@ -212,6 +314,7 @@ impl ProjectView<'_> { match self { ProjectView::Disk(root) => read_regular_to_bytes(&root.join(rel)).await, ProjectView::Memory(project) => project.read_bytes(rel), + ProjectView::Snapshot(snap) => snap.read_bytes(rel).await, } } @@ -220,13 +323,16 @@ impl ProjectView<'_> { match self { ProjectView::Disk(root) => read_regular_to_string_sync(&root.join(rel)), ProjectView::Memory(project) => project.read_text(rel), + ProjectView::Snapshot(snap) => snap.read_bytes_sync(rel).and_then(utf8), } } /// `metadata` (follows links) succeeds. pub async fn exists(&self, rel: &str) -> bool { match self { - ProjectView::Disk(root) => tokio::fs::metadata(root.join(rel)).await.is_ok(), + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { + tokio::fs::metadata(root.join(rel)).await.is_ok() + } ProjectView::Memory(project) => project.contains(rel) || project.is_dir(rel), } } @@ -234,7 +340,9 @@ impl ProjectView<'_> { /// `symlink_metadata` (does not follow links) succeeds. pub async fn exists_no_follow(&self, rel: &str) -> bool { match self { - ProjectView::Disk(root) => tokio::fs::symlink_metadata(root.join(rel)).await.is_ok(), + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { + tokio::fs::symlink_metadata(root.join(rel)).await.is_ok() + } ProjectView::Memory(project) => project.contains(rel) || project.is_dir(rel), } } @@ -242,7 +350,9 @@ impl ProjectView<'_> { /// A regular file (following links on disk). pub fn is_file(&self, rel: &str) -> bool { match self { - ProjectView::Disk(root) => root.join(rel).is_file(), + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { + root.join(rel).is_file() + } ProjectView::Memory(project) => matches!( project.get(rel), Some(MemoryEntry::Text(_) | MemoryEntry::Binary(_) | MemoryEntry::Present) @@ -253,7 +363,7 @@ impl ProjectView<'_> { /// The path itself is a symbolic link. pub fn is_symlink(&self, rel: &str) -> bool { match self { - ProjectView::Disk(root) => { + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { std::fs::symlink_metadata(root.join(rel)).is_ok_and(|m| m.file_type().is_symlink()) } ProjectView::Memory(project) => project.is_symlink(rel), @@ -263,7 +373,7 @@ impl ProjectView<'_> { /// The UTF-8-named entries of directory `rel`, sorted by name. pub async fn list_dir(&self, rel: &str) -> io::Result> { match self { - ProjectView::Disk(root) => { + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { let mut dir = tokio::fs::read_dir(root.join(rel)).await?; let mut out = Vec::new(); while let Ok(Some(entry)) = dir.next_entry().await { @@ -298,7 +408,7 @@ pub(crate) async fn detect_npm_lock_flavor_in( view: &ProjectView<'_>, ) -> Result<(NpmLockFlavor, Vec), (&'static str, String)> { let project = match view { - ProjectView::Disk(root) => { + ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => { return crate::vendor::npm_flavor::detect_npm_lock_flavor(root).await } ProjectView::Memory(project) => *project, @@ -507,4 +617,30 @@ mod tests { "vendor_lockfile_missing" ); } + + #[tokio::test] + async fn a_snapshot_reads_each_file_once_until_invalidated() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("a.lock"), "one").unwrap(); + let snap = DiskSnapshot::new(tmp.path()); + let view = ProjectView::Snapshot(&snap); + assert_eq!(view.read_text("a.lock").await.unwrap(), "one"); + std::fs::write(tmp.path().join("a.lock"), "two").unwrap(); + assert_eq!(view.read_text("a.lock").await.unwrap(), "one", "cached"); + assert_eq!(view.read_bytes("a.lock").await.unwrap(), b"one"); + assert_eq!(view.read_text_sync("a.lock").unwrap(), "one"); + let missing = view.read_text("b.lock").await.unwrap_err(); + assert_eq!(missing.kind(), io::ErrorKind::NotFound); + std::fs::write(tmp.path().join("b.lock"), "late").unwrap(); + assert_eq!( + view.read_text("b.lock").await.unwrap_err().kind(), + io::ErrorKind::NotFound, + "a cached miss stays a miss" + ); + assert!(view.exists("b.lock").await, "non-read probes go to disk"); + snap.invalidate(); + assert_eq!(view.read_text("a.lock").await.unwrap(), "two"); + assert_eq!(view.read_text("b.lock").await.unwrap(), "late"); + assert_eq!(view.disk_root(), Some(tmp.path())); + } } diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 9a923263..be657f84 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -707,7 +707,22 @@ pub async fn discover_patched_refs(root: &Path) -> Discovery { /// See the module docs for the contract; the extractor order below is fixed /// only for deterministic diagnostics — refs are sorted afterwards. pub async fn discover_patched_refs_with(root: &Path, opts: &DiscoverOptions) -> Discovery { - let ctx = DiscoverCtx::with_origins(root, &opts.patch_server_origins); + discover_with_ctx(DiscoverCtx::with_origins(root, &opts.patch_server_origins)).await +} + +/// [`discover_patched_refs_with`] reading the lock and config files through +/// `snapshot`, so a run that also inventories the lockfiles reads each file +/// once. +pub async fn discover_patched_refs_in( + snapshot: &crate::vendor::lock_inventory::DiskSnapshot<'_>, + opts: &DiscoverOptions, +) -> Discovery { + let mut ctx = DiscoverCtx::with_origins(snapshot.root, &opts.patch_server_origins); + ctx.view = crate::vendor::lock_inventory::ProjectView::Snapshot(snapshot); + discover_with_ctx(ctx).await +} + +async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { let mut out = Discovery::default(); npm::extract(&ctx, &mut out).await; yarn::extract(&ctx, &mut out).await; @@ -737,6 +752,9 @@ fn is_script_lock(file: &Path) -> bool { /// allowlist, with the guarded-read and identity helpers bolted on. pub(crate) struct DiscoverCtx<'a> { pub(crate) root: &'a Path, + /// Where the guarded reads read from: `root` on disk, or a per-run + /// snapshot of it. + view: crate::vendor::lock_inventory::ProjectView<'a>, patch_server_origins: &'a [String], /// What the guarded reads have recognized so far (rule 11) — collected /// here, not in the extractor's `&mut Discovery`, so a read into a @@ -749,6 +767,7 @@ impl<'a> DiscoverCtx<'a> { pub(crate) fn with_origins(root: &'a Path, patch_server_origins: &'a [String]) -> Self { DiscoverCtx { root, + view: crate::vendor::lock_inventory::ProjectView::Disk(root), patch_server_origins, recognized: Mutex::new(BTreeSet::new()), } @@ -813,7 +832,7 @@ impl<'a> DiscoverCtx<'a> { /// ledger claim it alone keeps textually "alive" must be dead (rule 11). /// Quiet — a missing or unreadable ignored file is not a finding. pub(crate) async fn recognize_ignored(&self, rel: &str) { - if let Ok(bytes) = crate::utils::fs::read_regular_to_bytes(&self.root.join(rel)).await { + if let Ok(bytes) = self.view.read_bytes(rel).await { self.recognize_text(rel, &String::from_utf8_lossy(&bytes)); } } @@ -828,9 +847,7 @@ impl<'a> DiscoverCtx<'a> { /// Whether `rel` exists (lstat — a dangling symlink still "exists", the /// read then fails and diagnoses). pub(crate) async fn exists(&self, rel: &str) -> bool { - tokio::fs::symlink_metadata(self.root.join(rel)) - .await - .is_ok() + self.view.exists_no_follow(rel).await } /// Guarded UTF-8 read of root-relative `rel`: `None` when missing @@ -839,7 +856,7 @@ impl<'a> DiscoverCtx<'a> { /// parsing (rule 11) — so a file that then fails to parse, or an entry /// the extractor rejects or skips, is still recognized. pub(crate) async fn read_text(&self, rel: &str, out: &mut Discovery) -> Option { - match crate::utils::fs::read_regular_to_string(&self.root.join(rel)).await { + match self.view.read_text(rel).await { Ok(text) => { self.recognize_text(rel, &text); Some(text) @@ -862,7 +879,7 @@ impl<'a> DiscoverCtx<'a> { /// left in `bun.lockb`'s append-only pool names a DEAD patch, which is /// exactly what recognition should say about it. pub(crate) async fn read_bytes(&self, rel: &str, out: &mut Discovery) -> Option> { - match crate::utils::fs::read_regular_to_bytes(&self.root.join(rel)).await { + match self.view.read_bytes(rel).await { Ok(bytes) => { self.recognize_text(rel, &String::from_utf8_lossy(&bytes)); Some(bytes) @@ -2948,7 +2965,10 @@ mod tests { "uv.lock", ], ), - ("cargo", &[".cargo/config", ".cargo/config.toml", "Cargo.toml"]), + ( + "cargo", + &[".cargo/config", ".cargo/config.toml", "Cargo.toml"], + ), ("golang", &["go.mod"]), ("gem", &["Gemfile.lock"]), ("composer", &["composer.lock"]), diff --git a/crates/socket-patch-core/src/vex/mod.rs b/crates/socket-patch-core/src/vex/mod.rs index 3e82c73c..561263f3 100644 --- a/crates/socket-patch-core/src/vex/mod.rs +++ b/crates/socket-patch-core/src/vex/mod.rs @@ -26,8 +26,9 @@ pub mod verify; pub use build::{build_document, BuildOptions}; pub use discover::{ - canonical_base_purl, discover_patched_refs, discover_patched_refs_with, Diag, DiscoverOptions, - Discovery, PatchedRef, Recognized, UnlockedPin, WiringMode, + canonical_base_purl, discover_patched_refs, discover_patched_refs_in, + discover_patched_refs_with, Diag, DiscoverOptions, Discovery, PatchedRef, Recognized, + UnlockedPin, WiringMode, }; pub use product::{detect_product, DetectResult}; pub use schema::{ diff --git a/crates/socket-patch-node/Cargo.toml b/crates/socket-patch-node/Cargo.toml index d95cb71a..797ee2cc 100644 --- a/crates/socket-patch-node/Cargo.toml +++ b/crates/socket-patch-node/Cargo.toml @@ -12,13 +12,13 @@ name = "socket_patch_node" path = "src/lib.rs" crate-type = ["cdylib"] # A cdylib that links against the host's `napi_*` symbols has nothing to -# test outside Node; the engine's tests live in socket-patch-cli and the +# test outside Node; the engine's tests live in socket-patch-core (unit) and +# socket-patch-cli (disk parity), and the # addon's in npm/test/smoke.mjs. test = false doctest = false [dependencies] -socket-patch-cli = { path = "../socket-patch-cli" } socket-patch-core = { workspace = true } napi = { workspace = true } napi-derive = { workspace = true } diff --git a/crates/socket-patch-node/src/lib.rs b/crates/socket-patch-node/src/lib.rs index 5f1c8ed7..d83403b8 100644 --- a/crates/socket-patch-node/src/lib.rs +++ b/crates/socket-patch-node/src/lib.rs @@ -1,5 +1,5 @@ //! Node addon for the in-memory hosted redirect engine -//! (`socket_patch_cli::hosted_memory`). +//! (`socket_patch_core::hosted::memory`). //! //! This is the private native half of `@socketsecurity/socket-patch-node`; //! npm/index.js is the public surface (npm/index.d.ts). Options, tree @@ -15,8 +15,8 @@ use std::sync::Arc; use napi::bindgen_prelude::{Buffer, External, Function, JsObjectValue, Object, PromiseRaw}; use napi::{Env, Status}; use napi_derive::napi; -use socket_patch_cli::hosted_memory::{ - self, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, SelectOptions, +use socket_patch_core::hosted::memory::{ + self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, SelectOptions, SessionBuilder, TreeEntryInput, }; use socket_patch_core::api::client::PatchApi;