From 8a277fb134fa38cf0b2a551e85349974ee247166 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:41:44 +0000 Subject: [PATCH 1/7] Share one hosted engine between disk and memory Extract the plan -> rewrite -> edits stages of `run_redirect_selected` into `socket_patch_core::hosted::engine`, a set of pure functions over a `ProjectView` (build_candidates, bun_lockb_symlinked, withhold_everywhere, read_candidate_files, wheel_targets, rewrite, guard). The disk flow (`scan`/`get --mode hosted`) keeps only the apply lock, the host probes (pipenv version, gem/python/vlt stale installs), the vendored takeover, the symlink refusal and the commit of the rewritten files. The in-memory engine moves to `socket_patch_core::hosted::memory` and runs the same stages over `ProjectView::Memory`; its duplicated redirect.rs / ledger.rs orchestration is deleted. The pnpm trust / npm allow-remote planners move to `hosted::guidance`, the vlt preflight to `hosted::vlt`, and the redirect-ledger delta to `hosted::ledger`, which the engine never calls, so removing the hosted ledger only touches the two callers. `socket-patch-node` now depends on socket-patch-core only; the CLI re-exports `hosted_memory` for `hosted-bundle` and the tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- Cargo.lock | 2 +- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- crates/socket-patch-cli/src/commands/get.rs | 79 +- .../src/commands/scan/discovery.rs | 35 +- .../src/commands/scan/hosted.rs | 2109 ++--------------- .../src/commands/scan/hosted/vlt.rs | 184 +- .../src/hosted_memory/ledger.rs | 187 -- .../src/hosted_memory/redirect.rs | 1257 ---------- crates/socket-patch-cli/src/lib.rs | 5 +- .../tests/covgap_commands_rollback.rs | 3 + crates/socket-patch-core/Cargo.toml | 2 + crates/socket-patch-core/src/hosted/engine.rs | 1819 ++++++++++++++ .../socket-patch-core/src/hosted/guidance.rs | 434 ++++ crates/socket-patch-core/src/hosted/ledger.rs | 311 +++ .../src/hosted/memory}/discover.rs | 14 +- .../src/hosted/memory}/limits.rs | 2 +- .../src/hosted/memory}/mod.rs | 131 +- .../src/hosted/memory}/roots.rs | 2 +- .../src/hosted/memory}/select.rs | 14 +- .../src/hosted/memory/stages.rs | 343 +++ .../src/hosted/memory}/types.rs | 19 +- crates/socket-patch-core/src/hosted/mod.rs | 21 + crates/socket-patch-core/src/hosted/vlt.rs | 211 ++ crates/socket-patch-core/src/lib.rs | 1 + crates/socket-patch-core/src/manifest/mod.rs | 1 + .../socket-patch-core/src/manifest/records.rs | 84 + .../src/vendor/lock_inventory/mod.rs | 35 + crates/socket-patch-node/Cargo.toml | 4 +- crates/socket-patch-node/src/lib.rs | 6 +- 29 files changed, 3544 insertions(+), 3773 deletions(-) delete mode 100644 crates/socket-patch-cli/src/hosted_memory/ledger.rs delete mode 100644 crates/socket-patch-cli/src/hosted_memory/redirect.rs create mode 100644 crates/socket-patch-core/src/hosted/engine.rs create mode 100644 crates/socket-patch-core/src/hosted/guidance.rs create mode 100644 crates/socket-patch-core/src/hosted/ledger.rs rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/discover.rs (97%) rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/limits.rs (99%) rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/mod.rs (91%) rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/roots.rs (99%) rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/select.rs (97%) create mode 100644 crates/socket-patch-core/src/hosted/memory/stages.rs rename crates/{socket-patch-cli/src/hosted_memory => socket-patch-core/src/hosted/memory}/types.rs (97%) create mode 100644 crates/socket-patch-core/src/hosted/mod.rs create mode 100644 crates/socket-patch-core/src/hosted/vlt.rs create mode 100644 crates/socket-patch-core/src/manifest/records.rs 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 55a3983a..6327298e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -29,7 +29,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/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 76fe2a8b..40369f8f 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -12,8 +12,10 @@ use socket_patch_core::api::types::{ use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::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}; @@ -365,43 +367,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 @@ -425,44 +390,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 { diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index d9d51ea7..b70af594 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -45,40 +45,7 @@ pub(super) struct LockfileSupplement { pub(super) 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 diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 156f8bf4..b96588f1 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -26,81 +26,20 @@ pub(crate) mod vlt; pub(crate) use vlt::rollback_heal as vlt_rollback_heal; pub(crate) use vlt::takeover_heal as vlt_takeover_heal; -/// Fragment-edit kinds whose lockfile the package manager re-lays in place -/// (keeping the Socket source) — a re-scan REBASES their ledger edits instead -/// of appending; see the ledger merge below. -pub(crate) const REBASE_KINDS: &[&str] = &[ - "redirect_poetry_lock_package", - "redirect_pdm_lock_package", - socket_patch_core::patch::redirect::vlt::KIND, -]; - -/// Candidate lockfiles / registry configs the redirect rewriters may touch — -/// read from the project when present and handed to `rewrite_registry_redirect`. -pub(crate) const REDIRECT_CANDIDATE_FILES: &[&str] = &[ - "package-lock.json", - "npm-shrinkwrap.json", - "pnpm-lock.yaml", - // pnpm <=2 uses the same package identities under the old filename. - "shrinkwrap.yaml", - "node_modules/.modules.yaml", - "yarn.lock", - // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is - // `bun.lock`; binary locks are read separately below. - ".yarnrc.yml", - "bun.lock", - "bun.lockb", - // vlt: the lock is rewritten, vlt.json is read-only (the old-lockfile - // advisory), and the hidden lock is only stat'ed as the install-state - // sentinel. - "vlt-lock.json", - "vlt.json", - "node_modules/.vlt-lock.json", - "requirements.txt", - "uv.lock", - "poetry.lock", - "pdm.lock", - "Pipfile.lock", - "pyproject.toml", - "hatch.toml", - "Cargo.toml", - "Cargo.lock", - ".cargo/config.toml", - // The LEGACY extensionless spelling: cargo reads `.cargo/config` in - // preference to `config.toml` when both exist, so the rewriter must see - // it (it wires the managed registry into whichever one is present) — - // otherwise the `[registries.…]` block lands in a file cargo ignores. - ".cargo/config", - "composer.lock", - "nuget.config", - "packages.lock.json", - "Gemfile", - "Gemfile.lock", - // Bundler's modern manifest spelling — preferred over Gemfile when both - // exist (the gem rewriter picks the pair bundler reads and fails closed - // on diverging spellings). - "gems.rb", - "gems.locked", - // The golang rewriter edits the main module's go.mod (fork-style - // `replace`) and go.sum (the socket module's two h1: lines). go.sum may - // legitimately be absent — the rewriter creates it in that case. - "go.mod", - "go.sum", - "pom.xml", - // Maven Trusted Checksums files the fail-closed maven rewriter merges into - // (read so an existing user config / checksum set is preserved, not - // clobbered). - ".mvn/maven.config", - ".mvn/checksums/checksums.sha256", - // Gradle build scripts are never edited — their presence only feeds the - // maven rewriter's paste-able `exclusiveContent` snippet warning. - "settings.gradle", - "settings.gradle.kts", - "build.gradle", - "build.gradle.kts", - // deno.lock is deliberately absent: no redirect rewriter edits its - // integrity entries. -]; +pub(crate) use socket_patch_core::hosted::engine::redirect_json_block; +#[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, +}; +#[cfg(test)] +pub(crate) use socket_patch_core::hosted::ledger::{rebase_vlt_edits, REBASE_KINDS}; /// 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 @@ -118,437 +57,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 redirect 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 redirect 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 redirect 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 \ - redirected (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 redirect 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), - ) -} - -/// `lockfileVersion` major sniffed from a pnpm-lock.yaml head. pnpm 9-12 -/// emit `lockfileVersion: '9.0'` (single doc, first line); pnpm 8 emits -/// `'6.0'`, pnpm 7 an unquoted `5.4`. `None` when no parseable version line -/// exists — callers treat that as "not trust-policy era" and stay -/// hands-off (fail closed: never write config for a lock we can't read). -pub(crate) fn pnpm_lock_version_major(lock_text: &str) -> Option { - lock_text.lines().find_map(|line| { - let rest = line.strip_prefix("lockfileVersion:")?; - let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); - value.split('.').next()?.parse::().ok() - }) -} - -/// Whether a pnpm lock may belong to pnpm 1–4, which spell the store flag -/// `--store` (pnpm 1–3 can silently ignore `--store-dir`; early pnpm 4 -/// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion -/// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. -pub(crate) fn pnpm_lock_may_need_store_flag(lock_text: &str) -> bool { - lock_text.lines().any(|line| { - if line.starts_with("shrinkwrapVersion:") { - return true; - } - let Some(rest) = line.strip_prefix("lockfileVersion:") else { - return false; - }; - let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); - let mut parts = value.split('.'); - let major = parts.next().and_then(|m| m.parse::().ok()); - let minor = parts - .next() - .and_then(|m| m.parse::().ok()) - .unwrap_or(0); - major == Some(5) && minor <= 2 - }) -} - -/// The 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 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 @@ -603,29 +111,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 { @@ -646,18 +131,23 @@ fn refuse_symlinked_file( scan_result: Option, linked: &str, ) -> 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}"); + refuse( + common, + scan_result, + &socket_patch_core::hosted::engine::symlink_refusal(linked), + ) +} + +/// 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, + refusal: &socket_patch_core::hosted::engine::Refusal, +) -> i32 { + 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 } @@ -1118,85 +608,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` @@ -1211,24 +641,15 @@ 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, RedirectState, + use socket_patch_core::hosted::engine::{ + self, Candidate, CandidateFiles, RewriteOptions, SkippedPatch, TakeoverPreview, }; + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::patch::redirect::RedirectState; + 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 @@ -1257,128 +678,14 @@ 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 @@ -1393,18 +700,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 @@ -1506,9 +806,9 @@ pub(crate) async fn run_redirect_selected( // 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(); + // The withheld dry-run takeover candidates' artifact URLs and wired root locks, + // filled when they leave the rewrite set below. + let mut dry_run_takeover_urls: Vec = Vec::new(); if !candidates.iter().any(|c| takeover_capable(&c.purl)) { // No takeover-capable candidates — nothing to reconcile. } else { @@ -1835,9 +1135,7 @@ pub(crate) async fn run_redirect_selected( 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, - })); + skipped.push(SkippedPatch::new(purl, &c.dep.patch_uuid, reason)); } } // Purls leaving the rewrite set: refused takeovers, plus the dry-run @@ -1857,108 +1155,26 @@ pub(crate) async fn run_redirect_selected( .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())); + dry_run_takeover_urls.push(TakeoverPreview { + artifact_url: c.dep.artifact_url.clone(), + locks: 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 { - 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(); @@ -1966,57 +1182,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()), ) { @@ -2084,29 +1256,20 @@ 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, @@ -2121,627 +1284,45 @@ pub(crate) async fn run_redirect_selected( } (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 + // 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() + }) }; - // 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, + 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, - 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_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 ), - }); - } - - // 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 redirect 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() - }; - 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"), - }) - .map(|(c, _)| (c.purl.clone(), c.dep.patch_uuid.clone())) - .collect(); + 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 @@ -2753,25 +1334,12 @@ pub(crate) async fn run_redirect_selected( 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); + // 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 { @@ -2788,7 +1356,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) + }) }) }) }, @@ -2803,136 +1373,37 @@ 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} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `socket-patch scan --mode \ - hosted` is re-run" - ), - })); + record_warnings.push(engine::record_fetch_failed_warning(purl)); } } } status.finish(); } + let rewrite = &done.rewrite; // Whether this run persisted the redirect ledger (human next steps). let mut ledger_written = false; if !common.dry_run { // Ledger (mirrors the vendor state.json shape): recorded edits for a // future revert + the patch records (file hashes + vulnerabilities) so // a post-install `socket-patch vex` can attest the redirected patches. - // MERGE with any existing ledger rather than overwriting: an idempotent - // re-run produces no new edits (the lockfile already points at the - // hosted patch), and clobbering the file would lose the original - // pre-redirect values a future revert needs. New edits APPEND (revert - // walks them in reverse), skipping byte-identical re-plans from a - // retried partial failure; records are keyed by PURL, newest wins. + // MERGE with any existing ledger rather than overwriting (see + // `hosted::ledger::merge`): an idempotent re-run produces no new + // edits (the lockfile already points at the hosted patch), and + // clobbering the file would lose the original pre-redirect values a + // future revert needs. // // Persisted BEFORE the project files, and atomically (stage + fsync + // rename): a crash between the two leaves a complete ledger whose // originals match files never rewritten, never rewritten files whose // originals reached no ledger. if !rewrite.edits.is_empty() || !records.is_empty() { - // Older ledgers carry `"mode": "redirect"`; normalize on rewrite - // (the loader accepts either). - ledger.mode = "hosted".to_string(); - // REBASE instead of append for fragment kinds whose file the - // package manager itself rewrites in place: when the ledger already - // holds edits for the same (path, kind, key) and the file no longer - // carried their `new` fragments before this run (Poetry 1.1/1.2 - // `poetry lock --no-update` keeps the Socket source but re-lays the - // unit and drops the inserted `files` line), appending this run's - // edits — recorded against the RELOCKED text — would build a chain - // whose older links match nothing, so rollback and remove refuse - // forever. Keeping the oldest `original` (the pristine - // fragment) and adopting the fresh `new` keeps the chain a single - // invertible link: replay swaps the fragment this run wrote back to - // the fragment the very first run found. - let vlt_merged = rebase_vlt_edits( - &mut ledger.edits, + socket_patch_core::hosted::ledger::merge( + &mut ledger, &rewrite.edits, - files - .get(socket_patch_core::constants::npm_family::VLT_LOCK) - .map(String::as_str), + records, + &done.files, ); - let mut rebased: Vec = Vec::new(); - for edit in rewrite.edits.iter().filter(|e| { - REBASE_KINDS.contains(&e.kind.as_str()) - && e.kind != socket_patch_core::patch::redirect::vlt::KIND - }) { - let siblings: Vec = ledger - .edits - .iter() - .enumerate() - .filter(|(_, old)| { - old.path == edit.path && old.kind == edit.kind && old.key == edit.key - }) - .map(|(i, _)| i) - .collect(); - let before = files.get(&edit.path).map(String::as_str).unwrap_or(""); - let drifted = !siblings.is_empty() - && siblings.iter().all(|&i| { - ledger.edits[i] - .new - .as_ref() - .and_then(serde_json::Value::as_str) - .is_none_or(|new| !before.contains(new)) - }); - if !drifted { - continue; - } - // Positional pairing: the rewriter emits a key's fragments in a - // fixed order (package unit, then the legacy integrity entry). - let nth = rewrite - .edits - .iter() - .filter(|e| e.path == edit.path && e.kind == edit.kind && e.key == edit.key) - .position(|e| std::ptr::eq(e, edit)) - .unwrap_or(0); - if let Some(&target) = siblings.get(nth) { - if !rebased.contains(&target) { - // `pdm lock` fully un-patches the lock (registry source - // restored) and may reflow line endings (CRLF → LF), so - // the fresh run's `original` IS the correct - // relocked-registry rollback target and the stale - // recorded one would restore a mismatched fragment. - // Poetry's relock instead KEEPS the Socket source (it - // only drops the inserted `files` line), so its oldest - // `original` — the true pre-patch fragment — must - // survive; only its `new` is refreshed. - if edit.kind == "redirect_pdm_lock_package" { - ledger.edits[target].original = edit.original.clone(); - } - ledger.edits[target].new = edit.new.clone(); - ledger.edits[target].action = edit.action.clone(); - rebased.push(target); - } - } - } - // Dedup against the ledger as this run found it, never within - // this run: one run legitimately records identical edits (a - // Cargo.toml declaring the crate with the same line in two - // sections), and each one reverts one occurrence. - let recorded = ledger.edits.len(); - for (i, edit) in rewrite.edits.iter().enumerate() { - if vlt_merged[i] { - continue; - } - let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) - && rebased.iter().any(|&t| { - let old = &ledger.edits[t]; - old.path == edit.path - && old.kind == edit.kind - && old.key == edit.key - && old.new == edit.new - }); - if !is_rebased && !ledger.edits[..recorded].contains(edit) { - ledger.edits.push(edit.clone()); - } - } - ledger.records.extend(records); // The ledger is the only revert path and the VEX record store — // a swallowed write failure would let the lockfile writes below // proceed with no revert data persisted while reporting success. @@ -2987,7 +1458,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| { @@ -3035,7 +1507,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: &ledger.records, @@ -3150,20 +1622,12 @@ 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(); + let mut warnings: Vec = engine::rewrite_warnings_json(&rewrite.warnings); 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()); + warnings.extend(done.rush_warnings.iter().cloned()); + warnings.extend(done.pnpm_warnings.iter().cloned()); + warnings.extend(done.npm_warnings.iter().cloned()); warnings.extend(gem_stale.warnings.iter().cloned()); warnings.extend(python_stale.warnings.iter().cloned()); warnings.extend(vlt_stale.warnings.iter().cloned()); @@ -3178,8 +1642,8 @@ 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(SkippedPatch::to_json).collect(), warnings, common.dry_run, ); @@ -3220,7 +1684,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(); @@ -3248,12 +1712,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. @@ -3266,11 +1725,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( @@ -3285,7 +1740,7 @@ pub(crate) async fn run_redirect_selected( eprintln!("{line}"); } for (code, detail) in &human_warnings { - let detail = if *code == "redirect_pnpm_trust_lockfile" && pnpm_rerun_only { + let detail = if *code == "redirect_pnpm_trust_lockfile" && done.pnpm_rerun_only { pnpm_trust_rerun_reminder() } else { detail @@ -3660,91 +2115,6 @@ fn format_next_steps( steps } -/// Merge this run's vlt node edits into the recorded ones. A fresh edit -/// for the same `key` and DepID keeps the oldest recorded `original` (the -/// pristine registry entry), takes the fresh `new` and drops the chain's -/// later links (a server-written ledger appends one per hosted PR). One -/// whose recorded same-key edits all name DepIDs the pre-run lock no longer -/// holds (a re-lock, or a new id grammar after a vlt upgrade) replaces -/// them. So does one for another key of the same `name@version` whose -/// vanished recorded edit's pin vlt carried to the fresh DepID (a new peer -/// context). A replacing edit keeps the recorded pristine slots when vlt -/// carried the pin ([`carried_pin_original`]). Returns, per fresh edit, -/// whether it was merged (anything else is appended as usual). -pub(crate) fn rebase_vlt_edits( - ledger: &mut Vec, - fresh: &[socket_patch_core::patch::redirect::FileEdit], - before_lock: Option<&str>, -) -> Vec { - use socket_patch_core::patch::redirect::vlt::{ - carried_pin_original, edit_dep_id, lock_node_ids, KIND, - }; - use socket_patch_core::patch::redirect::FileEdit; - fn superseding(edit: &FileEdit, old: &FileEdit) -> FileEdit { - let mut next = edit.clone(); - if let Some(original) = carried_pin_original(edit, old) { - next.original = Some(original); - } - next - } - fn key_base(key: &Option) -> Option<&str> { - key.as_deref() - .map(|k| k.split_once('~').map_or(k, |(base, _)| base)) - } - let live = before_lock.and_then(lock_node_ids).unwrap_or_default(); - let mut merged = vec![false; fresh.len()]; - for (i, edit) in fresh.iter().enumerate() { - if edit.kind != KIND { - continue; - } - let id = edit_dep_id(edit); - let same_key: Vec = ledger - .iter() - .enumerate() - .filter(|(_, old)| old.kind == KIND && old.path == edit.path && old.key == edit.key) - .map(|(j, _)| j) - .collect(); - let same_dep: Vec = same_key - .iter() - .copied() - .filter(|&j| id.is_some() && edit_dep_id(&ledger[j]) == id) - .collect(); - if let Some((&first, rest)) = same_dep.split_first() { - ledger[first].new = edit.new.clone(); - ledger[first].action = edit.action.clone(); - for &j in rest.iter().rev() { - ledger.remove(j); - } - merged[i] = true; - continue; - } - let vanished = |j: &usize| edit_dep_id(&ledger[*j]).is_none_or(|old| !live.contains(&old)); - let gone: Vec = same_key.into_iter().filter(vanished).collect(); - if let Some((&first, rest)) = gone.split_first() { - ledger[first] = superseding(edit, &ledger[first]); - for &j in rest.iter().rev() { - ledger.remove(j); - } - merged[i] = true; - continue; - } - let rekeyed = (0..ledger.len()).find(|j| { - let old = &ledger[*j]; - old.kind == KIND - && old.path == edit.path - && old.key != edit.key - && key_base(&old.key) == key_base(&edit.key) - && vanished(j) - && carried_pin_original(edit, old).is_some() - }); - if let Some(j) = rekeyed { - ledger[j] = superseding(edit, &ledger[j]); - merged[i] = true; - } - } - merged -} - /// Transient-frame boxed constructor for [`run_redirect_selected`] — the /// future embeds the whole hosted engine, and callers outside scan (`get /// --mode hosted`) must not materialize it in their own poll frame (Windows @@ -3771,6 +2141,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>( #[cfg(test)] mod tests { + use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, installed_stale_positive_evidence, @@ -3781,7 +2152,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, @@ -5740,171 +4111,3 @@ mod tests { } } } - -/// 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, 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 d819af6b..4034ddb8 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -7,92 +7,20 @@ 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, + VLT_HIDDEN_LOCK_REL, VLT_LOCK, }; 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::hosted::vlt::{self as hosted_vlt, Preflight}; +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 +42,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) + hosted_vlt::judge(&plan, deps, 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 -} - -/// 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 diff --git a/crates/socket-patch-cli/src/hosted_memory/ledger.rs b/crates/socket-patch-cli/src/hosted_memory/ledger.rs deleted file mode 100644 index 2961d107..00000000 --- a/crates/socket-patch-cli/src/hosted_memory/ledger.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! The redirect ledger (`.socket/vendor/redirect-state.json`) in memory: -//! loaded strictly (a malformed ledger is a project error, never a fresh -//! start), merged exactly like the disk flow (edits appended unless already -//! recorded, `REBASE_KINDS` rebased, records extended newest-wins), and -//! serialized with the disk writer's bytes (`to_vec_pretty` + `\n`). - -use std::collections::BTreeMap; - -use socket_patch_core::manifest::schema::PatchRecord; -use socket_patch_core::patch::redirect::{ - CorruptRedirectState, FileEdit, RedirectState, REDIRECT_STATE_REL, -}; -use socket_patch_core::vendor::lock_inventory::{MemoryEntry, MemoryProject}; - -use crate::commands::scan::hosted::{rebase_vlt_edits, REBASE_KINDS}; - -/// Load the project's ledger: `Ok(None)` when absent, `Err` (the disk -/// message) when present but unreadable or malformed. -pub(crate) fn load(project: &MemoryProject, root: &str) -> Result, String> { - let path = super::roots::join_root(root, REDIRECT_STATE_REL); - let corrupt = |detail: String, unreadable: bool| { - CorruptRedirectState { - path: path.clone().into(), - detail, - quarantined_to: None, - unreadable, - } - .to_string() - }; - let bytes: &[u8] = match project.get(REDIRECT_STATE_REL) { - None => return Ok(None), - Some(MemoryEntry::Text(text)) => text.as_bytes(), - Some(MemoryEntry::Binary(bytes)) => bytes, - Some(MemoryEntry::Present) => { - return Err(corrupt("file content was not provided".into(), true)) - } - Some(MemoryEntry::Symlink) => return Err(corrupt("is a symbolic link".into(), true)), - }; - serde_json::from_slice(bytes) - .map(Some) - .map_err(|e| corrupt(format!("invalid JSON: {e}"), false)) -} - -/// Merge this run's `edits` and `records` into `ledger` (the disk flow's -/// merge, verbatim). `files` are the pre-rewrite candidate contents the -/// rebase drift check reads. -pub(crate) fn merge( - ledger: &mut RedirectState, - edits: &[FileEdit], - records: BTreeMap, - files: &BTreeMap, -) { - ledger.mode = "hosted".to_string(); - let vlt_merged = rebase_vlt_edits( - &mut ledger.edits, - edits, - files - .get(socket_patch_core::constants::npm_family::VLT_LOCK) - .map(String::as_str), - ); - let mut rebased: Vec = Vec::new(); - for edit in edits.iter().filter(|e| { - REBASE_KINDS.contains(&e.kind.as_str()) - && e.kind != socket_patch_core::patch::redirect::vlt::KIND - }) { - let siblings: Vec = ledger - .edits - .iter() - .enumerate() - .filter(|(_, old)| { - old.path == edit.path && old.kind == edit.kind && old.key == edit.key - }) - .map(|(i, _)| i) - .collect(); - let before = files.get(&edit.path).map(String::as_str).unwrap_or(""); - let drifted = !siblings.is_empty() - && siblings.iter().all(|&i| { - ledger.edits[i] - .new - .as_ref() - .and_then(serde_json::Value::as_str) - .is_none_or(|new| !before.contains(new)) - }); - if !drifted { - continue; - } - let nth = edits - .iter() - .filter(|e| e.path == edit.path && e.kind == edit.kind && e.key == edit.key) - .position(|e| std::ptr::eq(e, edit)) - .unwrap_or(0); - if let Some(&target) = siblings.get(nth) { - if !rebased.contains(&target) { - if edit.kind == "redirect_pdm_lock_package" { - ledger.edits[target].original = edit.original.clone(); - } - ledger.edits[target].new = edit.new.clone(); - ledger.edits[target].action = edit.action.clone(); - rebased.push(target); - } - } - } - let recorded = ledger.edits.len(); - for (i, edit) in edits.iter().enumerate() { - if vlt_merged[i] { - continue; - } - let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) - && rebased.iter().any(|&t| { - let old = &ledger.edits[t]; - old.path == edit.path - && old.kind == edit.kind - && old.key == edit.key - && old.new == edit.new - }); - if !is_rebased && !ledger.edits[..recorded].contains(edit) { - ledger.edits.push(edit.clone()); - } - } - ledger.records.extend(records); -} - -/// The ledger's on-disk bytes. -pub(crate) fn serialize(ledger: &RedirectState) -> Result { - let mut bytes = serde_json::to_vec_pretty(ledger).map_err(|e| e.to_string())?; - bytes.push(b'\n'); - String::from_utf8(bytes).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn edit(kind: &str, new: &str) -> FileEdit { - FileEdit { - path: "poetry.lock".into(), - kind: kind.into(), - action: "replaced".into(), - key: Some("k".into()), - original: Some(serde_json::json!("orig")), - new: Some(serde_json::json!(new)), - } - } - - #[test] - fn corrupt_and_absent_ledgers() { - let mut p = MemoryProject::new(); - assert!(load(&p, "").unwrap().is_none()); - p.insert_text(REDIRECT_STATE_REL, "{not json"); - let err = load(&p, "sub").unwrap_err(); - assert!( - err.contains("sub/.socket/vendor/redirect-state.json"), - "{err}" - ); - assert!(err.contains("malformed"), "{err}"); - p.insert_symlink(REDIRECT_STATE_REL); - assert!(load(&p, "").unwrap_err().contains("cannot be read")); - } - - #[test] - fn merge_appends_new_edits_and_rebases_drifted_fragments() { - let mut ledger = RedirectState::new(); - ledger.edits.push(edit("redirect_npm_lock_entry", "a")); - ledger - .edits - .push(edit("redirect_poetry_lock_package", "old-new")); - let files = BTreeMap::from([("poetry.lock".to_string(), "relocked".to_string())]); - merge( - &mut ledger, - &[ - edit("redirect_npm_lock_entry", "a"), - edit("redirect_npm_lock_entry", "b"), - edit("redirect_poetry_lock_package", "fresh"), - ], - BTreeMap::new(), - &files, - ); - let news: Vec<&str> = ledger - .edits - .iter() - .map(|e| e.new.as_ref().and_then(|v| v.as_str()).unwrap()) - .collect(); - assert_eq!(news, vec!["a", "fresh", "b"]); - let text = serialize(&ledger).unwrap(); - assert!(text.ends_with("}\n")); - } -} 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 7848425c..00000000 --- a/crates/socket-patch-cli/src/hosted_memory/redirect.rs +++ /dev/null @@ -1,1257 +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> { - let base = rel.rsplit('/').next().unwrap_or(rel); - Some(match base { - "package-lock.json" - | "npm-shrinkwrap.json" - | "pnpm-lock.yaml" - | "shrinkwrap.yaml" - | ".modules.yaml" - | "yarn.lock" - | ".yarnrc.yml" - | "bun.lock" - | "bun.lockb" - | "vlt-lock.json" - | "vlt.json" - | ".vlt-lock.json" => "npm", - "requirements.txt" | "uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock" - | "pyproject.toml" | "hatch.toml" => "pypi", - "Cargo.toml" | "Cargo.lock" | "config.toml" | "config" => "cargo", - "composer.lock" => "composer", - "nuget.config" | "packages.lock.json" => "nuget", - "Gemfile" | "Gemfile.lock" | "gems.rb" | "gems.locked" => "gem", - "go.mod" | "go.sum" => "golang", - "pom.xml" | "maven.config" | "checksums.sha256" => "maven", - _ if socket_patch_core::utils::python_lock::is_python_lock_name(base) - || base.ends_with(".py") => - { - "pypi" - } - _ => return None, - }) -} - -/// 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 { - 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 redirect 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 cd18d33d..015d9a8b 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-cli/tests/covgap_commands_rollback.rs b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs index bfa15851..90d3831b 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_rollback.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_rollback.rs @@ -149,7 +149,10 @@ struct PatchedFixture { purl: &'static str, before: &'static [u8], after: &'static [u8], + // Read only by the macOS-only test below. + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] before_hash: String, + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] after_hash: String, } 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..ebbc7f92 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -0,0 +1,1819 @@ +//! 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`. +pub const REDIRECT_CANDIDATE_FILES: &[&str] = &[ + "package-lock.json", + "npm-shrinkwrap.json", + "pnpm-lock.yaml", + // pnpm <=2 uses the same package identities under the old filename. + "shrinkwrap.yaml", + "node_modules/.modules.yaml", + "yarn.lock", + // A berry lock's cache-config gate reads `.yarnrc.yml`; bun's text lock is + // `bun.lock`; binary locks are read separately below. + ".yarnrc.yml", + "bun.lock", + "bun.lockb", + // vlt: the lock is rewritten, vlt.json is read-only (the old-lockfile + // advisory), and the hidden lock is only stat'ed as the install-state + // sentinel. + "vlt-lock.json", + "vlt.json", + "node_modules/.vlt-lock.json", + "requirements.txt", + "uv.lock", + "poetry.lock", + "pdm.lock", + "Pipfile.lock", + "pyproject.toml", + "hatch.toml", + "Cargo.toml", + "Cargo.lock", + ".cargo/config.toml", + // The LEGACY extensionless spelling: cargo reads `.cargo/config` in + // preference to `config.toml` when both exist, so the rewriter must see + // it (it wires the managed registry into whichever one is present) — + // otherwise the `[registries.…]` block lands in a file cargo ignores. + ".cargo/config", + "composer.lock", + "nuget.config", + "packages.lock.json", + "Gemfile", + "Gemfile.lock", + // Bundler's modern manifest spelling — preferred over Gemfile when both + // exist (the gem rewriter picks the pair bundler reads and fails closed + // on diverging spellings). + "gems.rb", + "gems.locked", + // The golang rewriter edits the main module's go.mod (fork-style + // `replace`) and go.sum (the socket module's two h1: lines). go.sum may + // legitimately be absent — the rewriter creates it in that case. + "go.mod", + "go.sum", + "pom.xml", + // Maven Trusted Checksums files the fail-closed maven rewriter merges into + // (read so an existing user config / checksum set is preserved, not + // clobbered). + ".mvn/maven.config", + ".mvn/checksums/checksums.sha256", + // Gradle build scripts are never edited — their presence only feeds the + // maven rewriter's paste-able `exclusiveContent` snippet warning. + "settings.gradle", + "settings.gradle.kts", + "build.gradle", + "build.gradle.kts", + // deno.lock is deliberately absent: no redirect rewriter edits its + // integrity entries. +]; + +/// 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, + } + } + + /// The `skipped[]` JSON entry (`{purl, uuid, reason[, detail]}`). + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).expect("SkippedPatch is plain strings: serialization cannot fail") + } +} + +/// 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) => 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(_) => 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) => 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) => 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 { + // 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) => (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) => 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) => 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(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 redirect 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(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('.') + ), + })); + (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(serde_json::json!({ + "code": "redirect_npm_allow_remote", + "detail": 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> { + let base = rel.rsplit('/').next().unwrap_or(rel); + Some(match base { + "package-lock.json" + | "npm-shrinkwrap.json" + | "pnpm-lock.yaml" + | "shrinkwrap.yaml" + | ".modules.yaml" + | "yarn.lock" + | ".yarnrc.yml" + | "bun.lock" + | "bun.lockb" + | "vlt-lock.json" + | "vlt.json" + | ".vlt-lock.json" => "npm", + "requirements.txt" | "uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock" + | "pyproject.toml" | "hatch.toml" => "pypi", + "Cargo.toml" | "Cargo.lock" | "config.toml" | "config" => "cargo", + "composer.lock" => "composer", + "nuget.config" | "packages.lock.json" => "nuget", + "Gemfile" | "Gemfile.lock" | "gems.rb" | "gems.locked" => "gem", + "go.mod" | "go.sum" => "golang", + "pom.xml" | "maven.config" | "checksums.sha256" => "maven", + _ if crate::utils::python_lock::is_python_lock_name(base) || base.ends_with(".py") => { + "pypi" + } + _ => return None, + }) +} + +/// 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) -> serde_json::Value { + serde_json::json!({ + "code": "record_fetch_failed", + "detail": format!( + "{purl} redirected, but its patch record could not be fetched; \ + it will be missing from VEX until `socket-patch scan --mode \ + hosted` is re-run" + ), + }) +} + +/// The rewriters' own 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, + }) +} + +#[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..01ed71ae --- /dev/null +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -0,0 +1,434 @@ +//! 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 redirect 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 redirect 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 redirect 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 \ + redirected (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 redirect 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), + ) +} + +/// `lockfileVersion` major sniffed from a pnpm-lock.yaml head. pnpm 9-12 +/// emit `lockfileVersion: '9.0'` (single doc, first line); pnpm 8 emits +/// `'6.0'`, pnpm 7 an unquoted `5.4`. `None` when no parseable version line +/// exists — callers treat that as "not trust-policy era" and stay +/// hands-off (fail closed: never write config for a lock we can't read). +pub fn pnpm_lock_version_major(lock_text: &str) -> Option { + lock_text.lines().find_map(|line| { + let rest = line.strip_prefix("lockfileVersion:")?; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + value.split('.').next()?.parse::().ok() + }) +} + +/// Whether a pnpm lock may belong to pnpm 1–4, which spell the store flag +/// `--store` (pnpm 1–3 can silently ignore `--store-dir`; early pnpm 4 +/// rejects it): a `shrinkwrapVersion` lock (pnpm 1–2) or lockfileVersion +/// 5.0–5.2 (pnpm 3–5). Later locks never get the `--store` note. +pub fn pnpm_lock_may_need_store_flag(lock_text: &str) -> bool { + lock_text.lines().any(|line| { + if line.starts_with("shrinkwrapVersion:") { + return true; + } + let Some(rest) = line.strip_prefix("lockfileVersion:") else { + return false; + }; + let value = rest.trim().trim_matches(|c| c == '\'' || c == '"'); + let mut parts = value.split('.'); + let major = parts.next().and_then(|m| m.parse::().ok()); + let minor = parts + .next() + .and_then(|m| m.parse::().ok()) + .unwrap_or(0); + major == Some(5) && minor <= 2 + }) +} + +/// The 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-core/src/hosted/ledger.rs b/crates/socket-patch-core/src/hosted/ledger.rs new file mode 100644 index 00000000..066918b1 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/ledger.rs @@ -0,0 +1,311 @@ +//! The hosted redirect ledger (`.socket/vendor/redirect-state.json`) delta: +//! how a run's recorded edits and patch records merge into the loaded +//! ledger (edits appended unless already recorded, [`REBASE_KINDS`] +//! rebased, records extended newest-wins), plus the in-memory load and the +//! disk writer's bytes. Kept apart from the engine ([`super::engine`]) so +//! the engine never depends on the ledger: a caller that no longer keeps +//! one simply never calls into this module. + +use std::collections::BTreeMap; + +use crate::manifest::schema::PatchRecord; +use crate::patch::redirect::{CorruptRedirectState, FileEdit, RedirectState, REDIRECT_STATE_REL}; +use crate::vendor::lock_inventory::{MemoryEntry, MemoryProject}; + +/// Fragment-edit kinds whose lockfile the package manager re-lays in place +/// (keeping the Socket source) — a re-scan REBASES their ledger edits instead +/// of appending; see the ledger merge below. +pub const REBASE_KINDS: &[&str] = &[ + "redirect_poetry_lock_package", + "redirect_pdm_lock_package", + crate::patch::redirect::vlt::KIND, +]; + + +/// Merge this run's vlt node edits into the recorded ones. A fresh edit +/// for the same `key` and DepID keeps the oldest recorded `original` (the +/// pristine registry entry), takes the fresh `new` and drops the chain's +/// later links (a server-written ledger appends one per hosted PR). One +/// whose recorded same-key edits all name DepIDs the pre-run lock no longer +/// holds (a re-lock, or a new id grammar after a vlt upgrade) replaces +/// them. So does one for another key of the same `name@version` whose +/// vanished recorded edit's pin vlt carried to the fresh DepID (a new peer +/// context). A replacing edit keeps the recorded pristine slots when vlt +/// carried the pin ([`carried_pin_original`]). Returns, per fresh edit, +/// whether it was merged (anything else is appended as usual). +pub fn rebase_vlt_edits( + ledger: &mut Vec, + fresh: &[crate::patch::redirect::FileEdit], + before_lock: Option<&str>, +) -> Vec { + use crate::patch::redirect::vlt::{ + carried_pin_original, edit_dep_id, lock_node_ids, KIND, + }; + use crate::patch::redirect::FileEdit; + fn superseding(edit: &FileEdit, old: &FileEdit) -> FileEdit { + let mut next = edit.clone(); + if let Some(original) = carried_pin_original(edit, old) { + next.original = Some(original); + } + next + } + fn key_base(key: &Option) -> Option<&str> { + key.as_deref() + .map(|k| k.split_once('~').map_or(k, |(base, _)| base)) + } + let live = before_lock.and_then(lock_node_ids).unwrap_or_default(); + let mut merged = vec![false; fresh.len()]; + for (i, edit) in fresh.iter().enumerate() { + if edit.kind != KIND { + continue; + } + let id = edit_dep_id(edit); + let same_key: Vec = ledger + .iter() + .enumerate() + .filter(|(_, old)| old.kind == KIND && old.path == edit.path && old.key == edit.key) + .map(|(j, _)| j) + .collect(); + let same_dep: Vec = same_key + .iter() + .copied() + .filter(|&j| id.is_some() && edit_dep_id(&ledger[j]) == id) + .collect(); + if let Some((&first, rest)) = same_dep.split_first() { + ledger[first].new = edit.new.clone(); + ledger[first].action = edit.action.clone(); + for &j in rest.iter().rev() { + ledger.remove(j); + } + merged[i] = true; + continue; + } + let vanished = |j: &usize| edit_dep_id(&ledger[*j]).is_none_or(|old| !live.contains(&old)); + let gone: Vec = same_key.into_iter().filter(vanished).collect(); + if let Some((&first, rest)) = gone.split_first() { + ledger[first] = superseding(edit, &ledger[first]); + for &j in rest.iter().rev() { + ledger.remove(j); + } + merged[i] = true; + continue; + } + let rekeyed = (0..ledger.len()).find(|j| { + let old = &ledger[*j]; + old.kind == KIND + && old.path == edit.path + && old.key != edit.key + && key_base(&old.key) == key_base(&edit.key) + && vanished(j) + && carried_pin_original(edit, old).is_some() + }); + if let Some(j) = rekeyed { + ledger[j] = superseding(edit, &ledger[j]); + merged[i] = true; + } + } + merged +} + +/// Merge this run's `edits` and `records` into `ledger`. `files` are the +/// pre-rewrite candidate contents the rebase drift check reads. +/// +/// REBASE instead of append for fragment kinds whose file the package +/// manager itself rewrites in place: when the ledger already holds edits +/// for the same (path, kind, key) and the file no longer carried their +/// `new` fragments before this run (Poetry 1.1/1.2 `poetry lock +/// --no-update` keeps the Socket source but re-lays the unit and drops the +/// inserted `files` line), appending this run's edits — recorded against +/// the RELOCKED text — would build a chain whose older links match nothing, +/// so rollback and remove refuse forever. Keeping the oldest `original` +/// (the pristine fragment) and adopting the fresh `new` keeps the chain a +/// single invertible link: replay swaps the fragment this run wrote back +/// to the fragment the very first run found. +pub fn merge( + ledger: &mut RedirectState, + edits: &[FileEdit], + records: BTreeMap, + files: &BTreeMap, +) { + // Older ledgers carry `"mode": "redirect"`; normalize on rewrite (the + // loader accepts either). + ledger.mode = "hosted".to_string(); + let vlt_merged = rebase_vlt_edits( + &mut ledger.edits, + edits, + files + .get(crate::constants::npm_family::VLT_LOCK) + .map(String::as_str), + ); + let mut rebased: Vec = Vec::new(); + for edit in edits.iter().filter(|e| { + REBASE_KINDS.contains(&e.kind.as_str()) && e.kind != crate::patch::redirect::vlt::KIND + }) { + let siblings: Vec = ledger + .edits + .iter() + .enumerate() + .filter(|(_, old)| { + old.path == edit.path && old.kind == edit.kind && old.key == edit.key + }) + .map(|(i, _)| i) + .collect(); + let before = files.get(&edit.path).map(String::as_str).unwrap_or(""); + let drifted = !siblings.is_empty() + && siblings.iter().all(|&i| { + ledger.edits[i] + .new + .as_ref() + .and_then(serde_json::Value::as_str) + .is_none_or(|new| !before.contains(new)) + }); + if !drifted { + continue; + } + // Positional pairing: the rewriter emits a key's fragments in a + // fixed order (package unit, then the legacy integrity entry). + let nth = edits + .iter() + .filter(|e| e.path == edit.path && e.kind == edit.kind && e.key == edit.key) + .position(|e| std::ptr::eq(e, edit)) + .unwrap_or(0); + if let Some(&target) = siblings.get(nth) { + if !rebased.contains(&target) { + // `pdm lock` fully un-patches the lock (registry source + // restored) and may reflow line endings (CRLF → LF), so the + // fresh run's `original` IS the correct relocked-registry + // rollback target and the stale recorded one would restore a + // mismatched fragment. Poetry's relock instead KEEPS the + // Socket source (it only drops the inserted `files` line), so + // its oldest `original` — the true pre-patch fragment — must + // survive; only its `new` is refreshed. + if edit.kind == "redirect_pdm_lock_package" { + ledger.edits[target].original = edit.original.clone(); + } + ledger.edits[target].new = edit.new.clone(); + ledger.edits[target].action = edit.action.clone(); + rebased.push(target); + } + } + } + // Dedup against the ledger as this run found it, never within this + // run: one run legitimately records identical edits (a Cargo.toml + // declaring the crate with the same line in two sections), and each one + // reverts one occurrence. + let recorded = ledger.edits.len(); + for (i, edit) in edits.iter().enumerate() { + if vlt_merged[i] { + continue; + } + let is_rebased = REBASE_KINDS.contains(&edit.kind.as_str()) + && rebased.iter().any(|&t| { + let old = &ledger.edits[t]; + old.path == edit.path + && old.kind == edit.kind + && old.key == edit.key + && old.new == edit.new + }); + if !is_rebased && !ledger.edits[..recorded].contains(edit) { + ledger.edits.push(edit.clone()); + } + } + ledger.records.extend(records); +} + +/// Load an in-memory project's ledger: `Ok(None)` when absent, `Err` (the +/// disk loader's message, naming `display_path`) when present but +/// unreadable or malformed. +pub fn load_memory( + project: &MemoryProject, + display_path: &str, +) -> Result, String> { + let corrupt = |detail: String, unreadable: bool| { + CorruptRedirectState { + path: display_path.into(), + detail, + quarantined_to: None, + unreadable, + } + .to_string() + }; + let bytes: &[u8] = match project.get(REDIRECT_STATE_REL) { + None => return Ok(None), + Some(MemoryEntry::Text(text)) => text.as_bytes(), + Some(MemoryEntry::Binary(bytes)) => bytes, + Some(MemoryEntry::Present) => { + return Err(corrupt("file content was not provided".into(), true)) + } + Some(MemoryEntry::Symlink) => return Err(corrupt("is a symbolic link".into(), true)), + }; + serde_json::from_slice(bytes) + .map(Some) + .map_err(|e| corrupt(format!("invalid JSON: {e}"), false)) +} + +/// The ledger's on-disk bytes (the disk writer's `to_vec_pretty` + `\n`). +pub fn serialize(ledger: &RedirectState) -> Result { + let mut bytes = serde_json::to_vec_pretty(ledger).map_err(|e| e.to_string())?; + bytes.push(b'\n'); + String::from_utf8(bytes).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(kind: &str, new: &str) -> FileEdit { + FileEdit { + path: "poetry.lock".into(), + kind: kind.into(), + action: "replaced".into(), + key: Some("k".into()), + original: Some(serde_json::json!("orig")), + new: Some(serde_json::json!(new)), + } + } + + #[test] + fn corrupt_and_absent_ledgers() { + let mut p = MemoryProject::new(); + assert!(load_memory(&p, REDIRECT_STATE_REL).unwrap().is_none()); + p.insert_text(REDIRECT_STATE_REL, "{not json"); + let err = load_memory(&p, "sub/.socket/vendor/redirect-state.json").unwrap_err(); + assert!( + err.contains("sub/.socket/vendor/redirect-state.json"), + "{err}" + ); + assert!(err.contains("malformed"), "{err}"); + p.insert_symlink(REDIRECT_STATE_REL); + assert!(load_memory(&p, REDIRECT_STATE_REL) + .unwrap_err() + .contains("cannot be read")); + } + + #[test] + fn merge_appends_new_edits_and_rebases_drifted_fragments() { + let mut ledger = RedirectState::new(); + ledger.edits.push(edit("redirect_npm_lock_entry", "a")); + ledger + .edits + .push(edit("redirect_poetry_lock_package", "old-new")); + let files = BTreeMap::from([("poetry.lock".to_string(), "relocked".to_string())]); + merge( + &mut ledger, + &[ + edit("redirect_npm_lock_entry", "a"), + edit("redirect_npm_lock_entry", "b"), + edit("redirect_poetry_lock_package", "fresh"), + ], + BTreeMap::new(), + &files, + ); + let news: Vec<&str> = ledger + .edits + .iter() + .map(|e| e.new.as_ref().and_then(|v| v.as_str()).unwrap()) + .collect(); + assert_eq!(news, vec!["a", "fresh", "b"]); + let text = serialize(&ledger).unwrap(); + assert!(text.ends_with("}\n")); + } +} 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 91% rename from crates/socket-patch-cli/src/hosted_memory/mod.rs rename to crates/socket-patch-core/src/hosted/memory/mod.rs index b5f8b0f3..b19a91ea 100644 --- a/crates/socket-patch-cli/src/hosted_memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -37,23 +37,22 @@ 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::manifest::schema::PatchRecord; -use socket_patch_core::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; -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::manifest::schema::PatchRecord; +use crate::patch::redirect::{RedirectState, REDIRECT_STATE_REL}; +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(crate) mod ledger; 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; @@ -61,7 +60,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. @@ -113,7 +112,7 @@ struct RootState { ledger: Option, purls: Vec, summary: ProjectSummary, - packages: Vec, + packages: Vec, selected: Vec<(String, String)>, skipped: Vec, error: Option, @@ -423,7 +422,10 @@ async fn engine( let Some(project) = state.project.as_ref() else { continue; }; - match ledger::load(project, &state.root) { + match crate::hosted::ledger::load_memory( + project, + &roots::join_root(&state.root, REDIRECT_STATE_REL), + ) { Ok(loaded) => state.ledger = loaded, Err(message) => { state.fail("corrupt_ledger", message); @@ -432,7 +434,7 @@ 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); @@ -583,9 +585,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 @@ -608,12 +610,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)); } } } @@ -624,7 +625,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() { @@ -658,7 +659,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::engine::redirect_json_block( 0, Vec::new(), Vec::new(), @@ -722,13 +723,20 @@ fn finish_root( warnings: &mut Vec, ) -> ProjectResult { let Rewritten { - planned, + project, + skipped, + pre_warnings, + done, + } = done; + let crate::hosted::engine::Rewritten { + files, rewrite, rewritten, confirmed, rush_warnings, pnpm_warnings, npm_warnings, + .. } = done; let root = state.root.clone(); let mut record_map: BTreeMap = BTreeMap::new(); @@ -738,17 +746,10 @@ fn finish_root( match records.get(uuid) { Some(Some(response)) => { let (rec_purl, record) = - crate::commands::get::record_from_patch_response(response); + crate::manifest::records::record_from_patch_response(response); record_map.insert(rec_purl, record); } - _ => record_warnings.push(serde_json::json!({ - "code": "record_fetch_failed", - "detail": format!( - "{purl} redirected, but its patch record could not be fetched; \ - it will be missing from VEX until `socket-patch scan --mode \ - hosted` is re-run" - ), - })), + _ => record_warnings.push(crate::hosted::engine::record_fetch_failed_warning(purl)), } } } @@ -757,10 +758,10 @@ fn finish_root( let mut ledger_error: Option = None; if !dry_run && (!rewrite.edits.is_empty() || !record_map.is_empty()) { let mut ledger = state.ledger.take().unwrap_or_default(); - ledger::merge(&mut ledger, &rewrite.edits, record_map, &planned.files); - match ledger::serialize(&ledger) { + crate::hosted::ledger::merge(&mut ledger, &rewrite.edits, record_map, &files); + match crate::hosted::ledger::serialize(&ledger) { Ok(text) => { - if planned.project.text(REDIRECT_STATE_REL) != Some(text.as_str()) { + if project.text(REDIRECT_STATE_REL) != Some(text.as_str()) { project_changes.push((REDIRECT_STATE_REL.to_string(), text)); } } @@ -778,12 +779,12 @@ fn finish_root( redirect: serde_json::json!({ "mode": "hosted" }), summary: state.summary.clone(), redirected: Vec::new(), - skipped: planned.skipped, + skipped, error: Some(error), }; } 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())); } } @@ -796,7 +797,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() ) }) @@ -831,7 +832,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, @@ -849,22 +850,16 @@ fn finish_root( .or_insert_with(|| (root.clone(), bytes)); } - let mut redirect_warnings: Vec = rewrite - .warnings - .iter() - .map(|w| serde_json::json!({ "code": w.code, "detail": w.detail })) - .collect(); + let mut redirect_warnings: Vec = + crate::hosted::engine::rewrite_warnings_json(&rewrite.warnings); 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( + redirect_warnings.extend(pre_warnings); + let skipped_values: Vec = + skipped.iter().map(SkippedPatch::to_json).collect(); + let redirect = crate::hosted::engine::redirect_json_block( confirmed.len(), rewritten, skipped_values, @@ -879,7 +874,7 @@ fn finish_root( .into_iter() .map(|(purl, uuid)| RedirectedPatch { purl, uuid }) .collect(), - skipped: planned.skipped, + skipped, error: None, } } @@ -887,7 +882,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 { @@ -905,8 +900,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 @@ -922,13 +915,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, + }, } } @@ -986,7 +990,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 99% rename from crates/socket-patch-cli/src/hosted_memory/roots.rs rename to crates/socket-patch-core/src/hosted/memory/roots.rs index 002cac15..0b0e03d9 100644 --- a/crates/socket-patch-cli/src/hosted_memory/roots.rs +++ b/crates/socket-patch-core/src/hosted/memory/roots.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use socket_patch_core::utils::python_lock::is_python_lock_name; +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 67a4b4b7..8e33d219 100644 --- a/crates/socket-patch-cli/src/hosted_memory/select.rs +++ b/crates/socket-patch-core/src/hosted/memory/select.rs @@ -8,18 +8,19 @@ 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::patch::redirect::REDIRECT_STATE_REL; -use socket_patch_core::utils::python_lock::is_python_lock_name; +use crate::patch::redirect::npmrc::NPMRC_REL; +use crate::patch::redirect::REDIRECT_STATE_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; @@ -33,9 +34,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; 4] = [ PNPM_WORKSPACE_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..a20717c9 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -0,0 +1,343 @@ +//! 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(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::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: Vec = 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..6b6932f7 --- /dev/null +++ b/crates/socket-patch-core/src/hosted/mod.rs @@ -0,0 +1,21 @@ +//! 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. +//! - [`ledger`] — the redirect-ledger delta (merge, in-memory load, +//! serialization), kept apart so the engine never depends on it. +//! - [`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 ledger; +pub mod memory; +pub mod vlt; 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..c6ff5e2f --- /dev/null +++ b/crates/socket-patch-core/src/hosted/vlt.rs @@ -0,0 +1,211 @@ +//! 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) => { + 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) => 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(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 +} + +/// 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/lib.rs b/crates/socket-patch-core/src/lib.rs index 04a2a6f9..6022b4e8 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod constants; pub mod crawlers; pub mod hash; +pub mod hosted; pub mod manifest; pub mod package_json; pub mod patch; 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/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 15330b99..8858b27d 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -205,6 +205,41 @@ 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-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; From 3b953c1551420cfb79b9f75ed19c16fdade89bd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 22:41:44 +0000 Subject: [PATCH 2/7] Split the vendored takeover out of run_redirect_selected Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- crates/socket-patch-cli/src/args.rs | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 4 +- crates/socket-patch-cli/src/commands/get.rs | 32 +- .../src/commands/scan/discovery.rs | 9 +- .../src/commands/scan/hosted.rs | 847 +++++++++--------- .../src/commands/scan/hosted/vlt.rs | 6 +- .../socket-patch-cli/src/commands/scan/mod.rs | 69 +- .../src/commands/scan/render.rs | 5 +- crates/socket-patch-cli/src/commands/setup.rs | 3 +- .../socket-patch-cli/src/commands/update.rs | 21 +- .../socket-patch-cli/src/commands/vendor.rs | 6 +- .../socket-patch-cli/tests/apply_network.rs | 5 +- .../tests/cli_config_fallback.rs | 7 +- .../socket-patch-cli/tests/cli_parse_list.rs | 10 +- .../tests/cli_parse_rollback.rs | 6 +- .../coverage_fix_apply_silent_mute_exit.rs | 4 +- .../coverage_fix_repair_vendor_predelete.rs | 14 +- .../tests/covgap_commands_scan_mod.rs | 7 +- .../tests/covgap_commands_update.rs | 12 +- .../tests/covgap_commands_vex.rs | 16 +- .../tests/covgap_ecosystem_dispatch.rs | 8 +- .../socket-patch-cli/tests/covgap_output.rs | 10 +- .../tests/covgap_setup_composer_mod.rs | 5 +- .../tests/covgap_setup_gem_mod.rs | 14 +- .../tests/covgap_setup_pypi_detect.rs | 11 +- crates/socket-patch-cli/tests/e2e_cargo.rs | 6 +- crates/socket-patch-cli/tests/e2e_maven.rs | 3 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 6 +- .../tests/get_edge_cases_e2e.rs | 12 +- .../tests/global_packages_e2e.rs | 5 +- .../tests/help_text_hygiene.rs | 10 +- .../tests/hosted_memory_engine.rs | 3 +- .../tests/in_process_redirect/vlt.rs | 10 +- .../tests/in_process_redirect_pdm.rs | 34 +- .../tests/in_process_redirect_pipenv.rs | 93 +- .../tests/in_process_rollback_hosted/vlt.rs | 18 +- .../tests/interactive_prompts_e2e.rs | 5 +- .../tests/rollback_duality_invariants.rs | 3 +- .../socket-patch-cli/tests/scan_invariants.rs | 18 +- .../socket-patch-cli/tests/scan_paths_e2e.rs | 12 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 6 +- .../tests/self_update_channels_e2e.rs | 5 +- .../tests/vendor_rerun_no_network_e2e.rs | 13 +- crates/socket-patch-core/src/api/ranking.rs | 7 +- .../src/crawlers/npm_crawler.rs | 6 +- .../src/crawlers/npm_crawler/oracle.rs | 6 +- .../src/crawlers/python_crawler.rs | 6 +- crates/socket-patch-core/src/hosted/engine.rs | 106 ++- .../socket-patch-core/src/hosted/guidance.rs | 10 +- crates/socket-patch-core/src/hosted/ledger.rs | 5 +- .../src/hosted/memory/mod.rs | 4 +- .../socket-patch-core/src/manifest/records.rs | 5 +- .../patch/redirect/group_equivalence_tests.rs | 7 +- .../src/patch/redirect/mod.rs | 14 +- .../src/patch/redirect/pdm.rs | 15 +- .../src/patch/redirect/pipenv.rs | 88 +- .../src/patch/redirect/poetry.rs | 32 +- .../src/patch/redirect/requirements.rs | 4 +- .../socket-patch-core/src/update/download.rs | 13 +- .../socket-patch-core/src/update/release.rs | 39 +- .../src/utils/group_commit.rs | 21 +- crates/socket-patch-core/src/utils/mod.rs | 2 +- .../socket-patch-core/src/utils/pdm_lock.rs | 11 +- .../src/utils/poetry_lock.rs | 107 ++- .../src/utils/python_script.rs | 7 +- .../src/vendor/lock_inventory/mod.rs | 6 +- .../src/vendor/lock_inventory/vlt.rs | 2 +- .../socket-patch-core/src/vendor/npm_dir.rs | 6 +- .../socket-patch-core/src/vendor/prestage.rs | 5 +- .../src/vendor/toml_surgery.rs | 3 +- .../socket-patch-core/src/vex/discover/mod.rs | 5 +- .../tests/covgap_api_blob_fetcher.rs | 5 +- .../tests/covgap_crawlers_composer_crawler.rs | 6 +- .../tests/covgap_vendor_nuget_feed.rs | 4 +- .../socket-patch-core/tests/poetry_hosted.rs | 115 ++- crates/socket-patch-node/src/lib.rs | 6 +- 76 files changed, 1294 insertions(+), 783 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 29a09df7..686ea443 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -402,9 +402,9 @@ impl GlobalArgs { /// empty). The names are validated at parse time, so this is an exact /// match. pub(crate) fn ecosystem_selected(&self, eco: Ecosystem) -> bool { - self.ecosystems.as_ref().is_none_or(|list| { - list.is_empty() || list.iter().any(|name| name == eco.cli_name()) - }) + self.ecosystems + .as_ref() + .is_none_or(|list| list.is_empty() || list.iter().any(|name| name == eco.cli_name())) } /// [`Self::ecosystem_selected`] for the ecosystem of `purl`; a purl of diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index b1b932eb..fb32947d 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2,9 +2,7 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{ - detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, -}; +use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 40369f8f..dc6b0bbe 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -14,9 +14,7 @@ use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; 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, -}; +use socket_patch_core::manifest::schema::{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}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; @@ -390,7 +388,6 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap i32 { // entry) and global installs (no project lockfile) mean agent mode. // Conflicts use get's exit-1 report_error style (scan's self-enforced // conflicts exit 2 — documented carve-out in CLI_CONTRACT.md). - let mode = args.mode.unwrap_or(if args.save_only || args.common.is_global() { - super::scan::ScanMode::Agent - } else { - super::scan::ScanMode::Hosted - }); + let mode = args + .mode + .unwrap_or(if args.save_only || args.common.is_global() { + super::scan::ScanMode::Agent + } else { + super::scan::ScanMode::Hosted + }); if args.save_only && mode != super::scan::ScanMode::Agent { report_error( args.common.json, @@ -2867,8 +2866,7 @@ pub async fn run(args: GetArgs) -> i32 { } IdentifierType::Package => { status.set("Enumerating packages..."); - let (all_packages, _, _) = - crawl_all_ecosystems(&args.common.crawler_options()).await; + let (all_packages, _, _) = crawl_all_ecosystems(&args.common.crawler_options()).await; if all_packages.is_empty() { status.finish(); @@ -7175,8 +7173,11 @@ mod tests { let installed = |name: &str, body: &[u8]| { let dist = site.path().join(format!("{name}-1.0.0.dist-info")); std::fs::create_dir_all(&dist).unwrap(); - std::fs::write(dist.join("METADATA"), format!("Name: {name}\nVersion: 1.0.0\n")) - .unwrap(); + std::fs::write( + dist.join("METADATA"), + format!("Name: {name}\nVersion: 1.0.0\n"), + ) + .unwrap(); std::fs::write(site.path().join(format!("{name}.py")), body).unwrap(); compute_git_sha256_from_bytes(body) }; @@ -7220,7 +7221,10 @@ mod tests { mount(uuid("bs"), "beta_sdist.py".into(), "0".repeat(64), 0).await; for n in ["gw", "gs"] { Mock::given(method("GET")) - .and(wm_path(format!("/v0/orgs/test-org/patches/view/{}", uuid(n)))) + .and(wm_path(format!( + "/v0/orgs/test-org/patches/view/{}", + uuid(n) + ))) .respond_with(ResponseTemplate::new(500)) .expect(0) .mount(&server) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index b70af594..f3b8a878 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1010,7 +1010,8 @@ mod tests { let empty = socket_patch_core::patch::redirect::RedirectState::new(); let empty_vendor = VendorState::new(); assert!( - merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]).is_none() + merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]) + .is_none() ); let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); @@ -1924,7 +1925,11 @@ mod tests { "pkg:npm/lockonly@1.0.0", std::path::PathBuf::from("/nonexistent"), ), - crawled_pkg("alpha", "pkg:npm/alpha@1.0.0", installed("alpha", "alpha.js")), + crawled_pkg( + "alpha", + "pkg:npm/alpha@1.0.0", + installed("alpha", "alpha.js"), + ), crawled_pkg( "embedded", "pkg:npm/embedded@1.0.0", diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index b96588f1..0bc2883c 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -35,8 +35,8 @@ pub(crate) use socket_patch_core::hosted::guidance::{ 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, + pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, + TrustPlan, }; #[cfg(test)] pub(crate) use socket_patch_core::hosted::ledger::{rebase_vlt_edits, REBASE_KINDS}; @@ -120,24 +120,6 @@ 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( - common: &crate::args::GlobalArgs, - scan_result: Option, - linked: &str, -) -> i32 { - refuse( - common, - scan_result, - &socket_patch_core::hosted::engine::symlink_refusal(linked), - ) -} - /// An engine refusal (nothing was written): `Error (): ` on /// stderr plus the `--json` error envelope carrying the code, exit 1. fn refuse( @@ -642,7 +624,7 @@ pub(crate) async fn run_redirect_selected( npm_prior: Option<&crate::ecosystem_dispatch::NpmCrawlSnapshot>, ) -> i32 { use socket_patch_core::hosted::engine::{ - self, Candidate, CandidateFiles, RewriteOptions, SkippedPatch, TakeoverPreview, + self, Candidate, CandidateFiles, RewriteOptions, SkippedPatch, }; use socket_patch_core::manifest::schema::PatchRecord; use socket_patch_core::patch::redirect::RedirectState; @@ -685,7 +667,11 @@ pub(crate) async fn run_redirect_selected( // (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()); + return refuse( + common, + scan_result.take(), + &engine::bun_lockb_symlink_refusal(), + ); } // vlt artifact preflight: before any takeover or rewrite (dry runs @@ -765,405 +751,17 @@ 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(); - // The withheld dry-run takeover candidates' artifact URLs and wired root locks, - // filled when they leave the rewrite set below. - let mut dry_run_takeover_urls: 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 redirected — 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 redirect will \ - revert its vendored wiring, ledger entry, and committed \ - artifact first, then redirect (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 redirected — 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 redirected — 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 redirecting (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(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(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(TakeoverPreview { - artifact_url: c.dep.artifact_url.clone(), - locks: locks.clone(), - }); - } - } - candidates.retain(|c| !withheld.contains(c.purl.as_str())); - } - } // Read the project's candidate files. Skipped when no candidate // survived and no dry-run takeover preview is pending (the rewriters do @@ -1782,6 +1380,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 redirected — 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 redirect will \ + revert its vendored wiring, ledger entry, and committed \ + artifact first, then redirect (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 redirected — 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 redirected — 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 redirecting (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 @@ -2141,7 +2146,6 @@ pub(crate) fn boxed_run_redirect_selected<'a>( #[cfg(test)] mod tests { - use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, installed_stale_positive_evidence, @@ -2163,6 +2167,7 @@ mod tests { use super::{rebase_vlt_edits, REBASE_KINDS}; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::constants::npm_family; + use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use socket_patch_core::patch::redirect::{DepOverride, FileEdit}; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; 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 4034ddb8..58522c02 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -6,14 +6,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use socket_patch_core::constants::npm_family::{ - VLT_HIDDEN_LOCK_REL, VLT_LOCK, -}; +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::hosted::vlt::{self as hosted_vlt, Preflight}; use socket_patch_core::patch::redirect::vlt_preflight; use socket_patch_core::patch::redirect::{vlt, DepOverride}; use socket_patch_core::vendor::lock_inventory::ProjectView; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 20ab9896..c981b93a 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -241,9 +241,7 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { // stays report-only (neither has a project lockfile to rewire). args.mode = Some(ScanMode::Hosted); } - if args.mode == Some(ScanMode::Hosted) - && args.common.is_global() - { + if args.mode == Some(ScanMode::Hosted) && args.common.is_global() { // Global installs have no project lockfile to repoint: the hosted // flow would "redirect 0 packages" and exit 0, a silent no-op. return Err(format!( @@ -357,11 +355,7 @@ pub struct ScanArgs { /// `requests`), or a purl with or without its version /// (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or /// separate with commas - #[arg( - long = "package", - env = "SOCKET_SCAN_PACKAGES", - value_delimiter = ',' - )] + #[arg(long = "package", env = "SOCKET_SCAN_PACKAGES", value_delimiter = ',')] pub packages: Vec, /// On a successful scan, also generate an OpenVEX 0.2.0 document. @@ -397,7 +391,8 @@ pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { None => name_version, }; if let Some(spec_rest) = spec.strip_prefix("pkg:") { - let spec_purl = normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); + let spec_purl = + normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); let spec_rest = &spec_purl[4..]; let has_version = spec_rest .split_once('/') @@ -405,7 +400,9 @@ pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { return if has_version { decoded == spec_purl } else { - decoded.strip_prefix(&spec_purl).is_some_and(|tail| tail.starts_with('@')) + decoded + .strip_prefix(&spec_purl) + .is_some_and(|tail| tail.starts_with('@')) }; } let spec = spec.replace(':', "/"); @@ -1530,7 +1527,8 @@ fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { let joined = cwd.join(raw); if raw.contains(['*', '?', '[']) { let pattern = joined.to_string_lossy().into_owned(); - let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; + let matches = + glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; let before = dirs.len(); dirs.extend(matches.filter_map(Result::ok).filter(|p| p.is_dir())); if dirs.len() == before { @@ -1752,8 +1750,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .filter(|pkg| args.common.purl_ecosystem_selected(&pkg.purl)) .collect(); - let package_specs: Vec<&String> = - args.packages.iter().filter(|s| !s.trim().is_empty()).collect(); + let package_specs: Vec<&String> = args + .packages + .iter() + .filter(|s| !s.trim().is_empty()) + .collect(); let filtered_crawled: Vec<_> = if package_specs.is_empty() { filtered_crawled } else { @@ -2172,18 +2173,17 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { }; // The hosted pins the lockfiles wire count too: the lockfile is the // record of a hosted redirect even where no ledger was committed. - let hosted_pins: Vec<(String, String)> = - if args.common.is_global() { - Vec::new() - } else { - crate::commands::discover_wiring(&args.common, &args.common.cwd) - .await - .refs - .into_iter() - .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) - .map(|r| (r.purl, r.uuid)) - .collect() - }; + let hosted_pins: Vec<(String, String)> = if args.common.is_global() { + Vec::new() + } else { + crate::commands::discover_wiring(&args.common, &args.common.cwd) + .await + .refs + .into_iter() + .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) + .map(|r| (r.purl, r.uuid)) + .collect() + }; let update_manifest = merge_ledger_records_for_updates( existing_manifest.as_ref(), redirect_state.as_ref(), @@ -2950,11 +2950,19 @@ mod tests { std::fs::write(tmp.path().join("apps/README"), "").unwrap(); let rel = |dirs: Vec| -> Vec { dirs.iter() - .map(|d| d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/")) + .map(|d| { + d.strip_prefix(tmp.path()) + .unwrap() + .to_string_lossy() + .replace('\\', "/") + }) .collect() }; - let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()]) - .unwrap(); + let got = project_dirs( + tmp.path(), + &["apps/*".into(), "libs/core".into(), "apps/web".into()], + ) + .unwrap(); assert_eq!(rel(got), ["apps/api", "apps/web", "libs/core"]); assert!(project_dirs(tmp.path(), &["apps/README".into()]) .unwrap_err() @@ -3272,7 +3280,10 @@ mod tests { }, ] { let picked = selection_args(&common); - assert!(!picked.json && picked.yes, "scan always takes the top patch"); + assert!( + !picked.json && picked.yes, + "scan always takes the top patch" + ); } } diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 34adcadc..d616c5c1 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -726,7 +726,10 @@ mod tests { #[test] fn report_only_hint_names_agent_mode() { - assert_eq!(report_only_hint()[0], "To apply these patches in place, run:"); + assert_eq!( + report_only_hint()[0], + "To apply these patches in place, run:" + ); assert!(report_only_hint()[1].contains("--mode agent")); } diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 63106bdc..bbdf92eb 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -247,8 +247,7 @@ async fn hooked_vlt_members(found: &PackageJsonFindResult) -> Vec { } let mut hooked = Vec::new(); for loc in found.files.iter().filter(|loc| !loc.is_root) { - if let Ok(content) = socket_patch_core::utils::fs::read_regular_to_string(&loc.path).await - { + if let Ok(content) = socket_patch_core::utils::fs::read_regular_to_string(&loc.path).await { let status = is_setup_configured_str(&content); if status.postinstall_configured || status.dependencies_configured { hooked.push(loc.path.clone()); diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index dc8fe2be..b736f72f 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -157,7 +157,11 @@ fn cancelled_message(current: &semver::Version, target: &semver::Version) -> &'s /// The result line after a successful install, naming the same action as /// [`confirm_prompt`]. -fn installed_message(current: &semver::Version, target: &semver::Version, path: &std::path::Path) -> String { +fn installed_message( + current: &semver::Version, + target: &semver::Version, + path: &std::path::Path, +) -> String { let path = path.display(); if target < current { format!("Downgraded socket-patch {current} \u{2192} {target} ({path})") @@ -498,9 +502,18 @@ mod tests { #[test] fn cancel_and_result_lines_match_the_prompt() { - assert_eq!(cancelled_message(&v("4.0.0"), &v("9.9.9")), "Update cancelled."); - assert_eq!(cancelled_message(&v("4.0.0"), &v("3.0.0")), "Downgrade cancelled."); - assert_eq!(cancelled_message(&v("4.0.0"), &v("4.0.0")), "Reinstall cancelled."); + assert_eq!( + cancelled_message(&v("4.0.0"), &v("9.9.9")), + "Update cancelled." + ); + assert_eq!( + cancelled_message(&v("4.0.0"), &v("3.0.0")), + "Downgrade cancelled." + ); + assert_eq!( + cancelled_message(&v("4.0.0"), &v("4.0.0")), + "Reinstall cancelled." + ); let p = std::path::Path::new("/opt/sp/socket-patch"); assert_eq!( installed_message(&v("4.0.0"), &v("9.9.9"), p), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 5da61dd9..3fd77adf 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -3674,7 +3674,11 @@ mod plan_gate_tests { .unwrap(); let packages = [ ("pkg:composer/psr/cache@1.0.0", "psr/cache", UUID_A), - ("pkg:composer/psr/http-message@1.1.0", "psr/http-message", UUID_B), + ( + "pkg:composer/psr/http-message@1.1.0", + "psr/http-message", + UUID_B, + ), ("pkg:composer/psr/log@3.0.2", "psr/log", UUID_C), ]; let mut all_packages: Vec<(String, StagedSource)> = Vec::new(); diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 2c01ebb8..8fe7e427 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -1069,10 +1069,7 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { v["summary"]["applied"], 1, "the drifted nested copy must be warn-overwritten.\nstdout={v:#}" ); - assert_eq!( - v["summary"]["failed"], 0, - "no copy may fail.\nstdout={v:#}" - ); + assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}"); // The nested copy's blob was fetched on demand… let requests = mock.received_requests().await.unwrap(); diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index a919ab99..942fdc2f 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -59,8 +59,7 @@ fn scan_cmd(project: &Path, data_dir: &Path) -> Command { let mut cmd = Command::new(BINARY); // Human mode: core's proxy advisory (the oracle below) is muted under // `--json`/`--silent`. - cmd.args(["scan", "-e", "npm", "--cwd"]) - .arg(project); + cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") { @@ -298,7 +297,9 @@ async fn corrupt_config_warns_and_keeps_json_stdout_clean() { json_cmd.arg("--json"); let json_out = run(json_cmd); assert!( - json_out.stderr.contains("could not parse socket-cli config"), + json_out + .stderr + .contains("could not parse socket-cli config"), "the parse warning must reach stderr under --json too; got:\n{}", json_out.stderr ); diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 5e5dd991..18cf1e07 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -1196,7 +1196,10 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); let warnings = v["warnings"].as_array().expect("warnings[] present"); assert_eq!(warnings.len(), 1, "envelope={v}"); - assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}"); + assert_eq!( + warnings[0]["code"], "redirect_ledger_corrupt", + "envelope={v}" + ); assert!( out.stderr.is_empty(), "--json must keep stderr clean: {}", @@ -1208,7 +1211,10 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code(), Some(1)); assert!(stderr.contains("Warning: "), "stderr={stderr}"); - assert!(stderr.contains("Error: Manifest not found at "), "stderr={stderr}"); + assert!( + stderr.contains("Error: Manifest not found at "), + "stderr={stderr}" + ); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 79787517..8691238c 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -378,7 +378,11 @@ fn bare_bool_does_not_consume_next_token() { /// relied on the rejection get a test-visible flip instead of a silent one. #[test] fn multiple_targets_parse_in_order() { - let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]); + let args = parse_rollback(&[ + "pkg:npm/foo@1", + "packages/api/**", + "b0630680-4da6-45f9-bba8-b888e0ffd58c", + ]); assert_eq!( args.targets, vec![ diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs index 444dd2a3..049d8356 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs @@ -149,7 +149,9 @@ fn apply_silent_online_download_failure_keeps_error_output() { ); let chatter = stderr_chatter(&stderr); assert!( - chatter.iter().any(|l| l.contains("could not be downloaded")), + chatter + .iter() + .any(|l| l.contains("could not be downloaded")), "--silent must keep the download-failure error (errors only, \ never nothing); stderr was: {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs index e403c3d6..d68e382d 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs @@ -234,12 +234,9 @@ async fn repair_keeps_healthy_soft_artifact_when_rebuild_dispatch_fails() { let gemfile_wired = std::fs::read(tmp.path().join("Gemfile")).unwrap(); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); - std::fs::remove_file( - tmp.path() - .join(format!( - "vendor/bundle/ruby/3.4.0/gems/{GEM_NAME}-{GEM_VERSION}/lib/padlock.rb" - )), - ) + std::fs::remove_file(tmp.path().join(format!( + "vendor/bundle/ruby/3.4.0/gems/{GEM_NAME}-{GEM_VERSION}/lib/padlock.rb" + ))) .unwrap(); mount_blob(&mock).await; @@ -280,7 +277,10 @@ async fn repair_keeps_healthy_soft_artifact_when_rebuild_dispatch_fails() { &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), ) .unwrap(); - assert_eq!(state["entries"][GEM_PURL]["uuid"], GEM_UUID, "state={state}"); + assert_eq!( + state["entries"][GEM_PURL]["uuid"], GEM_UUID, + "state={state}" + ); assert_eq!( std::fs::read(tmp.path().join("Gemfile")).unwrap(), gemfile_wired, diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index e34a34b1..44e82b5f 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1503,7 +1503,11 @@ async fn scan_hosted_paths_run_once_per_project_directory() { let header = format!("== {} ==", std::path::Path::new(app).display()); assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); } - assert_eq!(stdout.matches("Redirected 0 packages").count(), 2, "{stdout}"); + assert_eq!( + stdout.matches("Redirected 0 packages").count(), + 2, + "{stdout}" + ); let reqs = recorded(&mock).await; assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); } @@ -1942,7 +1946,6 @@ mod pty { screen.join("\n") ); } - } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/covgap_commands_update.rs b/crates/socket-patch-cli/tests/covgap_commands_update.rs index 82aa57f2..ccc7306b 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_update.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_update.rs @@ -9,10 +9,10 @@ //! itself must never be a swap target. Fixture shapes are copied from //! self_update_e2e.rs / interactive_prompts_e2e.rs. -#[path = "common/pty_io.rs"] -mod pty_io; #[path = "common/mod.rs"] mod common; +#[path = "common/pty_io.rs"] +mod pty_io; #[path = "common/update_fixture.rs"] mod update_fixture; @@ -272,9 +272,8 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -342,7 +341,8 @@ mod pty { "a declined update exits 1 (codebase convention); got: {output}" ); assert!( - !output.contains("Updated socket-patch") && !output.contains("Reinstalled socket-patch"), + !output.contains("Updated socket-patch") + && !output.contains("Reinstalled socket-patch"), "a declined update must not report a swap; got: {output}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index a1bed29c..f6549833 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -542,14 +542,24 @@ fn auto_detect_multi_manifest_warning_reaches_json_envelope() { ]) .output() .expect("invoke vex"); - assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); let w = env["warnings"] .as_array() - .and_then(|ws| ws.iter().find(|w| w["code"] == "product_multiple_manifests")) + .and_then(|ws| { + ws.iter() + .find(|w| w["code"] == "product_multiple_manifests") + }) .unwrap_or_else(|| panic!("product_multiple_manifests warning expected: {env}")); assert!( - w["detail"].as_str().unwrap().contains("Multiple project manifests"), + w["detail"] + .as_str() + .unwrap() + .contains("Multiple project manifests"), "{w}" ); let stderr = String::from_utf8_lossy(&out.stderr); diff --git a/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs b/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs index 5fee90f8..87d4900a 100644 --- a/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs @@ -253,7 +253,10 @@ fn rollback_dispatch_branch_deno() { .unwrap_or_else(|e| panic!("rollback envelope must parse ({e}); stdout={stdout}")); let code = out.status.code().unwrap_or(-1); - assert_eq!(code, 0, "rollback --ecosystems=deno: expected exit 0; env={env}"); + assert_eq!( + code, 0, + "rollback --ecosystems=deno: expected exit 0; env={env}" + ); assert_eq!( env["status"], "success", "rollback --ecosystems=deno: expected success; env={env}" @@ -294,7 +297,8 @@ fn rollback_dispatch_branch_deno() { // The decisive check: the on-disk bytes are restored to ORIGINAL. let restored = std::fs::read(&verify_file).unwrap(); assert_eq!( - restored, ORIGINAL, + restored, + ORIGINAL, "rollback --ecosystems=deno: {} was not restored to its original bytes", verify_file.display() ); diff --git a/crates/socket-patch-cli/tests/covgap_output.rs b/crates/socket-patch-cli/tests/covgap_output.rs index eb964cc2..48864f7e 100644 --- a/crates/socket-patch-cli/tests/covgap_output.rs +++ b/crates/socket-patch-cli/tests/covgap_output.rs @@ -172,9 +172,8 @@ fn run_in_pty_inner( .expect("spawn socket-patch in PTY"); drop(pair.slave); - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); // Watchdog: detached kill after `timeout`; a no-op if the child exits // naturally first. @@ -265,7 +264,10 @@ fn remove_interactive_bare_enter_proceeds_with_default_yes() { "\n", Duration::from_secs(15), ); - assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}"); + assert_eq!( + code, 0, + "remove with bare Enter must succeed; got: {output}" + ); // The interactive confirm MUST have run — otherwise this test passes // vacuously against a regression that drops the TTY gate and // auto-proceeds. Match the distinctive prompt verbatim (the loose diff --git a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs index 5b056b87..a2cf92ea 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs @@ -126,7 +126,10 @@ fn remove_malformed_composer_json_errors_not_silent_noop() { write(&cwd.join("composer.json"), MALFORMED_COMPOSER_JSON); let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!(code, 1, "remove on a malformed composer.json must fail: {v}"); + assert_eq!( + code, 1, + "remove on a malformed composer.json must fail: {v}" + ); assert_eq!(v["status"], "error", "{v}"); assert_eq!(v["removed"], 0, "{v}"); assert_eq!(v["errors"], 1, "{v}"); diff --git a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs index 44eefd78..c4d64911 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs @@ -71,7 +71,10 @@ fn setup_remove_clears_bundler_registration_under_bundle_app_config() { &["setup", "--yes", "--json", "--ecosystems", "gem"], &[], ); - assert_eq!(code, 0, "gem setup must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert_eq!( + code, 0, + "gem setup must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); let v = common::parse_json_envelope(&stdout); assert_eq!(v["status"], "success", "{v}"); assert!( @@ -110,7 +113,14 @@ fn setup_remove_clears_bundler_registration_under_bundle_app_config() { // Step 3: unwire with BUNDLE_APP_CONFIG set (child-only env injection). let (code, stdout, stderr) = common::run_with_env( root, - &["setup", "--remove", "--yes", "--json", "--ecosystems", "gem"], + &[ + "setup", + "--remove", + "--yes", + "--json", + "--ecosystems", + "gem", + ], &[("BUNDLE_APP_CONFIG", "bundle-config")], ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs b/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs index 814a55ef..6adf98e7 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs @@ -45,7 +45,10 @@ fn read(path: &Path) -> String { fn write_pm_shim(bin_dir: &Path, name: &str, log: &Path) { use std::os::unix::fs::PermissionsExt; std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let body = format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", log.display()); + let body = format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", + log.display() + ); let p = bin_dir.join(name); std::fs::write(&p, body).expect("write shim"); std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); @@ -80,11 +83,7 @@ fn assert_no_pm_spawned(project: &Path, context: &str) { /// through the shared hermetic runner (the seed-then-scrub of the ambient /// `SOCKET_*` surface is load-bearing: SOCKET_DRY_RUN=true would fake every /// edit, SOCKET_ECOSYSTEMS=npm would hide the Python branch entirely). -fn run_setup_with_shims( - cwd: &Path, - bin_dir: &Path, - extra: &[&str], -) -> (i32, serde_json::Value) { +fn run_setup_with_shims(cwd: &Path, bin_dir: &Path, extra: &[&str]) -> (i32, serde_json::Value) { let path_env = format!( "{}:{}", bin_dir.display(), diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index 65581526..b0429631 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -205,8 +205,7 @@ async fn scan_discovers_fake_registry_crates() { "Expected human scan to report exactly 'Found 2 packages (2 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported no packages despite a populated registry:\n{combined}" ); @@ -263,8 +262,7 @@ async fn scan_discovers_vendor_crates() { "Expected human scan to report exactly 'Found 1 package (1 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported no packages despite a populated vendor dir:\n{combined}" ); diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 63f53e27..4de7aef4 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -179,8 +179,7 @@ async fn scan_discovers_maven_artifacts() { // Must NOT have hit the empty-crawl path — that line *also* contains // the word "packages". assert!( - !combined.contains("No packages found") - && !combined.contains("No packages found"), + !combined.contains("No packages found") && !combined.contains("No packages found"), "scan reported zero packages — Maven discovery did not run:\n{combined}" ); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index 93fc84b7..f3636222 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -229,7 +229,8 @@ async fn scan_discovers_global_cache_packages() { // "packages" substring check would also match). assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") && !combined.contains("No global packages found"), + && !combined.contains("No packages found") + && !combined.contains("No global packages found"), "scan failed to discover the fake global cache:\n{combined}" ); // Exactly the two packages we planted (Newtonsoft.Json, System.Text.Json), @@ -288,7 +289,8 @@ async fn scan_discovers_legacy_packages() { ); assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") && !combined.contains("No global packages found"), + && !combined.contains("No packages found") + && !combined.contains("No global packages found"), "scan failed to discover the legacy packages/ layout:\n{combined}" ); // Exactly the single legacy package we planted (Newtonsoft.Json.13.0.3), diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index 86b754a2..d067baec 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -507,8 +507,16 @@ fn get_help_lists_all_identifier_flags() { // parseable (scripts get that explicit error) but is not advertised. assert!(!stdout.contains("--one-off"), "{stdout}"); // Help text is for users: no implementation notes from the source. - for leak in ["value_parser", "parse_bool_flag", "No env binding", "locally- installed"] { - assert!(!stdout.contains(leak), "get --help leaks {leak:?}: {stdout}"); + for leak in [ + "value_parser", + "parse_bool_flag", + "No env binding", + "locally- installed", + ] { + assert!( + !stdout.contains(leak), + "get --help leaks {leak:?}: {stdout}" + ); } } diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index 61d0f7d3..68bad2eb 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -212,7 +212,10 @@ fn assert_rollback_noop(stdout: &str) { r["skipped"], "package_not_installed", "a no-op rollback may carry only not-installed markers; envelope={v}" ); - assert!(r["path"].is_null(), "marker path must be null; envelope={v}"); + assert!( + r["path"].is_null(), + "marker path must be null; envelope={v}" + ); assert!( r.get("success").is_none() && r.get("error").is_none(), "markers carry no success/error keys; envelope={v}" diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 9b2be79a..84e6e63c 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -61,7 +61,11 @@ fn every_help_page_has_no_developer_notes() { names.extend(cmd.get_subcommands().map(|s| s.get_name().to_string())); let mut failures = Vec::new(); for name in &names { - let path: Vec<&str> = if name.is_empty() { vec![] } else { vec![name.as_str()] }; + let path: Vec<&str> = if name.is_empty() { + vec![] + } else { + vec![name.as_str()] + }; let text = long_help(&path); let found = leaks(&text); if !found.is_empty() { @@ -147,7 +151,9 @@ fn vex_product_list_renders_one_item_per_line() { fn root_command_list_uses_the_verb_form() { let text = long_help(&[]); assert!( - text.contains("Undo patches: restore original files and unwind hosted or vendored lockfile wiring"), + text.contains( + "Undo patches: restore original files and unwind hosted or vendored lockfile wiring" + ), "{text}" ); assert!(!text.contains("Rollback patches"), "{text}"); diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 8b57dfc1..a6e5e43b 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -963,7 +963,8 @@ async fn a_vlt_project_is_withheld_as_offline() { .and_then(|w| w["detail"].as_str()) .expect("the preflight warning is reported"); assert!( - detail.contains("/patch/npm//") && detail.contains(": offline; nothing was written"), + detail.contains("/patch/npm//") + && detail.contains(": offline; nothing was written"), "the offline refusal quotes the redacted URL" ); assert!(output.changed_files.is_empty()); diff --git a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs index 373455be..5da58877 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs @@ -335,11 +335,15 @@ async fn scan_redirect_vlt_artifact_fetch_error() { let detail = warning_detail(&doc, UNVERIFIABLE); let redacted = url.replace(&format!("/{TOKEN}/"), "//"); assert!( - detail.starts_with(&format!("vlt would fail to verify {redacted}: fetch error ")) - && detail.ends_with(&format!("; nothing was written for {PURL}")), + detail.starts_with(&format!( + "vlt would fail to verify {redacted}: fetch error " + )) && detail.ends_with(&format!("; nothing was written for {PURL}")), "the fetch-error refusal quotes the redacted URL" ); - assert!(!detail.contains(TOKEN), "the grant token never reaches the warning"); + assert!( + !detail.contains(TOKEN), + "the grant token never reaches the warning" + ); } async fn redirect_chain(hops: usize) -> (Value, tempfile::TempDir) { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index da069fcb..94b96550 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -110,7 +110,9 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -338,8 +340,10 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { PYPROJECT, "pyproject untouched" ); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); + let ledger: serde_json::Value = serde_json::from_str(&read( + &tmp.path().join(".socket/vendor/redirect-state.json"), + )) + .unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "ledger keyed by the artifact-qualified purl: {ledger}" @@ -364,7 +368,11 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); + assert_eq!( + read(&lock_path), + redirected, + "re-scan must not touch the lock" + ); // 3. The committed state, manifest-less, attests (and only while wired). assert_manifestless_vex(tmp.path(), LOCK); @@ -423,8 +431,10 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { pyproject, "pyproject untouched" ); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); + let ledger: serde_json::Value = serde_json::from_str(&read( + &tmp.path().join(".socket/vendor/redirect-state.json"), + )) + .unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "the pdm redirect must be confirmed and recorded despite the hatch backend: {ledger}" @@ -444,7 +454,11 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { }) .await; assert_eq!(code, 0, "rollback must succeed"); - assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock"); + assert_eq!( + read(&lock_path), + LOCK, + "rollback must restore the pristine lock" + ); } /// The legacy `[metadata.files]` lock (lock_version 2) redirects the package @@ -462,8 +476,10 @@ async fn legacy_metadata_files_lock_redirects_both_fragments_and_warns() { assert_eq!(code, 0); let redirected = read(&lock_path); assert!(redirected.contains(HOSTED_URL), "{redirected}"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); + let ledger: serde_json::Value = serde_json::from_str(&read( + &tmp.path().join(".socket/vendor/redirect-state.json"), + )) + .unwrap(); assert_eq!( ledger["edits"].as_array().unwrap().len(), 2, diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index 43d789de..f25ea3e1 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -54,7 +54,8 @@ const MAJOR_ENV: &str = socket_patch_core::utils::pipenv::MAJOR_OVERRIDE_ENV; const LOCK: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile.lock"); -const PIPFILE: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); +const PIPFILE: &str = + include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); /// The upstream and patched bytes of the record's one file, so the venv /// tests can materialize a real `Ready` (upstream) install. @@ -119,7 +120,9 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) + .and(path_regex(format!( + "^/v0/orgs/{ORG}/patches/by-package/.+$" + ))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -309,7 +312,10 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { serde_json::json!([format!("sha256:{}", sha256())]), "{redirected}" ); - assert!(entry.get("version").is_none() && entry.get("index").is_none(), "{entry}"); + assert!( + entry.get("version").is_none() && entry.get("index").is_none(), + "{entry}" + ); assert_eq!( entry["markers"], urllib3_entry(LOCK)["markers"], @@ -317,11 +323,19 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { ); let before: serde_json::Value = serde_json::from_str(LOCK).unwrap(); let after: serde_json::Value = serde_json::from_str(&redirected).unwrap(); - assert_eq!(after["_meta"], before["_meta"], "the Pipfile content hash stays"); - assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE, "Pipfile untouched"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) - .unwrap(); + assert_eq!( + after["_meta"], before["_meta"], + "the Pipfile content hash stays" + ); + assert_eq!( + read(&tmp.path().join("Pipfile")), + PIPFILE, + "Pipfile untouched" + ); + let ledger: serde_json::Value = serde_json::from_str(&read( + &tmp.path().join(".socket/vendor/redirect-state.json"), + )) + .unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "ledger keyed by the artifact-qualified purl: {ledger}" @@ -341,17 +355,34 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); - assert_eq!(statements[0]["vulnerability"]["name"].as_str(), Some(GHSA), "{vex}"); - assert_eq!(statements[0]["status"].as_str(), Some("not_affected"), "{vex}"); + assert_eq!( + statements[0]["vulnerability"]["name"].as_str(), + Some(GHSA), + "{vex}" + ); + assert_eq!( + statements[0]["status"].as_str(), + Some("not_affected"), + "{vex}" + ); // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); - let ledger: serde_json::Value = - serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) - .unwrap(); - assert_eq!(ledger["edits"].as_array().map(Vec::len), Some(1), "one edit, not two"); + assert_eq!( + read(&lock_path), + redirected, + "re-scan must not touch the lock" + ); + let ledger: serde_json::Value = serde_json::from_str(&read( + &tmp.path().join(".socket/vendor/redirect-state.json"), + )) + .unwrap(); + assert_eq!( + ledger["edits"].as_array().map(Vec::len), + Some(1), + "one edit, not two" + ); // Manifest-less VEX over the committed state (the depscan / CI shape). manifestless_vex(tmp.path(), "pipenv lock-only", &|p: &Path| { @@ -360,7 +391,11 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { // 3. rollback unwinds the redirect and drops the record. roll_back(tmp.path(), server.uri()).await; - assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte"); + assert_eq!( + read(&lock_path), + LOCK, + "rollback must restore the pristine lock byte for byte" + ); let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); if ledger_path.exists() { let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); @@ -393,7 +428,10 @@ async fn legacy_installer_major_selects_path_references() { "Pipenv 7–11 install `path` references: {redirected}" ); assert!(entry.get("file").is_none(), "{entry}"); - assert_eq!(entry["hashes"], serde_json::json!([format!("sha256:{}", sha256())])); + assert_eq!( + entry["hashes"], + serde_json::json!([format!("sha256:{}", sha256())]) + ); // The legacy `path` reference is discovered just like `file`. manifestless_vex(tmp.path(), "pipenv legacy path", &|p: &Path| { @@ -414,7 +452,9 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { write_project(tmp.path()); // The Pipfile.lock left behind pins a DIFFERENT package; the project // installs from requirements.txt. - let stale = LOCK.replace("\"urllib3\"", "\"six\"").replace("==1.26.18", "==1.16.0"); + let stale = LOCK + .replace("\"urllib3\"", "\"six\"") + .replace("==1.26.18", "==1.16.0"); std::fs::write(tmp.path().join("Pipfile.lock"), &stale).unwrap(); std::fs::write(tmp.path().join("requirements.txt"), "urllib3==1.26.18\n").unwrap(); @@ -462,16 +502,27 @@ async fn warm_venv_with_the_upstream_release_is_not_attested() { // attested and the embedded-VEX contract fails the command. let code = run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; let redirected = read(&lock_path); - assert!(redirected.contains(HOSTED_URL), "the lock is still repointed: {redirected}"); + assert!( + redirected.contains(HOSTED_URL), + "the lock is still repointed: {redirected}" + ); let attested = vex_path .exists() .then(|| serde_json::from_str::(&read(&vex_path)).unwrap()) .and_then(|v| v["statements"].as_array().map(Vec::len)) .unwrap_or(0); - assert_eq!(attested, 0, "a stale install must not be attested from the ledger"); + assert_eq!( + attested, 0, + "a stale install must not be attested from the ledger" + ); assert_ne!(code, 0, "nothing to attest fails the embedded-VEX run"); assert_eq!( - std::fs::read(site_packages(tmp.path()).join("urllib3").join("response.py")).unwrap(), + std::fs::read( + site_packages(tmp.path()) + .join("urllib3") + .join("response.py") + ) + .unwrap(), UPSTREAM, "the probe is read-only" ); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs index 7075ba26..ba5deecc 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs @@ -509,8 +509,10 @@ async fn vlt_heal_follows_the_vlt_group_when_another_group_refuses() { let (_, doc) = scan_hosted(root, &server, &["--no-npm-allow-remote-config"], &[]); assert_eq!(redirected(&doc), 1, "the scan redirects both locks"); vlt_install_patched(root, &server); - let drifted = read(root, "package-lock.json") - .replace(&artifact_url(&server), "https://example.invalid/left-pad-1.3.0.tgz"); + let drifted = read(root, "package-lock.json").replace( + &artifact_url(&server), + "https://example.invalid/left-pad-1.3.0.tgz", + ); std::fs::write(root.join("package-lock.json"), &drifted).unwrap(); let cwd = root.to_str().unwrap().to_string(); @@ -526,10 +528,18 @@ async fn vlt_heal_follows_the_vlt_group_when_another_group_refuses() { vlt_lock(Era::V1, &[registry_node(TILDE_ID)]), "the vlt group restored the registry pins" ); - assert_eq!(read(root, "package-lock.json"), drifted, "the refused group wrote nothing"); + assert_eq!( + read(root, "package-lock.json"), + drifted, + "the refused group wrote nothing" + ); assert!( !store_dir(root, TILDE_ID).exists(), "the patched store copy is removed for the restored pins" ); - assert_eq!(advisory_details(&doc), [RESTORED], "the heal advisory is reported"); + assert_eq!( + advisory_details(&doc), + [RESTORED], + "the heal advisory is reported" + ); } diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index ce1fae11..c50acf1d 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -115,9 +115,8 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // closed. The previous design used a chunked read+mpsc loop // because it interleaved with a try_wait poll; the simplified // design serializes wait → drop master → read_to_end joins. - let reader_handle = crate::pty_io::PtyOutput::spawn( - pair.master.try_clone_reader().expect("clone reader"), - ); + let reader_handle = + crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); // Watchdog: detach a thread that kills the child after `timeout`. // The cloned ChildKiller is independent of the main `child` diff --git a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs index d4830cbd..31f45750 100644 --- a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs @@ -533,8 +533,7 @@ fn bare_word_target_stays_identifier_error() { )], false, ); - let manifest_before = - std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); + let manifest_before = std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); let (code, stdout, stderr) = run(tmp.path(), &["--offline", "lodash"]); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index d0b61f89..f3e5362b 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1817,7 +1817,8 @@ async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { ) .unwrap(); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; @@ -2027,7 +2028,8 @@ async fn silent_gates_scan_malformed_ledger_warning() { std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); // Control: without --silent the corruption is surfaced on stderr. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = + run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stderr.contains("malformed"), @@ -2040,7 +2042,11 @@ async fn silent_gates_scan_malformed_ledger_warning() { ); // --silent mutes the advisory warning; the run is otherwise identical. - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--silent"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--silent"], + ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( !stderr.contains("malformed"), @@ -2072,7 +2078,11 @@ async fn ecosystems_filter_keeps_records_but_not_wiring_live() { /*with_record=*/ true, ); - let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &mock.uri(), + &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"], + ); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; diff --git a/crates/socket-patch-cli/tests/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan_paths_e2e.rs index 159b7394..bc301b90 100644 --- a/crates/socket-patch-cli/tests/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_paths_e2e.rs @@ -215,7 +215,11 @@ async fn paths_scope_narrows_the_query() { let tmp = tempfile::tempdir().unwrap(); write_two_subtree_project(tmp.path()); - let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &server.uri(), + &["packages/app", "--mode", "agent", "--dry-run"], + ); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" @@ -476,7 +480,11 @@ async fn supplements_excluded_with_warning() { // purl reaches the API. let scoped_server = MockServer::start().await; mock_batch_empty(&scoped_server).await; - let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan( + tmp.path(), + &scoped_server.uri(), + &["packages/app", "--mode", "agent", "--dry-run"], + ); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 2926892d..65f4cb32 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -3075,7 +3075,11 @@ snapshots: "{v}" ); assert!(events_for(&v, CARGO_SCOPE[0].0).is_empty(), "{v}"); - assert_eq!(record_for(dl, CARGO_SCOPE[1].0)["action"], "downloaded", "{v}"); + assert_eq!( + record_for(dl, CARGO_SCOPE[1].0)["action"], + "downloaded", + "{v}" + ); assert_eq!( events_for(&v, CARGO_SCOPE[1].0), vec![("skipped", "package_not_installed")], diff --git a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs index a13fb56f..04310864 100644 --- a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs @@ -55,7 +55,10 @@ async fn npm_project_local_refuses_with_local_hint() { "a project install must get the in-project upgrade command: {stderr}" ); assert!(!stderr.contains("npm update -g"), "{stderr}"); - assert!(stderr.starts_with("Error: This socket-patch binary ("), "{stderr}"); + assert!( + stderr.starts_with("Error: This socket-patch binary ("), + "{stderr}" + ); } /// An npm-bundled binary (any `node_modules` component) refuses with the diff --git a/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs b/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs index 4c581e70..b07e4327 100644 --- a/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs @@ -1173,8 +1173,7 @@ fn a_package_absent_from_the_lock_keeps_the_not_installed_skip() { b"after\n", ); let home = cargo_home.to_string_lossy().into_owned(); - let (_code, v, stderr) = - run_vendor(&root, &dead, &[], &[("CARGO_HOME", home.as_str())]); + let (_code, v, stderr) = run_vendor(&root, &dead, &[], &[("CARGO_HOME", home.as_str())]); assert_eq!( purl_events(&v, purl), vec![("skipped", "package_not_installed")], @@ -1221,7 +1220,10 @@ fn a_stale_prestage_tree_is_swept_by_the_next_wet_run_only() { let (_code, v, stderr) = run_vendor(root, &dead, &["--dry-run"], &[]); for dir in &litter { - assert!(root.join(dir).exists(), "a dry run deletes nothing: {dir}\n{v:#}\n{stderr}"); + assert!( + root.join(dir).exists(), + "a dry run deletes nothing: {dir}\n{v:#}\n{stderr}" + ); } assert!( !v.to_string().contains("socket-prestage"), @@ -1231,7 +1233,10 @@ fn a_stale_prestage_tree_is_swept_by_the_next_wet_run_only() { for extra in [&["--offline"][..], &[][..]] { let (_code, v, stderr) = run_vendor(root, &dead, extra, &[]); for dir in &litter { - assert!(!root.join(dir).exists(), "{extra:?} sweeps {dir}\n{v:#}\n{stderr}"); + assert!( + !root.join(dir).exists(), + "{extra:?} sweeps {dir}\n{v:#}\n{stderr}" + ); } assert!( !root.join(format!(".socket/vendor/composer/{OLD}")).exists(), diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 395f790d..80472361 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -364,12 +364,7 @@ mod tests { "2020-01-01T00:00:00Z", &["critical", "high"] ), - search_multi( - "z_new_low", - "free", - "2026-08-01T00:00:00Z", - &["low", "low"] - ), + search_multi("z_new_low", "free", "2026-08-01T00:00:00Z", &["low", "low"]), ]), "z_new_low" ); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 348c6377..54816f3c 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -961,7 +961,11 @@ impl NpmCrawler { /// Inside a store entry (`store_entry`) a link is a dependency edge into /// a sibling entry, whose own visit records that copy, so only a real /// directory there matches. - fn visit_resolver_dir(nm_path: PathBuf, store_entry: bool, pending: &[Target]) -> ResolverVisit { + fn visit_resolver_dir( + nm_path: PathBuf, + store_entry: bool, + pending: &[Target], + ) -> ResolverVisit { let listing = list_dir_sync(&nm_path); let probe_filter = ProbeFilter::new(&listing); let matched = pending diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs index d71a0212..2d6e225c 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs @@ -9,9 +9,9 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use super::{ - build_npm_purl, is_legacy_pnpm_store_dir_name, - is_safe_npm_component, parse_package_name, read_package_json, NpmCrawler, StoreEntry, - Target, NESTED_STORE_MAX_DEPTH, NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME, + build_npm_purl, is_legacy_pnpm_store_dir_name, is_safe_npm_component, parse_package_name, + read_package_json, NpmCrawler, StoreEntry, Target, NESTED_STORE_MAX_DEPTH, + NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME, }; use crate::crawlers::types::{CrawledPackage, CrawlerOptions}; use crate::utils::fs::is_dir; diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 03814ab2..3ba4bd3b 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1086,10 +1086,8 @@ pub async fn get_global_python_site_packages() -> Vec { } // 1. Ask Python for site-packages (subprocesses: on the blocking pool) - let site_output = run_blocking(|| { - SITE_QUERY_MEMO.get_or_run(site_query_key(), run_site_query) - }) - .await; + let site_output = + run_blocking(|| SITE_QUERY_MEMO.get_or_run(site_query_key(), run_site_query)).await; if let Some(stdout) = site_output { for p in parse_python_site_packages_output(&stdout) { add_path(p, &mut seen, &mut results); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index ebbc7f92..22ce57d3 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -24,7 +24,9 @@ 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::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, @@ -44,9 +46,9 @@ use super::guidance::{ 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, + 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; @@ -169,7 +171,8 @@ impl SkippedPatch { /// The `skipped[]` JSON entry (`{purl, uuid, reason[, detail]}`). pub fn to_json(&self) -> serde_json::Value { - serde_json::to_value(self).expect("SkippedPatch is plain strings: serialization cannot fail") + serde_json::to_value(self) + .expect("SkippedPatch is plain strings: serialization cannot fail") } } @@ -290,9 +293,7 @@ pub fn build_candidates( let token = reference .registry_override .as_ref() - .and_then(|o| { - crate::patch::redirect::grant_token_path_segment(&o.index_url, sel_uuid) - }) + .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 { @@ -412,7 +413,9 @@ impl CandidateFiles { /// The root-level Python lock names (sorted). async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(cwd) => crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default(), + ProjectView::Disk(cwd) => { + crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default() + } ProjectView::Memory(project) => project .children("") .into_iter() @@ -643,7 +646,10 @@ pub struct Rewritten { /// symbolic link (absent to the planner, refused by [`guard`]). fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, bool) { match view { - ProjectView::Disk(cwd) => (read_workspace_for_trust(&cwd.join(PNPM_WORKSPACE_REL)), false), + ProjectView::Disk(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), @@ -860,10 +866,22 @@ pub async fn rewrite( })); } - 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); + 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 @@ -987,7 +1005,12 @@ fn pnpm_trust( pnpm_lock_texts.push(text); } if pnpm_lock_texts.is_empty() { - return (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked); + 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 @@ -1110,7 +1133,12 @@ fn pnpm_trust( detail.trim_end_matches('.') ), })); - (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) + ( + pnpm_warnings, + trust_config_write, + pnpm_rerun_only, + workspace_symlinked, + ) } /// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every @@ -1188,26 +1216,28 @@ fn npm_allow_remote( 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) + 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(serde_json::json!({ @@ -1419,7 +1449,11 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { /// 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 { +pub fn guard( + view: &ProjectView<'_>, + done: &Rewritten, + candidates: &[Candidate], +) -> Option { if done.workspace_symlinked { return Some(symlink_refusal(PNPM_WORKSPACE_REL)); } diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 01ed71ae..2ffc10e3 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -155,9 +155,7 @@ pub fn pnpm_lock_carries_hosted_redirect( 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.push(crate::utils::uri::encode_uri_component(artifact_url)); needles } @@ -311,11 +309,7 @@ fn npm_allow_remote_preamble(hosts: &[&str]) -> String { /// 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 { +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", diff --git a/crates/socket-patch-core/src/hosted/ledger.rs b/crates/socket-patch-core/src/hosted/ledger.rs index 066918b1..d4e9e19c 100644 --- a/crates/socket-patch-core/src/hosted/ledger.rs +++ b/crates/socket-patch-core/src/hosted/ledger.rs @@ -21,7 +21,6 @@ pub const REBASE_KINDS: &[&str] = &[ crate::patch::redirect::vlt::KIND, ]; - /// Merge this run's vlt node edits into the recorded ones. A fresh edit /// for the same `key` and DepID keeps the oldest recorded `original` (the /// pristine registry entry), takes the fresh `new` and drops the chain's @@ -38,9 +37,7 @@ pub fn rebase_vlt_edits( fresh: &[crate::patch::redirect::FileEdit], before_lock: Option<&str>, ) -> Vec { - use crate::patch::redirect::vlt::{ - carried_pin_original, edit_dep_id, lock_node_ids, KIND, - }; + use crate::patch::redirect::vlt::{carried_pin_original, edit_dep_id, lock_node_ids, KIND}; use crate::patch::redirect::FileEdit; fn superseding(edit: &FileEdit, old: &FileEdit) -> FileEdit { let mut next = edit.clone(); diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index b19a91ea..5f91d40a 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -434,7 +434,9 @@ async fn engine( } let (entries, unsupported) = inventory_project_diagnosed_in(&ProjectView::Memory(project)).await; - for (code, detail) in crate::vendor::lock_inventory::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); diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 453e0ab2..7032d435 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -32,7 +32,10 @@ pub fn vulnerabilities_for_manifest( /// `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 { +pub fn build_patch_record( + patch: &PatchResponse, + files: HashMap, +) -> PatchRecord { PatchRecord { uuid: patch.uuid.clone(), exported_at: patch.published_at.clone(), diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index d6fb3831..6398d800 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -131,11 +131,12 @@ fn assert_same_with_metadata( bun_lockb_present, python_metadata, ); - let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)) - .map(|mut merged| { + let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)).map( + |mut merged| { merged.vlt_drives = vlt::vlt_drives(files, bun_lockb_present); merged - }); + }, + ); assert_eq!( merged.as_ref(), Some(&want), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 9f03dbaa..be3d3227 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -11614,11 +11614,19 @@ mod tests { let redacted = format!( "https://patch.socket.dev/patch/npm/left-pad/1.3.0//{uuid}/left-pad-1.3.0.tgz?x=1" ); - assert_eq!(redact_grant_token(&url, &url, uuid), redacted, "the URL alone"); + assert_eq!( + redact_grant_token(&url, &url, uuid), + redacted, + "the URL alone" + ); let text = format!("vlt would fail to verify {url}: fetch error GET {url}: reset"); - let want = format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); + let want = + format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); assert_eq!(redact_grant_token(&text, &url, uuid), want, "every quote"); - assert!(!redact_grant_token(&text, &url, uuid).contains(token), "no token left"); + assert!( + !redact_grant_token(&text, &url, uuid).contains(token), + "no token left" + ); let registry = format!("https://patch.socket.dev/patch-registry/npm/{token}/{uuid}"); assert_eq!( redact_grant_token(®istry, ®istry, uuid), diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index 51cab65d..e8b44145 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -278,9 +278,18 @@ mod tests { #[test] fn legacy_formats_warn_stale_install_risk_once() { for (fixture, warns) in [ - (include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), true), - (include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), true), - (include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), false), + ( + include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), + true, + ), + ( + include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), + true, + ), + ( + include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), + false, + ), ] { let mut result = RewriteResult::default(); rewrite( diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index 7725c9e6..f2c49a68 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -555,7 +555,10 @@ mod tests { let original = serde_json::to_string(&value).unwrap(); // A live lock (Pipfile beside it): conflicts veto the siblings. let files = BTreeMap::from([ - ("Pipfile".to_string(), "[packages]\nurllib3 = \"*\"\n".to_string()), + ( + "Pipfile".to_string(), + "[packages]\nurllib3 = \"*\"\n".to_string(), + ), ("Pipfile.lock".to_string(), original), ]); let mut result = RewriteResult::default(); @@ -595,20 +598,30 @@ mod tests { for stale in &stale_locks { let files = BTreeMap::from([ ("Pipfile.lock".to_string(), stale.clone()), - ("requirements.txt".to_string(), "urllib3==1.26.18\n".to_string()), + ( + "requirements.txt".to_string(), + "urllib3==1.26.18\n".to_string(), + ), ]); - let result = super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); + let result = + super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); assert!( !result.refused_pipenv_uuids.contains("patch-one"), "a non-conflict must not veto: {stale}" ); assert!( - result.warnings.iter().any(|w| w.code == "redirect_pipenv_skipped"), + result + .warnings + .iter() + .any(|w| w.code == "redirect_pipenv_skipped"), "{:?}", result.warnings ); assert!( - result.files.get("requirements.txt").is_some_and(|t| t.contains("patch.socket.dev")), + result + .files + .get("requirements.txt") + .is_some_and(|t| t.contains("patch.socket.dev")), "requirements.txt must still be redirected past a stale Pipfile.lock: {result:?}" ); assert!(!result.files.contains_key("Pipfile.lock")); @@ -693,10 +706,18 @@ mod tests { ); let foreign = redirected.replacen( redirected_entry, - &format_entry(&json!({"file": "https://example.org/fork.whl"}), &redirected, 0).unwrap(), + &format_entry( + &json!({"file": "https://example.org/fork.whl"}), + &redirected, + 0, + ) + .unwrap(), 1, ); - assert!(restore(&foreign, &edits[0]).is_err(), "a foreign reference is drift"); + assert!( + restore(&foreign, &edits[0]).is_err(), + "a foreign reference is drift" + ); // Re-scan after the relock, then roll back newest-first. let (again, second) = plan(&relocked, &dep, None).unwrap(); @@ -714,7 +735,10 @@ mod tests { let files = |text: &str| BTreeMap::from([("Pipfile.lock".to_string(), text.to_string())]); assert!(lock_targets(&files(&lock()), std::slice::from_ref(&dep))); assert!(!lock_targets(&files(&lock()), std::slice::from_ref(&other))); - assert!(!lock_targets(&files("{ not json"), std::slice::from_ref(&dep))); + assert!(!lock_targets( + &files("{ not json"), + std::slice::from_ref(&dep) + )); assert!(!lock_targets(&BTreeMap::new(), std::slice::from_ref(&dep))); let mut npm = dep.clone(); npm.ecosystem = "npm".into(); @@ -739,7 +763,10 @@ mod tests { let entry: Value = serde_json::from_str(&fixed).unwrap(); assert!(entry["default"]["urllib3"].get("version").is_none()); assert!(entry["default"]["urllib3"].get("index").is_none()); - assert!(entry["default"]["urllib3"]["file"].as_str().unwrap().contains("patch-one")); + assert!(entry["default"]["urllib3"]["file"] + .as_str() + .unwrap() + .contains("patch-one")); value["default"]["urllib3"]["version"] = json!("==2.0.0"); let conflicting = serde_json::to_string(&value).unwrap(); @@ -760,7 +787,10 @@ mod tests { assert!(owned_url(public, &dep)); assert!(!owned_url("https://example.org/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl", &dep)); dep.artifact_url = "https://patches.internal.example:8443/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl".into(); - assert!(owned_url(&dep.artifact_url, &dep), "the grant's own origin is ours"); + assert!( + owned_url(&dep.artifact_url, &dep), + "the grant's own origin is ours" + ); assert!(owned_url(public, &dep), "and so is the public service"); assert!(!owned_url("https://patches.internal.example:8443/patch/pypi/urllib3/1.26.19/tok/patch-one/urllib3-1.26.19-py3-none-any.whl", &dep), "another version is not"); // Rotation on the custom origin restores through the chain. @@ -779,7 +809,12 @@ mod tests { fn restore_refuses_a_non_object_ledger_original() { let dep = dependency("urllib3", "1.26.18", "patch-one"); let (text, edits) = plan(&lock(), &dep, None).unwrap(); - for bad in ["\"just a string\"", "[1, 2]", "not json at all", "{\"a\": 1}, \"injected\": {}"] { + for bad in [ + "\"just a string\"", + "[1, 2]", + "not json at all", + "{\"a\": 1}, \"injected\": {}", + ] { let mut edit = edits[0].clone(); edit.original = Some(Value::String(bad.to_string())); assert!(restore(&text, &edit).is_err(), "{bad}"); @@ -813,18 +848,28 @@ mod tests { for edit in &first_edits { let replacement = edit.new.as_ref().unwrap().as_str().unwrap(); // A tampered reference (its `#sha256=` pin) is drift… - let drift = two.replacen(replacement, &replacement.replace("#sha256=", "#sha256=0"), 1); + let drift = two.replacen( + replacement, + &replacement.replace("#sha256=", "#sha256=0"), + 1, + ); assert!(restore(&drift, edit).is_err()); // …while a re-serialized entry that kept our reference (Pipenv // 2023+ relocking a marker-excluded entry restores the registry // `hashes` and `version` next to it) is still ours and restores. let mut value: Value = serde_json::from_str(&two).unwrap(); - let section: &str = serde_json::from_str::<[String; 2]>(edit.key.as_deref().unwrap()).unwrap()[0].clone().leak(); + let section: &str = serde_json::from_str::<[String; 2]>(edit.key.as_deref().unwrap()) + .unwrap()[0] + .clone() + .leak(); value[section]["urllib3"]["hashes"] = json!(["sha256:upstream-a", "sha256:upstream-b"]); value[section]["urllib3"]["version"] = json!("==1.26.18"); let kept = serde_json::to_string_pretty(&value).unwrap(); let restored: Value = serde_json::from_str(&restore(&kept, edit).unwrap()).unwrap(); - assert!(restored[section]["urllib3"].get("file").is_none(), "{restored}"); + assert!( + restored[section]["urllib3"].get("file").is_none(), + "{restored}" + ); let mut unsafe_edit = edit.clone(); unsafe_edit.path = "../Pipfile.lock".into(); assert!(restore(&two, &unsafe_edit).is_err()); @@ -883,7 +928,10 @@ mod compatibility_tests { assert!(!result.refused_pipenv_uuids.contains("patch-one")); assert!(result.files["requirements.txt"].contains("patch.socket.dev")); assert!(!result.files.contains_key("Pipfile.lock")); - assert!(result.warnings.iter().any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); + assert!(result + .warnings + .iter() + .any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); } /// Rollback survives what git and Pipenv do to the lock between the @@ -915,11 +963,17 @@ mod compatibility_tests { value["default"]["urllib3"]["version"] = json!("==1.26.18"); value["default"]["urllib3"]["index"] = json!("pypi"); let hybrid = serde_json::to_string_pretty(&value).unwrap(); - let default_edit = edits.iter().find(|e| e.key.as_deref() == Some(r#"["default","urllib3"]"#)).unwrap(); + let default_edit = edits + .iter() + .find(|e| e.key.as_deref() == Some(r#"["default","urllib3"]"#)) + .unwrap(); let restored = restore(&hybrid, default_edit).unwrap(); let value: Value = serde_json::from_str(&restored).unwrap(); assert_eq!(value["default"]["urllib3"]["version"], json!("==1.26.18")); - assert!(value["default"]["urllib3"].get("file").is_none(), "{restored}"); + assert!( + value["default"]["urllib3"].get("file").is_none(), + "{restored}" + ); // Dropped entry (`pipenv uninstall`): nothing to unwind, retires. let mut value: Value = serde_json::from_str(&redirected).unwrap(); value["default"].as_object_mut().unwrap().remove("urllib3"); diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index a2798cd6..eacc889a 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -92,7 +92,9 @@ pub(super) fn rewrite_poetry( } } Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -100,7 +102,9 @@ pub(super) fn rewrite_poetry( continue; } } - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); content = rewrite.text; if !stale_warned { if let Some(format) = @@ -136,14 +140,18 @@ pub(super) fn rewrite_poetry( } // Already redirected to this artifact (idempotent re-scan). Ok(Some(_)) => { - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -215,7 +223,9 @@ fn rewrite_poetry_reference( } } Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -223,7 +233,9 @@ fn rewrite_poetry_reference( continue; } } - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); content = rewritten; if !stale_warned { if let Some(format) = pre_1_4_writer(&content) { @@ -257,14 +269,18 @@ fn rewrite_poetry_reference( } // Already redirected to this artifact (idempotent re-scan). Ok(Some(_)) => { - result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_python_lock_uuids + .insert(dep.patch_uuid.clone()); } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), Err(detail) => { - result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); + result + .refused_python_lock_uuids + .insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 5e0b921c..37e01cbc 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -265,7 +265,9 @@ pub(super) fn rewrite( } } matched = true; - result.confirmed_requirements_uuids.insert(dep.patch_uuid.clone()); + result + .confirmed_requirements_uuids + .insert(dep.patch_uuid.clone()); let options = requirement_tokens(specifier) .into_iter() .skip_while(|token| !token.starts_with("--")) diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs index f176426c..be1476b1 100644 --- a/crates/socket-patch-core/src/update/download.rs +++ b/crates/socket-patch-core/src/update/download.rs @@ -741,7 +741,10 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("never-existed"); sweep_stale_stages(&missing); - assert!(!missing.exists(), "sweep must not create the destination dir"); + assert!( + !missing.exists(), + "sweep must not create the destination dir" + ); } /// A write failure AFTER a successful open (EFBIG here, standing in @@ -757,8 +760,7 @@ mod tests { #[test] fn stage_write_failure_cleans_up_stage_file() { const CHILD_ENV: &str = "SOCKET_PATCH_CORE_TEST_STAGE_FSIZE_CHILD"; - const TEST_NAME: &str = - "update::download::tests::stage_write_failure_cleans_up_stage_file"; + const TEST_NAME: &str = "update::download::tests::stage_write_failure_cleans_up_stage_file"; if std::env::var_os(CHILD_ENV).is_none() { let exe = std::env::current_exe().expect("test binary path must resolve"); let output = std::process::Command::new(exe) @@ -824,7 +826,10 @@ mod tests { matches!(err, UpdateError::SwapFailed(_)), "expected SwapFailed, got: {err}" ); - assert!(err.to_string().contains("error writing staged binary"), "{err}"); + assert!( + err.to_string().contains("error writing staged binary"), + "{err}" + ); let leftovers: Vec = std::fs::read_dir(tmp.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index 5b286901..7c3b04c2 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -751,9 +751,11 @@ mod tests { .mount(&server) .await; - let client = - metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) - .unwrap(); + let client = metadata_client( + &short_timeouts(), + follow_redirect_policy(&default_endpoints()), + ) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -786,9 +788,11 @@ mod tests { .mount(&server) .await; - let client = - metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) - .unwrap(); + let client = metadata_client( + &short_timeouts(), + follow_redirect_policy(&default_endpoints()), + ) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -864,7 +868,10 @@ mod tests { .unwrap_err(); assert!(matches!(err, UpdateError::CheckFailed(_)), "{err:?}"); let msg = err.to_string(); - assert!(msg.contains("expected a redirect to the latest tag"), "{msg}"); + assert!( + msg.contains("expected a redirect to the latest tag"), + "{msg}" + ); assert!(msg.contains("API fallback:"), "{msg}"); assert!(msg.contains("returned 500"), "{msg}"); } @@ -945,8 +952,14 @@ mod tests { #[test] fn url_host_keeps_explicit_ports() { - assert_eq!(url_host("http://127.0.0.1:9/x").as_deref(), Some("127.0.0.1:9")); - assert_eq!(url_host("https://github.com/a").as_deref(), Some("github.com")); + assert_eq!( + url_host("http://127.0.0.1:9/x").as_deref(), + Some("127.0.0.1:9") + ); + assert_eq!( + url_host("https://github.com/a").as_deref(), + Some("github.com") + ); assert_eq!(url_host("not a url"), None); } @@ -959,7 +972,9 @@ mod tests { // code stays `check_failed` (stable contract). let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) + .and(path( + "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", + )) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; @@ -992,7 +1007,9 @@ mod tests { // silently. let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) + .and(path( + "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", + )) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index d00a2da3..85490b35 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -296,9 +296,9 @@ where // write the lock edits beside the pre-run ledger. Put the caller's value // back before the unwind continues — the same value a caught-and- // continued caller holds. - if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - edit(Arc::make_mut(value)) - })) { + if let Err(panic) = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| edit(Arc::make_mut(value)))) + { files.insert(key, captured(value)); drop(files); std::panic::resume_unwind(panic); @@ -1595,7 +1595,10 @@ mod tests { .unwrap(); remove_dir_after_commit(&dir).await; drop(dropped); - assert!(dir.join("config.toml").exists(), "an abandoned commit removes nothing"); + assert!( + dir.join("config.toml").exists(), + "an abandoned commit removes nothing" + ); let group = GroupCommit::begin(root); super::super::fs::remove_file(&dir.join("config.toml")) @@ -1604,7 +1607,10 @@ mod tests { remove_dir_after_commit(&dir).await; assert!(dir.join("config.toml").exists(), "captured, still on disk"); group.commit().await.unwrap(); - assert!(!dir.exists(), "the emptied directory is removed after the commit"); + assert!( + !dir.exists(), + "the emptied directory is removed after the commit" + ); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("config.toml"), b"[patch]\n").unwrap(); @@ -1616,7 +1622,10 @@ mod tests { remove_dir_after_commit(&dir).await; group.commit().await.unwrap(); assert!(!dir.join("config.toml").exists()); - assert!(dir.join("credentials.toml").exists(), "a non-empty directory is kept"); + assert!( + dir.join("credentials.toml").exists(), + "a non-empty directory is kept" + ); remove_dir_after_commit(&root.join("gone")).await; std::fs::remove_file(dir.join("credentials.toml")).unwrap(); diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 80a6dda9..a2e23dcb 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -6,9 +6,9 @@ pub mod env_compat; pub mod failpoint; pub mod fs; pub mod group_commit; -pub mod notice; pub(crate) mod http; pub(crate) mod line_endings; +pub mod notice; pub mod pdm_lock; pub mod pipenv; pub mod poetry_lock; diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index 4dbd1816..1954f109 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -358,9 +358,11 @@ fn plan_pdm_rewrite( .filter_map(|&index| packages.get(index)?.get("version").and_then(Item::as_str)) .collect(); if locked_versions.len() > 1 { - return Err("PDM lock resolves this package at multiple versions (a marker or \ + return Err( + "PDM lock resolves this package at multiple versions (a marker or \ multi-target fork); patching one fork would leave the others unpatched" - .into()); + .into(), + ); } let mut variants = std::collections::BTreeSet::new(); let mut edits = Vec::new(); @@ -775,7 +777,10 @@ mod tests { &"a".repeat(64), ) .unwrap(); - assert!(rewired.contains(&fresh) && !rewired.contains(&stale), "{rewired}"); + assert!( + rewired.contains(&fresh) && !rewired.contains(&stale), + "{rewired}" + ); // A foreign (non-Socket) existing url is still refused. let foreign = fixture("2.29.2").replace( "name = \"urllib3\"", diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index 48b40b8a..e0d2473e 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -71,7 +71,10 @@ fn lock_version_of(lock: &Table) -> Result<&str, String> { { Ok("0") } - None => Err("poetry.lock has neither a [metadata] lock-version nor a [metadata.hashes] table".into()), + None => Err( + "poetry.lock has neither a [metadata] lock-version nor a [metadata.hashes] table" + .into(), + ), } } @@ -630,7 +633,15 @@ mod tests { Ok(other) => panic!("{label}: expected a refusal, got {other:?}"), } // The vendored (file-source) spelling takes the same guarded path. - match rewrite_poetry_lock(&text, "urllib3", "1.26.18", "file", ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", WHEEL, &sha()) { + match rewrite_poetry_lock( + &text, + "urllib3", + "1.26.18", + "file", + ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", + WHEEL, + &sha(), + ) { Err(err) => assert!(!err.is_empty(), "{label}"), Ok(other) => panic!("{label}: expected a refusal, got {other:?}"), } @@ -648,7 +659,10 @@ mod tests { assert!(rewritten.contains(URL)); assert!(rewritten.contains("lock-version = \"2.2\"")); for bad in ["3.0", "2", "2.x", "1.2"] { - let lock = fixture("2.4.3").replace("lock-version = \"2.1\"", &format!("lock-version = \"{bad}\"")); + let lock = fixture("2.4.3").replace( + "lock-version = \"2.1\"", + &format!("lock-version = \"{bad}\""), + ); let err = hosted(&lock).unwrap_err(); assert!(err.contains(bad), "{bad}: {err}"); } @@ -671,28 +685,47 @@ mod tests { // Poetry 1.0 carries a `#sha256=…&` fragment; the comparison ignores it. let lock10 = fixture("1.0.10"); let first10 = hosted(&lock10).unwrap().unwrap(); - let second10 = rewrite_poetry_lock(&first10, "urllib3", "1.26.18", "url", &rotated, WHEEL, &"b".repeat(64)) - .unwrap() - .unwrap(); + let second10 = rewrite_poetry_lock( + &first10, + "urllib3", + "1.26.18", + "url", + &rotated, + WHEEL, + &"b".repeat(64), + ) + .unwrap() + .unwrap(); assert!(second10.contains(&format!("{rotated}#sha256={}&", "b".repeat(64)))); // A user's own url source on another origin stays untouched. let foreign = first.replace("https://patch.socket.dev", "https://mirror.example"); - assert!(hosted(&foreign).unwrap_err().contains("existing Poetry source")); + assert!(hosted(&foreign) + .unwrap_err() + .contains("existing Poetry source")); // A vendored file source is never taken over by the hosted path here. - let vendored = rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "file", ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", WHEEL, &sha()) - .unwrap() - .unwrap(); - assert!(hosted(&vendored).unwrap_err().contains("existing Poetry source")); + let vendored = rewrite_poetry_lock( + &lock, + "urllib3", + "1.26.18", + "file", + ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", + WHEEL, + &sha(), + ) + .unwrap() + .unwrap(); + assert!(hosted(&vendored) + .unwrap_err() + .contains("existing Poetry source")); } #[test] fn sha256_is_written_lowercase() { let lock = fixture("2.4.3"); let upper = "A".repeat(64); - let rewritten = - rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "url", URL, WHEEL, &upper) - .unwrap() - .unwrap(); + let rewritten = rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "url", URL, WHEEL, &upper) + .unwrap() + .unwrap(); assert!(rewritten.contains(&format!("sha256:{}", "a".repeat(64)))); assert!(!rewritten.contains(&upper)); } @@ -712,7 +745,10 @@ mod tests { let lock = format!("{lock}{sibling}"); let rewritten = hosted(&lock).unwrap().unwrap(); assert!(rewritten.contains(URL)); - assert!(rewritten.contains(&sibling), "sibling entry must survive verbatim"); + assert!( + rewritten.contains(&sibling), + "sibling entry must survive verbatim" + ); let edits = poetry_lock_edits(&lock, &rewritten, "urllib3").unwrap(); assert_eq!(edits.len(), 2); assert!(edits[1].0.starts_with('\n')); @@ -733,7 +769,10 @@ mod tests { "{version}: {original:?}" ); assert!(new.ends_with("[metadata]") || new.ends_with("[extras]")); - assert!(!new.contains(original.as_str()), "{version}: pristine must not be a prefix of new"); + assert!( + !new.contains(original.as_str()), + "{version}: pristine must not be a prefix of new" + ); // A relock that keeps `[package.source]` but drops the inserted // `files` line must NOT contain the pristine fragment either. let drifted: String = rewritten @@ -747,12 +786,20 @@ mod tests { // header, the second starts with it; both splice independently. let lock = fixture("2.4.3"); let mut doc: DocumentMut = lock.parse().unwrap(); - let mut second = doc["package"].as_array_of_tables().unwrap().get(0).unwrap().clone(); + let mut second = doc["package"] + .as_array_of_tables() + .unwrap() + .get(0) + .unwrap() + .clone(); second["name"] = value("six"); second["version"] = value("1.16.0"); second.set_position(None); second.remove("extras"); - doc["package"].as_array_of_tables_mut().unwrap().push(second); + doc["package"] + .as_array_of_tables_mut() + .unwrap() + .push(second); let two = doc.to_string(); let first = hosted(&two).unwrap().unwrap(); let edits = poetry_lock_edits(&two, &first, "urllib3").unwrap(); @@ -764,11 +811,29 @@ mod tests { fn absent_or_other_version_yields_none_not_error() { let lock = fixture("2.4.3"); assert_eq!( - rewrite_poetry_lock(&lock, "six", "1.16.0", "url", &URL.replace("urllib3", "six").replace("1.26.18", "1.16.0"), "six-1.16.0-py2.py3-none-any.whl", &sha()).unwrap(), + rewrite_poetry_lock( + &lock, + "six", + "1.16.0", + "url", + &URL.replace("urllib3", "six").replace("1.26.18", "1.16.0"), + "six-1.16.0-py2.py3-none-any.whl", + &sha() + ) + .unwrap(), None ); assert_eq!( - rewrite_poetry_lock(&lock, "urllib3", "1.26.17", "url", &URL.replace("1.26.18", "1.26.17"), "urllib3-1.26.17-py2.py3-none-any.whl", &sha()).unwrap(), + rewrite_poetry_lock( + &lock, + "urllib3", + "1.26.17", + "url", + &URL.replace("1.26.18", "1.26.17"), + "urllib3-1.26.17-py2.py3-none-any.whl", + &sha() + ) + .unwrap(), None ); } diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index 2ca51e10..5eb99700 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -627,7 +627,12 @@ mod rendering_tests { "{direct}" ); assert!(uv_line.ends_with('}'), "{direct}"); - assert!(direct.starts_with("[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"), "{direct}"); + assert!( + direct.starts_with( + "[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n" + ), + "{direct}" + ); assert_settled(&direct); let transitive = rewrite_project_metadata( 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 8858b27d..de782d40 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; @@ -210,9 +210,7 @@ pub struct UnsupportedNpmLayout { /// (`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)> { +pub fn unsupported_layout_warnings(unsupported: &[UnsupportedNpmLayout]) -> Vec<(String, String)> { unsupported .iter() .map(|diag| match diag.code { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs index f84eed67..ba132444 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs @@ -6,8 +6,8 @@ use std::path::Path; use serde_json::{Map, Value}; -use crate::constants::npm_family::VLT_LOCK; use super::view::ProjectView; +use crate::constants::npm_family::VLT_LOCK; use crate::vendor::vlt_lock_text::{ is_default_registry, sniff_lock, split_dep_id, DepId, DepIdKind, LockSniff, }; diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 82cfd580..a675937d 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -1249,8 +1249,10 @@ mod tests { let why = gitignore_probe(&root, &outside).await.unwrap_err(); assert!(why.contains("`git check-ignore` exited 128"), "{why}"); assert_eq!(gitignored(&root, &outside).await, None); - assert!(gitignore_unchecked_detail(".socket/vendor/npm/u/a-1.0.0", &why) - .contains("make sure no ignore rule covers .socket/")); + assert!( + gitignore_unchecked_detail(".socket/vendor/npm/u/a-1.0.0", &why) + .contains("make sure no ignore rule covers .socket/") + ); } #[cfg(unix)] diff --git a/crates/socket-patch-core/src/vendor/prestage.rs b/crates/socket-patch-core/src/vendor/prestage.rs index b3149cca..b4cf64fb 100644 --- a/crates/socket-patch-core/src/vendor/prestage.rs +++ b/crates/socket-patch-core/src/vendor/prestage.rs @@ -464,7 +464,10 @@ mod sweep_tests { for dir in &kept { assert!(v.join(dir).exists(), "{dir} kept"); } - assert!(!v.join("gem").exists(), "the levels only the tree kept alive are pruned"); + assert!( + !v.join("gem").exists(), + "the levels only the tree kept alive are pruned" + ); assert!(!v.join(format!("composer/{u}/psr/log@3.0.2")).exists()); assert!(v.join("state.json").exists()); assert_eq!(sweep_stale(root).await, 0, "idempotent"); diff --git a/crates/socket-patch-core/src/vendor/toml_surgery.rs b/crates/socket-patch-core/src/vendor/toml_surgery.rs index 23c26154..2a10e5fb 100644 --- a/crates/socket-patch-core/src/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/vendor/toml_surgery.rs @@ -506,7 +506,8 @@ mod tests { // CRLF, and a hand edit can leave a mixed-ending file, so the // removal helpers must never normalize: every byte outside the // removed segment survives verbatim. - let wired = "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; + let wired = + "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; let after = remove_exact_line(wired, "foo = { path = \"w.whl\" }").unwrap(); assert_eq!(after, "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\n"); assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 34b3c3ef..335aa175 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2951,7 +2951,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/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 460bb2e6..75124d3b 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -571,5 +571,8 @@ async fn fetch_missing_blobs_mixed_outcomes_aggregate_and_format() { // End-to-end formatter exercise with a genuinely mixed result. let rendered = format_fetch_result(&result); assert!(rendered.contains("Downloaded 1 blob\n"), "{rendered}"); - assert!(rendered.contains("Failed to download 2 blobs"), "{rendered}"); + assert!( + rendered.contains("Failed to download 2 blobs"), + "{rendered}" + ); } diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 24a50d9b..fbbda629 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -95,7 +95,11 @@ async fn get_vendor_paths_global_nonexistent_composer_home_falls_back() { fn write_composer_shim(dir: &Path, echo_path: &Path) { use std::os::unix::fs::PermissionsExt; let shim = dir.join("composer"); - std::fs::write(&shim, format!("#!/bin/sh\necho '{}'\n", echo_path.display())).unwrap(); + std::fs::write( + &shim, + format!("#!/bin/sh\necho '{}'\n", echo_path.display()), + ) + .unwrap(); std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); } diff --git a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs index 5f712da1..7842d44b 100644 --- a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs +++ b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs @@ -88,9 +88,7 @@ async fn stage_tempdir_creation_failure_is_reported_not_fatal() { drop(guard); match outcome { - VendorOutcome::Done { - result, entry, .. - } => { + VendorOutcome::Done { result, entry, .. } => { assert!(!result.success, "the stage failure must fail the vendor"); assert!(entry.is_none(), "no ledger entry for a failed vendor"); let err = result.error.as_deref().unwrap_or(""); diff --git a/crates/socket-patch-core/tests/poetry_hosted.rs b/crates/socket-patch-core/tests/poetry_hosted.rs index 74ad3cd6..dc1e89cd 100644 --- a/crates/socket-patch-core/tests/poetry_hosted.rs +++ b/crates/socket-patch-core/tests/poetry_hosted.rs @@ -65,7 +65,11 @@ async fn native_lock_generations_redirect_idempotently_and_restore_every_byte() let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - if pre_1_4 { vec!["redirect_poetry_stale_install_risk"] } else { vec![] }, + if pre_1_4 { + vec!["redirect_poetry_stale_install_risk"] + } else { + vec![] + }, "{version}: {:?}", result.warnings ); @@ -111,12 +115,24 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert!(lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), "{lock10}"); + assert!( + lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), + "{lock10}" + ); assert!(lock10.contains("reference = \"\""), "{lock10}"); - assert!(lock10.contains(&format!("urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]")), "{lock10}"); + assert!( + lock10.contains(&format!( + "urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]" + )), + "{lock10}" + ); // Poetry >= 1.2 consuming this 1.0 lock verifies the package `files` // entry, so it is written too (1.0 ignores the extra key). - assert_eq!(lock10.matches(&format!("sha256:{sha}")).count(), 2, "{lock10}"); + assert_eq!( + lock10.matches(&format!("sha256:{sha}")).count(), + 2, + "{lock10}" + ); let doc: toml_edit::DocumentMut = lock10.parse().unwrap(); assert!(doc["package"][0]["files"].is_array(), "{lock10}"); @@ -126,8 +142,15 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!(lock11.matches(&format!("sha256:{sha}")).count(), 2, "package files + metadata.files:\n{lock11}"); - assert!(lock11.contains(&format!("url = \"{URL}\"")), "no fragment on 1.1"); + assert_eq!( + lock11.matches(&format!("sha256:{sha}")).count(), + 2, + "package files + metadata.files:\n{lock11}" + ); + assert!( + lock11.contains(&format!("url = \"{URL}\"")), + "no fragment on 1.1" + ); assert!(!lock11.contains("reference"), "{lock11}"); let doc: toml_edit::DocumentMut = lock11.parse().unwrap(); assert!(doc["package"][0]["files"].is_array()); @@ -139,11 +162,19 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!(lock21.matches(&format!("sha256:{sha}")).count(), 1, "{lock21}"); + assert_eq!( + lock21.matches(&format!("sha256:{sha}")).count(), + 1, + "{lock21}" + ); assert!(!lock21.contains("reference")); let pristine: toml_edit::DocumentMut = original("2.4.3").parse().unwrap(); let doc: toml_edit::DocumentMut = lock21.parse().unwrap(); - assert_eq!(doc["metadata"].to_string(), pristine["metadata"].to_string(), "[metadata] untouched on 2.x"); + assert_eq!( + doc["metadata"].to_string(), + pristine["metadata"].to_string(), + "[metadata] untouched on 2.x" + ); } #[test] @@ -360,14 +391,21 @@ fn absent_entries_warn_once_and_missing_sha256_is_gated_once_per_dep() { let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - vec!["redirect_poetry_entry_not_found", "redirect_poetry_entry_not_found"] + vec![ + "redirect_poetry_entry_not_found", + "redirect_poetry_entry_not_found" + ] ); let mut missing_hash = patch(); missing_hash.integrity.sha256 = None; let result = rewrite_registry_redirect(&files, &[missing_hash]); assert!(result.files.is_empty()); let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); - assert_eq!(codes, vec!["redirect_poetry_missing_sha256"], "gated once, not once per lock"); + assert_eq!( + codes, + vec!["redirect_poetry_missing_sha256"], + "gated once, not once per lock" + ); } /// A future Poetry that bumps the lock minor (2.2) is rewritten like 2.1 in @@ -381,9 +419,12 @@ async fn newer_2x_minor_redirects_and_reverts() { assert!(result.warnings.is_empty(), "{:?}", result.warnings); assert!(result.files["poetry.lock"].contains(URL)); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write(directory.path().join("poetry.lock"), &result.files["poetry.lock"]) - .await - .unwrap(); + tokio::fs::write( + directory.path().join("poetry.lock"), + &result.files["poetry.lock"], + ) + .await + .unwrap(); let mut state = RedirectState { edits: result.edits, ..RedirectState::default() @@ -391,7 +432,9 @@ async fn newer_2x_minor_redirects_and_reverts() { let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")) + .await + .unwrap(), lock ); } @@ -405,13 +448,22 @@ fn rotated_grant_token_supersedes_the_prior_hosted_url() { let first = rewrite_registry_redirect(&files, &[patch()]); let mut rotated = patch(); rotated.token = "00000000-0000-4000-8000-000000000000".into(); - rotated.artifact_url = URL.replace("7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", "00000000-0000-4000-8000-000000000000"); + rotated.artifact_url = URL.replace( + "7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", + "00000000-0000-4000-8000-000000000000", + ); let second = rewrite_registry_redirect(&first.files, &[rotated.clone()]); assert!(second.warnings.is_empty(), "{:?}", second.warnings); let lock = &second.files["poetry.lock"]; assert!(lock.contains(&rotated.artifact_url) && !lock.contains(URL)); assert_eq!(second.edits.len(), 1); - assert!(second.edits[0].original.as_ref().unwrap().as_str().unwrap().contains(URL)); + assert!(second.edits[0] + .original + .as_ref() + .unwrap() + .as_str() + .unwrap() + .contains(URL)); } /// A relock (or hand edit) that drops the inserted `files` line but keeps @@ -432,22 +484,35 @@ async fn dropped_files_line_with_source_kept_is_refused_not_converged() { .collect::>() .join("\n") + "\n"; - assert_ne!(drifted, *redirected, "{version}: the files line must have been removed"); + assert_ne!( + drifted, *redirected, + "{version}: the files line must have been removed" + ); assert!(drifted.contains("[package.source]")); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write(directory.path().join("poetry.lock"), &drifted).await.unwrap(); + tokio::fs::write(directory.path().join("poetry.lock"), &drifted) + .await + .unwrap(); let mut state = RedirectState { edits: result.edits.clone(), ..RedirectState::default() }; let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; - assert!(!outcome.fully_reverted(), "{version}: must refuse, not report success"); + assert!( + !outcome.fully_reverted(), + "{version}: must refuse, not report success" + ); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")) + .await + .unwrap(), drifted, "{version}: a refused revert writes nothing" ); - assert!(!state.edits.is_empty(), "{version}: the ledger keeps its edits for a re-scan"); + assert!( + !state.edits.is_empty(), + "{version}: the ledger keeps its edits for a re-scan" + ); } } @@ -462,7 +527,9 @@ async fn lock_1_0_rollback_converges_on_a_hand_restored_lock() { let result = rewrite_registry_redirect(&files, &[patch()]); assert!(!result.edits.is_empty()); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write(directory.path().join("poetry.lock"), &pristine).await.unwrap(); + tokio::fs::write(directory.path().join("poetry.lock"), &pristine) + .await + .unwrap(); let mut state = RedirectState { edits: result.edits, ..RedirectState::default() @@ -471,7 +538,9 @@ async fn lock_1_0_rollback_converges_on_a_hand_restored_lock() { assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); assert!(state.edits.is_empty()); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")) + .await + .unwrap(), pristine ); } diff --git a/crates/socket-patch-node/src/lib.rs b/crates/socket-patch-node/src/lib.rs index d83403b8..29fa8e6a 100644 --- a/crates/socket-patch-node/src/lib.rs +++ b/crates/socket-patch-node/src/lib.rs @@ -15,11 +15,11 @@ 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_core::api::client::PatchApi; use socket_patch_core::hosted::memory::{ - self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, SelectOptions, - SessionBuilder, TreeEntryInput, + self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, + SelectOptions, SessionBuilder, TreeEntryInput, }; -use socket_patch_core::api::client::PatchApi; use tokio_util::sync::CancellationToken; use provider::{JsPatchApi, ProviderRefs}; From 591fb3dc061c7bcee206bfe054807dd230eff9ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:08:46 +0000 Subject: [PATCH 3/7] Undo unrelated rustfmt churn from the takeover split The previous commit ran `cargo fmt --all` over a tree that is not rustfmt-clean, reformatting ~30 files it does not otherwise touch. Restore those files; no code changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- crates/socket-patch-cli/src/args.rs | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 4 +- crates/socket-patch-cli/src/commands/get.rs | 32 +++-- .../src/commands/scan/discovery.rs | 9 +- .../src/commands/scan/hosted.rs | 12 +- .../src/commands/scan/hosted/vlt.rs | 6 +- .../socket-patch-cli/src/commands/scan/mod.rs | 69 +++++------ .../src/commands/scan/render.rs | 5 +- crates/socket-patch-cli/src/commands/setup.rs | 3 +- .../socket-patch-cli/src/commands/update.rs | 21 +--- .../socket-patch-cli/src/commands/vendor.rs | 6 +- .../socket-patch-cli/tests/apply_network.rs | 5 +- .../tests/cli_config_fallback.rs | 7 +- .../socket-patch-cli/tests/cli_parse_list.rs | 10 +- .../tests/cli_parse_rollback.rs | 6 +- .../coverage_fix_apply_silent_mute_exit.rs | 4 +- .../coverage_fix_repair_vendor_predelete.rs | 14 +-- .../tests/covgap_commands_scan_mod.rs | 7 +- .../tests/covgap_commands_update.rs | 12 +- .../tests/covgap_commands_vex.rs | 16 +-- .../tests/covgap_ecosystem_dispatch.rs | 8 +- .../socket-patch-cli/tests/covgap_output.rs | 10 +- .../tests/covgap_setup_composer_mod.rs | 5 +- .../tests/covgap_setup_gem_mod.rs | 14 +-- .../tests/covgap_setup_pypi_detect.rs | 11 +- crates/socket-patch-cli/tests/e2e_cargo.rs | 6 +- crates/socket-patch-cli/tests/e2e_maven.rs | 3 +- crates/socket-patch-cli/tests/e2e_nuget.rs | 6 +- .../tests/get_edge_cases_e2e.rs | 12 +- .../tests/global_packages_e2e.rs | 5 +- .../tests/help_text_hygiene.rs | 10 +- .../tests/hosted_memory_engine.rs | 3 +- .../tests/in_process_redirect/vlt.rs | 10 +- .../tests/in_process_redirect_pdm.rs | 34 ++---- .../tests/in_process_redirect_pipenv.rs | 93 ++++---------- .../tests/in_process_rollback_hosted/vlt.rs | 18 +-- .../tests/interactive_prompts_e2e.rs | 5 +- .../tests/rollback_duality_invariants.rs | 3 +- .../socket-patch-cli/tests/scan_invariants.rs | 18 +-- .../socket-patch-cli/tests/scan_paths_e2e.rs | 12 +- .../socket-patch-cli/tests/scan_vendor_e2e.rs | 6 +- .../tests/self_update_channels_e2e.rs | 5 +- .../tests/vendor_rerun_no_network_e2e.rs | 13 +- crates/socket-patch-core/src/api/ranking.rs | 7 +- .../src/crawlers/npm_crawler.rs | 6 +- .../src/crawlers/npm_crawler/oracle.rs | 6 +- .../src/crawlers/python_crawler.rs | 6 +- crates/socket-patch-core/src/hosted/engine.rs | 106 ++++++---------- .../socket-patch-core/src/hosted/guidance.rs | 10 +- crates/socket-patch-core/src/hosted/ledger.rs | 5 +- .../src/hosted/memory/mod.rs | 4 +- .../socket-patch-core/src/manifest/records.rs | 5 +- .../patch/redirect/group_equivalence_tests.rs | 7 +- .../src/patch/redirect/mod.rs | 14 +-- .../src/patch/redirect/pdm.rs | 15 +-- .../src/patch/redirect/pipenv.rs | 88 +++----------- .../src/patch/redirect/poetry.rs | 32 ++--- .../src/patch/redirect/requirements.rs | 4 +- .../socket-patch-core/src/update/download.rs | 13 +- .../socket-patch-core/src/update/release.rs | 39 ++---- .../src/utils/group_commit.rs | 21 +--- crates/socket-patch-core/src/utils/mod.rs | 2 +- .../socket-patch-core/src/utils/pdm_lock.rs | 11 +- .../src/utils/poetry_lock.rs | 107 ++++------------ .../src/utils/python_script.rs | 7 +- .../src/vendor/lock_inventory/mod.rs | 6 +- .../src/vendor/lock_inventory/vlt.rs | 2 +- .../socket-patch-core/src/vendor/npm_dir.rs | 6 +- .../socket-patch-core/src/vendor/prestage.rs | 5 +- .../src/vendor/toml_surgery.rs | 3 +- .../socket-patch-core/src/vex/discover/mod.rs | 5 +- .../tests/covgap_api_blob_fetcher.rs | 5 +- .../tests/covgap_crawlers_composer_crawler.rs | 6 +- .../tests/covgap_vendor_nuget_feed.rs | 4 +- .../socket-patch-core/tests/poetry_hosted.rs | 115 ++++-------------- crates/socket-patch-node/src/lib.rs | 6 +- 76 files changed, 366 insertions(+), 876 deletions(-) diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 686ea443..29a09df7 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -402,9 +402,9 @@ impl GlobalArgs { /// empty). The names are validated at parse time, so this is an exact /// match. pub(crate) fn ecosystem_selected(&self, eco: Ecosystem) -> bool { - self.ecosystems - .as_ref() - .is_none_or(|list| list.is_empty() || list.iter().any(|name| name == eco.cli_name())) + self.ecosystems.as_ref().is_none_or(|list| { + list.is_empty() || list.iter().any(|name| name == eco.cli_name()) + }) } /// [`Self::ecosystem_selected`] for the ecosystem of `purl`; a purl of diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index fb32947d..b1b932eb 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -2,7 +2,9 @@ use clap::Args; use socket_patch_core::api::blob_fetcher::get_missing_blobs; use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient}; use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning; -use socket_patch_core::crawlers::{detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler}; +use socket_patch_core::crawlers::{ + detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler, +}; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{ diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index dc6b0bbe..40369f8f 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -14,7 +14,9 @@ use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; 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}; +use socket_patch_core::manifest::schema::{ + 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}; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched}; @@ -388,6 +390,7 @@ fn files_with_both_hashes(patch: &PatchResponse) -> HashMap i32 { // entry) and global installs (no project lockfile) mean agent mode. // Conflicts use get's exit-1 report_error style (scan's self-enforced // conflicts exit 2 — documented carve-out in CLI_CONTRACT.md). - let mode = args - .mode - .unwrap_or(if args.save_only || args.common.is_global() { - super::scan::ScanMode::Agent - } else { - super::scan::ScanMode::Hosted - }); + let mode = args.mode.unwrap_or(if args.save_only || args.common.is_global() { + super::scan::ScanMode::Agent + } else { + super::scan::ScanMode::Hosted + }); if args.save_only && mode != super::scan::ScanMode::Agent { report_error( args.common.json, @@ -2866,7 +2867,8 @@ pub async fn run(args: GetArgs) -> i32 { } IdentifierType::Package => { status.set("Enumerating packages..."); - let (all_packages, _, _) = crawl_all_ecosystems(&args.common.crawler_options()).await; + let (all_packages, _, _) = + crawl_all_ecosystems(&args.common.crawler_options()).await; if all_packages.is_empty() { status.finish(); @@ -7173,11 +7175,8 @@ mod tests { let installed = |name: &str, body: &[u8]| { let dist = site.path().join(format!("{name}-1.0.0.dist-info")); std::fs::create_dir_all(&dist).unwrap(); - std::fs::write( - dist.join("METADATA"), - format!("Name: {name}\nVersion: 1.0.0\n"), - ) - .unwrap(); + std::fs::write(dist.join("METADATA"), format!("Name: {name}\nVersion: 1.0.0\n")) + .unwrap(); std::fs::write(site.path().join(format!("{name}.py")), body).unwrap(); compute_git_sha256_from_bytes(body) }; @@ -7221,10 +7220,7 @@ mod tests { mount(uuid("bs"), "beta_sdist.py".into(), "0".repeat(64), 0).await; for n in ["gw", "gs"] { Mock::given(method("GET")) - .and(wm_path(format!( - "/v0/orgs/test-org/patches/view/{}", - uuid(n) - ))) + .and(wm_path(format!("/v0/orgs/test-org/patches/view/{}", uuid(n)))) .respond_with(ResponseTemplate::new(500)) .expect(0) .mount(&server) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index f3b8a878..b70af594 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1010,8 +1010,7 @@ mod tests { let empty = socket_patch_core::patch::redirect::RedirectState::new(); let empty_vendor = VendorState::new(); assert!( - merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]) - .is_none() + merge_ledger_records_for_updates(None, Some(&empty), Some(&empty_vendor), &[]).is_none() ); let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]); @@ -1925,11 +1924,7 @@ mod tests { "pkg:npm/lockonly@1.0.0", std::path::PathBuf::from("/nonexistent"), ), - crawled_pkg( - "alpha", - "pkg:npm/alpha@1.0.0", - installed("alpha", "alpha.js"), - ), + crawled_pkg("alpha", "pkg:npm/alpha@1.0.0", installed("alpha", "alpha.js")), crawled_pkg( "embedded", "pkg:npm/embedded@1.0.0", diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 0bc2883c..6ae85979 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -35,8 +35,8 @@ pub(crate) use socket_patch_core::hosted::guidance::{ 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, + pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, + read_workspace_for_trust, TrustPlan, }; #[cfg(test)] pub(crate) use socket_patch_core::hosted::ledger::{rebase_vlt_edits, REBASE_KINDS}; @@ -667,11 +667,7 @@ pub(crate) async fn run_redirect_selected( // (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(), - ); + return refuse(common, scan_result.take(), &engine::bun_lockb_symlink_refusal()); } // vlt artifact preflight: before any takeover or rewrite (dry runs @@ -2146,6 +2142,7 @@ pub(crate) fn boxed_run_redirect_selected<'a>( #[cfg(test)] mod tests { + use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, installed_stale_positive_evidence, @@ -2167,7 +2164,6 @@ mod tests { use super::{rebase_vlt_edits, REBASE_KINDS}; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::constants::npm_family; - use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use socket_patch_core::patch::redirect::{DepOverride, FileEdit}; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; 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 58522c02..4034ddb8 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -6,12 +6,14 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -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::constants::npm_family::{ + VLT_HIDDEN_LOCK_REL, VLT_LOCK, +}; 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::hosted::vlt::{self as hosted_vlt, Preflight}; use socket_patch_core::patch::redirect::vlt_preflight; use socket_patch_core::patch::redirect::{vlt, DepOverride}; use socket_patch_core::vendor::lock_inventory::ProjectView; diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index c981b93a..20ab9896 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -241,7 +241,9 @@ pub fn resolve_mode_flags(args: &mut ScanArgs) -> Result<(), String> { // stays report-only (neither has a project lockfile to rewire). args.mode = Some(ScanMode::Hosted); } - if args.mode == Some(ScanMode::Hosted) && args.common.is_global() { + if args.mode == Some(ScanMode::Hosted) + && args.common.is_global() + { // Global installs have no project lockfile to repoint: the hosted // flow would "redirect 0 packages" and exit 0, a silent no-op. return Err(format!( @@ -355,7 +357,11 @@ pub struct ScanArgs { /// `requests`), or a purl with or without its version /// (`pkg:npm/lodash`, `pkg:pypi/requests@2.31.0`). Repeat the flag or /// separate with commas - #[arg(long = "package", env = "SOCKET_SCAN_PACKAGES", value_delimiter = ',')] + #[arg( + long = "package", + env = "SOCKET_SCAN_PACKAGES", + value_delimiter = ',' + )] pub packages: Vec, /// On a successful scan, also generate an OpenVEX 0.2.0 document. @@ -391,8 +397,7 @@ pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { None => name_version, }; if let Some(spec_rest) = spec.strip_prefix("pkg:") { - let spec_purl = - normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); + let spec_purl = normalize_purl(strip_purl_qualifiers(&format!("pkg:{spec_rest}"))).to_lowercase(); let spec_rest = &spec_purl[4..]; let has_version = spec_rest .split_once('/') @@ -400,9 +405,7 @@ pub(crate) fn package_spec_matches(spec: &str, purl: &str) -> bool { return if has_version { decoded == spec_purl } else { - decoded - .strip_prefix(&spec_purl) - .is_some_and(|tail| tail.starts_with('@')) + decoded.strip_prefix(&spec_purl).is_some_and(|tail| tail.starts_with('@')) }; } let spec = spec.replace(':', "/"); @@ -1527,8 +1530,7 @@ fn project_dirs(cwd: &Path, paths: &[String]) -> Result, String> { let joined = cwd.join(raw); if raw.contains(['*', '?', '[']) { let pattern = joined.to_string_lossy().into_owned(); - let matches = - glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; + let matches = glob::glob(&pattern).map_err(|e| format!("invalid path pattern `{raw}`: {e}"))?; let before = dirs.len(); dirs.extend(matches.filter_map(Result::ok).filter(|p| p.is_dir())); if dirs.len() == before { @@ -1750,11 +1752,8 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { .filter(|pkg| args.common.purl_ecosystem_selected(&pkg.purl)) .collect(); - let package_specs: Vec<&String> = args - .packages - .iter() - .filter(|s| !s.trim().is_empty()) - .collect(); + let package_specs: Vec<&String> = + args.packages.iter().filter(|s| !s.trim().is_empty()).collect(); let filtered_crawled: Vec<_> = if package_specs.is_empty() { filtered_crawled } else { @@ -2173,17 +2172,18 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { }; // The hosted pins the lockfiles wire count too: the lockfile is the // record of a hosted redirect even where no ledger was committed. - let hosted_pins: Vec<(String, String)> = if args.common.is_global() { - Vec::new() - } else { - crate::commands::discover_wiring(&args.common, &args.common.cwd) - .await - .refs - .into_iter() - .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) - .map(|r| (r.purl, r.uuid)) - .collect() - }; + let hosted_pins: Vec<(String, String)> = + if args.common.is_global() { + Vec::new() + } else { + crate::commands::discover_wiring(&args.common, &args.common.cwd) + .await + .refs + .into_iter() + .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) + .map(|r| (r.purl, r.uuid)) + .collect() + }; let update_manifest = merge_ledger_records_for_updates( existing_manifest.as_ref(), redirect_state.as_ref(), @@ -2950,19 +2950,11 @@ mod tests { std::fs::write(tmp.path().join("apps/README"), "").unwrap(); let rel = |dirs: Vec| -> Vec { dirs.iter() - .map(|d| { - d.strip_prefix(tmp.path()) - .unwrap() - .to_string_lossy() - .replace('\\', "/") - }) + .map(|d| d.strip_prefix(tmp.path()).unwrap().to_string_lossy().replace('\\', "/")) .collect() }; - let got = project_dirs( - tmp.path(), - &["apps/*".into(), "libs/core".into(), "apps/web".into()], - ) - .unwrap(); + let got = project_dirs(tmp.path(), &["apps/*".into(), "libs/core".into(), "apps/web".into()]) + .unwrap(); assert_eq!(rel(got), ["apps/api", "apps/web", "libs/core"]); assert!(project_dirs(tmp.path(), &["apps/README".into()]) .unwrap_err() @@ -3280,10 +3272,7 @@ mod tests { }, ] { let picked = selection_args(&common); - assert!( - !picked.json && picked.yes, - "scan always takes the top patch" - ); + assert!(!picked.json && picked.yes, "scan always takes the top patch"); } } diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index d616c5c1..34adcadc 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -726,10 +726,7 @@ mod tests { #[test] fn report_only_hint_names_agent_mode() { - assert_eq!( - report_only_hint()[0], - "To apply these patches in place, run:" - ); + assert_eq!(report_only_hint()[0], "To apply these patches in place, run:"); assert!(report_only_hint()[1].contains("--mode agent")); } diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index bbdf92eb..63106bdc 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -247,7 +247,8 @@ async fn hooked_vlt_members(found: &PackageJsonFindResult) -> Vec { } let mut hooked = Vec::new(); for loc in found.files.iter().filter(|loc| !loc.is_root) { - if let Ok(content) = socket_patch_core::utils::fs::read_regular_to_string(&loc.path).await { + if let Ok(content) = socket_patch_core::utils::fs::read_regular_to_string(&loc.path).await + { let status = is_setup_configured_str(&content); if status.postinstall_configured || status.dependencies_configured { hooked.push(loc.path.clone()); diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs index b736f72f..dc8fe2be 100644 --- a/crates/socket-patch-cli/src/commands/update.rs +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -157,11 +157,7 @@ fn cancelled_message(current: &semver::Version, target: &semver::Version) -> &'s /// The result line after a successful install, naming the same action as /// [`confirm_prompt`]. -fn installed_message( - current: &semver::Version, - target: &semver::Version, - path: &std::path::Path, -) -> String { +fn installed_message(current: &semver::Version, target: &semver::Version, path: &std::path::Path) -> String { let path = path.display(); if target < current { format!("Downgraded socket-patch {current} \u{2192} {target} ({path})") @@ -502,18 +498,9 @@ mod tests { #[test] fn cancel_and_result_lines_match_the_prompt() { - assert_eq!( - cancelled_message(&v("4.0.0"), &v("9.9.9")), - "Update cancelled." - ); - assert_eq!( - cancelled_message(&v("4.0.0"), &v("3.0.0")), - "Downgrade cancelled." - ); - assert_eq!( - cancelled_message(&v("4.0.0"), &v("4.0.0")), - "Reinstall cancelled." - ); + assert_eq!(cancelled_message(&v("4.0.0"), &v("9.9.9")), "Update cancelled."); + assert_eq!(cancelled_message(&v("4.0.0"), &v("3.0.0")), "Downgrade cancelled."); + assert_eq!(cancelled_message(&v("4.0.0"), &v("4.0.0")), "Reinstall cancelled."); let p = std::path::Path::new("/opt/sp/socket-patch"); assert_eq!( installed_message(&v("4.0.0"), &v("9.9.9"), p), diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 3fd77adf..5da61dd9 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -3674,11 +3674,7 @@ mod plan_gate_tests { .unwrap(); let packages = [ ("pkg:composer/psr/cache@1.0.0", "psr/cache", UUID_A), - ( - "pkg:composer/psr/http-message@1.1.0", - "psr/http-message", - UUID_B, - ), + ("pkg:composer/psr/http-message@1.1.0", "psr/http-message", UUID_B), ("pkg:composer/psr/log@3.0.2", "psr/log", UUID_C), ]; let mut all_packages: Vec<(String, StagedSource)> = Vec::new(); diff --git a/crates/socket-patch-cli/tests/apply_network.rs b/crates/socket-patch-cli/tests/apply_network.rs index 8fe7e427..2c01ebb8 100644 --- a/crates/socket-patch-cli/tests/apply_network.rs +++ b/crates/socket-patch-cli/tests/apply_network.rs @@ -1069,7 +1069,10 @@ async fn mismatch_blob_topup_probes_every_copy_of_a_duplicated_package() { v["summary"]["applied"], 1, "the drifted nested copy must be warn-overwritten.\nstdout={v:#}" ); - assert_eq!(v["summary"]["failed"], 0, "no copy may fail.\nstdout={v:#}"); + assert_eq!( + v["summary"]["failed"], 0, + "no copy may fail.\nstdout={v:#}" + ); // The nested copy's blob was fetched on demand… let requests = mock.received_requests().await.unwrap(); diff --git a/crates/socket-patch-cli/tests/cli_config_fallback.rs b/crates/socket-patch-cli/tests/cli_config_fallback.rs index 942fdc2f..a919ab99 100644 --- a/crates/socket-patch-cli/tests/cli_config_fallback.rs +++ b/crates/socket-patch-cli/tests/cli_config_fallback.rs @@ -59,7 +59,8 @@ fn scan_cmd(project: &Path, data_dir: &Path) -> Command { let mut cmd = Command::new(BINARY); // Human mode: core's proxy advisory (the oracle below) is muted under // `--json`/`--silent`. - cmd.args(["scan", "-e", "npm", "--cwd"]).arg(project); + cmd.args(["scan", "-e", "npm", "--cwd"]) + .arg(project); for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); if name.starts_with("SOCKET_") { @@ -297,9 +298,7 @@ async fn corrupt_config_warns_and_keeps_json_stdout_clean() { json_cmd.arg("--json"); let json_out = run(json_cmd); assert!( - json_out - .stderr - .contains("could not parse socket-cli config"), + json_out.stderr.contains("could not parse socket-cli config"), "the parse warning must reach stderr under --json too; got:\n{}", json_out.stderr ); diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index 18cf1e07..5e5dd991 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -1196,10 +1196,7 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina assert_eq!(v["error"]["code"], "manifest_not_found", "envelope={v}"); let warnings = v["warnings"].as_array().expect("warnings[] present"); assert_eq!(warnings.len(), 1, "envelope={v}"); - assert_eq!( - warnings[0]["code"], "redirect_ledger_corrupt", - "envelope={v}" - ); + assert_eq!(warnings[0]["code"], "redirect_ledger_corrupt", "envelope={v}"); assert!( out.stderr.is_empty(), "--json must keep stderr clean: {}", @@ -1211,10 +1208,7 @@ fn missing_manifest_with_corrupt_ledger_keeps_warning_in_error_envelope_via_bina let stderr = String::from_utf8_lossy(&out.stderr); assert_eq!(out.status.code(), Some(1)); assert!(stderr.contains("Warning: "), "stderr={stderr}"); - assert!( - stderr.contains("Error: Manifest not found at "), - "stderr={stderr}" - ); + assert!(stderr.contains("Error: Manifest not found at "), "stderr={stderr}"); } #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_rollback.rs b/crates/socket-patch-cli/tests/cli_parse_rollback.rs index 8691238c..79787517 100644 --- a/crates/socket-patch-cli/tests/cli_parse_rollback.rs +++ b/crates/socket-patch-cli/tests/cli_parse_rollback.rs @@ -378,11 +378,7 @@ fn bare_bool_does_not_consume_next_token() { /// relied on the rejection get a test-visible flip instead of a silent one. #[test] fn multiple_targets_parse_in_order() { - let args = parse_rollback(&[ - "pkg:npm/foo@1", - "packages/api/**", - "b0630680-4da6-45f9-bba8-b888e0ffd58c", - ]); + let args = parse_rollback(&["pkg:npm/foo@1", "packages/api/**", "b0630680-4da6-45f9-bba8-b888e0ffd58c"]); assert_eq!( args.targets, vec![ diff --git a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs index 049d8356..444dd2a3 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_apply_silent_mute_exit.rs @@ -149,9 +149,7 @@ fn apply_silent_online_download_failure_keeps_error_output() { ); let chatter = stderr_chatter(&stderr); assert!( - chatter - .iter() - .any(|l| l.contains("could not be downloaded")), + chatter.iter().any(|l| l.contains("could not be downloaded")), "--silent must keep the download-failure error (errors only, \ never nothing); stderr was: {stderr:?}" ); diff --git a/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs index d68e382d..e403c3d6 100644 --- a/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs +++ b/crates/socket-patch-cli/tests/coverage_fix_repair_vendor_predelete.rs @@ -234,9 +234,12 @@ async fn repair_keeps_healthy_soft_artifact_when_rebuild_dispatch_fails() { let gemfile_wired = std::fs::read(tmp.path().join("Gemfile")).unwrap(); std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); - std::fs::remove_file(tmp.path().join(format!( - "vendor/bundle/ruby/3.4.0/gems/{GEM_NAME}-{GEM_VERSION}/lib/padlock.rb" - ))) + std::fs::remove_file( + tmp.path() + .join(format!( + "vendor/bundle/ruby/3.4.0/gems/{GEM_NAME}-{GEM_VERSION}/lib/padlock.rb" + )), + ) .unwrap(); mount_blob(&mock).await; @@ -277,10 +280,7 @@ async fn repair_keeps_healthy_soft_artifact_when_rebuild_dispatch_fails() { &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), ) .unwrap(); - assert_eq!( - state["entries"][GEM_PURL]["uuid"], GEM_UUID, - "state={state}" - ); + assert_eq!(state["entries"][GEM_PURL]["uuid"], GEM_UUID, "state={state}"); assert_eq!( std::fs::read(tmp.path().join("Gemfile")).unwrap(), gemfile_wired, diff --git a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs index 44e82b5f..e34a34b1 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_scan_mod.rs @@ -1503,11 +1503,7 @@ async fn scan_hosted_paths_run_once_per_project_directory() { let header = format!("== {} ==", std::path::Path::new(app).display()); assert!(stdout.contains(&header), "missing {header:?}: {stdout}"); } - assert_eq!( - stdout.matches("Redirected 0 packages").count(), - 2, - "{stdout}" - ); + assert_eq!(stdout.matches("Redirected 0 packages").count(), 2, "{stdout}"); let reqs = recorded(&mock).await; assert_eq!(batch_bodies(&reqs).len(), 2, "one discovery per directory"); } @@ -1946,6 +1942,7 @@ mod pty { screen.join("\n") ); } + } // --------------------------------------------------------------------------- diff --git a/crates/socket-patch-cli/tests/covgap_commands_update.rs b/crates/socket-patch-cli/tests/covgap_commands_update.rs index ccc7306b..82aa57f2 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_update.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_update.rs @@ -9,10 +9,10 @@ //! itself must never be a swap target. Fixture shapes are copied from //! self_update_e2e.rs / interactive_prompts_e2e.rs. -#[path = "common/mod.rs"] -mod common; #[path = "common/pty_io.rs"] mod pty_io; +#[path = "common/mod.rs"] +mod common; #[path = "common/update_fixture.rs"] mod update_fixture; @@ -272,8 +272,9 @@ mod pty { let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); drop(pair.slave); - let reader_handle = - crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); let mut killer = child.clone_killer(); std::thread::spawn(move || { @@ -341,8 +342,7 @@ mod pty { "a declined update exits 1 (codebase convention); got: {output}" ); assert!( - !output.contains("Updated socket-patch") - && !output.contains("Reinstalled socket-patch"), + !output.contains("Updated socket-patch") && !output.contains("Reinstalled socket-patch"), "a declined update must not report a swap; got: {output}" ); diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index f6549833..a1bed29c 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -542,24 +542,14 @@ fn auto_detect_multi_manifest_warning_reaches_json_envelope() { ]) .output() .expect("invoke vex"); - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); let w = env["warnings"] .as_array() - .and_then(|ws| { - ws.iter() - .find(|w| w["code"] == "product_multiple_manifests") - }) + .and_then(|ws| ws.iter().find(|w| w["code"] == "product_multiple_manifests")) .unwrap_or_else(|| panic!("product_multiple_manifests warning expected: {env}")); assert!( - w["detail"] - .as_str() - .unwrap() - .contains("Multiple project manifests"), + w["detail"].as_str().unwrap().contains("Multiple project manifests"), "{w}" ); let stderr = String::from_utf8_lossy(&out.stderr); diff --git a/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs b/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs index 87d4900a..5fee90f8 100644 --- a/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/tests/covgap_ecosystem_dispatch.rs @@ -253,10 +253,7 @@ fn rollback_dispatch_branch_deno() { .unwrap_or_else(|e| panic!("rollback envelope must parse ({e}); stdout={stdout}")); let code = out.status.code().unwrap_or(-1); - assert_eq!( - code, 0, - "rollback --ecosystems=deno: expected exit 0; env={env}" - ); + assert_eq!(code, 0, "rollback --ecosystems=deno: expected exit 0; env={env}"); assert_eq!( env["status"], "success", "rollback --ecosystems=deno: expected success; env={env}" @@ -297,8 +294,7 @@ fn rollback_dispatch_branch_deno() { // The decisive check: the on-disk bytes are restored to ORIGINAL. let restored = std::fs::read(&verify_file).unwrap(); assert_eq!( - restored, - ORIGINAL, + restored, ORIGINAL, "rollback --ecosystems=deno: {} was not restored to its original bytes", verify_file.display() ); diff --git a/crates/socket-patch-cli/tests/covgap_output.rs b/crates/socket-patch-cli/tests/covgap_output.rs index 48864f7e..eb964cc2 100644 --- a/crates/socket-patch-cli/tests/covgap_output.rs +++ b/crates/socket-patch-cli/tests/covgap_output.rs @@ -172,8 +172,9 @@ fn run_in_pty_inner( .expect("spawn socket-patch in PTY"); drop(pair.slave); - let reader_handle = - crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); // Watchdog: detached kill after `timeout`; a no-op if the child exits // naturally first. @@ -264,10 +265,7 @@ fn remove_interactive_bare_enter_proceeds_with_default_yes() { "\n", Duration::from_secs(15), ); - assert_eq!( - code, 0, - "remove with bare Enter must succeed; got: {output}" - ); + assert_eq!(code, 0, "remove with bare Enter must succeed; got: {output}"); // The interactive confirm MUST have run — otherwise this test passes // vacuously against a regression that drops the TTY gate and // auto-proceeds. Match the distinctive prompt verbatim (the loose diff --git a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs index a2cf92ea..5b056b87 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_composer_mod.rs @@ -126,10 +126,7 @@ fn remove_malformed_composer_json_errors_not_silent_noop() { write(&cwd.join("composer.json"), MALFORMED_COMPOSER_JSON); let (code, v) = run_json(cwd, &["setup", "--remove", "--yes", "--json"]); - assert_eq!( - code, 1, - "remove on a malformed composer.json must fail: {v}" - ); + assert_eq!(code, 1, "remove on a malformed composer.json must fail: {v}"); assert_eq!(v["status"], "error", "{v}"); assert_eq!(v["removed"], 0, "{v}"); assert_eq!(v["errors"], 1, "{v}"); diff --git a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs b/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs index c4d64911..44eefd78 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_gem_mod.rs @@ -71,10 +71,7 @@ fn setup_remove_clears_bundler_registration_under_bundle_app_config() { &["setup", "--yes", "--json", "--ecosystems", "gem"], &[], ); - assert_eq!( - code, 0, - "gem setup must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); + assert_eq!(code, 0, "gem setup must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); let v = common::parse_json_envelope(&stdout); assert_eq!(v["status"], "success", "{v}"); assert!( @@ -113,14 +110,7 @@ fn setup_remove_clears_bundler_registration_under_bundle_app_config() { // Step 3: unwire with BUNDLE_APP_CONFIG set (child-only env injection). let (code, stdout, stderr) = common::run_with_env( root, - &[ - "setup", - "--remove", - "--yes", - "--json", - "--ecosystems", - "gem", - ], + &["setup", "--remove", "--yes", "--json", "--ecosystems", "gem"], &[("BUNDLE_APP_CONFIG", "bundle-config")], ); assert_eq!( diff --git a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs b/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs index 6adf98e7..814a55ef 100644 --- a/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs +++ b/crates/socket-patch-cli/tests/covgap_setup_pypi_detect.rs @@ -45,10 +45,7 @@ fn read(path: &Path) -> String { fn write_pm_shim(bin_dir: &Path, name: &str, log: &Path) { use std::os::unix::fs::PermissionsExt; std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let body = format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", - log.display() - ); + let body = format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexit 0\n", log.display()); let p = bin_dir.join(name); std::fs::write(&p, body).expect("write shim"); std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).expect("chmod shim"); @@ -83,7 +80,11 @@ fn assert_no_pm_spawned(project: &Path, context: &str) { /// through the shared hermetic runner (the seed-then-scrub of the ambient /// `SOCKET_*` surface is load-bearing: SOCKET_DRY_RUN=true would fake every /// edit, SOCKET_ECOSYSTEMS=npm would hide the Python branch entirely). -fn run_setup_with_shims(cwd: &Path, bin_dir: &Path, extra: &[&str]) -> (i32, serde_json::Value) { +fn run_setup_with_shims( + cwd: &Path, + bin_dir: &Path, + extra: &[&str], +) -> (i32, serde_json::Value) { let path_env = format!( "{}:{}", bin_dir.display(), diff --git a/crates/socket-patch-cli/tests/e2e_cargo.rs b/crates/socket-patch-cli/tests/e2e_cargo.rs index b0429631..65581526 100644 --- a/crates/socket-patch-cli/tests/e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_cargo.rs @@ -205,7 +205,8 @@ async fn scan_discovers_fake_registry_crates() { "Expected human scan to report exactly 'Found 2 packages (2 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") && !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported no packages despite a populated registry:\n{combined}" ); @@ -262,7 +263,8 @@ async fn scan_discovers_vendor_crates() { "Expected human scan to report exactly 'Found 1 package (1 cargo)', got:\n{combined}" ); assert!( - !combined.contains("No packages found") && !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported no packages despite a populated vendor dir:\n{combined}" ); diff --git a/crates/socket-patch-cli/tests/e2e_maven.rs b/crates/socket-patch-cli/tests/e2e_maven.rs index 4de7aef4..63f53e27 100644 --- a/crates/socket-patch-cli/tests/e2e_maven.rs +++ b/crates/socket-patch-cli/tests/e2e_maven.rs @@ -179,7 +179,8 @@ async fn scan_discovers_maven_artifacts() { // Must NOT have hit the empty-crawl path — that line *also* contains // the word "packages". assert!( - !combined.contains("No packages found") && !combined.contains("No packages found"), + !combined.contains("No packages found") + && !combined.contains("No packages found"), "scan reported zero packages — Maven discovery did not run:\n{combined}" ); assert!( diff --git a/crates/socket-patch-cli/tests/e2e_nuget.rs b/crates/socket-patch-cli/tests/e2e_nuget.rs index f3636222..93fc84b7 100644 --- a/crates/socket-patch-cli/tests/e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_nuget.rs @@ -229,8 +229,7 @@ async fn scan_discovers_global_cache_packages() { // "packages" substring check would also match). assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") - && !combined.contains("No global packages found"), + && !combined.contains("No packages found") && !combined.contains("No global packages found"), "scan failed to discover the fake global cache:\n{combined}" ); // Exactly the two packages we planted (Newtonsoft.Json, System.Text.Json), @@ -289,8 +288,7 @@ async fn scan_discovers_legacy_packages() { ); assert!( !combined.contains("No packages found") - && !combined.contains("No packages found") - && !combined.contains("No global packages found"), + && !combined.contains("No packages found") && !combined.contains("No global packages found"), "scan failed to discover the legacy packages/ layout:\n{combined}" ); // Exactly the single legacy package we planted (Newtonsoft.Json.13.0.3), diff --git a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs index d067baec..86b754a2 100644 --- a/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs +++ b/crates/socket-patch-cli/tests/get_edge_cases_e2e.rs @@ -507,16 +507,8 @@ fn get_help_lists_all_identifier_flags() { // parseable (scripts get that explicit error) but is not advertised. assert!(!stdout.contains("--one-off"), "{stdout}"); // Help text is for users: no implementation notes from the source. - for leak in [ - "value_parser", - "parse_bool_flag", - "No env binding", - "locally- installed", - ] { - assert!( - !stdout.contains(leak), - "get --help leaks {leak:?}: {stdout}" - ); + for leak in ["value_parser", "parse_bool_flag", "No env binding", "locally- installed"] { + assert!(!stdout.contains(leak), "get --help leaks {leak:?}: {stdout}"); } } diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index 68bad2eb..61d0f7d3 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -212,10 +212,7 @@ fn assert_rollback_noop(stdout: &str) { r["skipped"], "package_not_installed", "a no-op rollback may carry only not-installed markers; envelope={v}" ); - assert!( - r["path"].is_null(), - "marker path must be null; envelope={v}" - ); + assert!(r["path"].is_null(), "marker path must be null; envelope={v}"); assert!( r.get("success").is_none() && r.get("error").is_none(), "markers carry no success/error keys; envelope={v}" diff --git a/crates/socket-patch-cli/tests/help_text_hygiene.rs b/crates/socket-patch-cli/tests/help_text_hygiene.rs index 84e6e63c..9b2be79a 100644 --- a/crates/socket-patch-cli/tests/help_text_hygiene.rs +++ b/crates/socket-patch-cli/tests/help_text_hygiene.rs @@ -61,11 +61,7 @@ fn every_help_page_has_no_developer_notes() { names.extend(cmd.get_subcommands().map(|s| s.get_name().to_string())); let mut failures = Vec::new(); for name in &names { - let path: Vec<&str> = if name.is_empty() { - vec![] - } else { - vec![name.as_str()] - }; + let path: Vec<&str> = if name.is_empty() { vec![] } else { vec![name.as_str()] }; let text = long_help(&path); let found = leaks(&text); if !found.is_empty() { @@ -151,9 +147,7 @@ fn vex_product_list_renders_one_item_per_line() { fn root_command_list_uses_the_verb_form() { let text = long_help(&[]); assert!( - text.contains( - "Undo patches: restore original files and unwind hosted or vendored lockfile wiring" - ), + text.contains("Undo patches: restore original files and unwind hosted or vendored lockfile wiring"), "{text}" ); assert!(!text.contains("Rollback patches"), "{text}"); diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index a6e5e43b..8b57dfc1 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -963,8 +963,7 @@ async fn a_vlt_project_is_withheld_as_offline() { .and_then(|w| w["detail"].as_str()) .expect("the preflight warning is reported"); assert!( - detail.contains("/patch/npm//") - && detail.contains(": offline; nothing was written"), + detail.contains("/patch/npm//") && detail.contains(": offline; nothing was written"), "the offline refusal quotes the redacted URL" ); assert!(output.changed_files.is_empty()); diff --git a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs index 5da58877..373455be 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect/vlt.rs @@ -335,15 +335,11 @@ async fn scan_redirect_vlt_artifact_fetch_error() { let detail = warning_detail(&doc, UNVERIFIABLE); let redacted = url.replace(&format!("/{TOKEN}/"), "//"); assert!( - detail.starts_with(&format!( - "vlt would fail to verify {redacted}: fetch error " - )) && detail.ends_with(&format!("; nothing was written for {PURL}")), + detail.starts_with(&format!("vlt would fail to verify {redacted}: fetch error ")) + && detail.ends_with(&format!("; nothing was written for {PURL}")), "the fetch-error refusal quotes the redacted URL" ); - assert!( - !detail.contains(TOKEN), - "the grant token never reaches the warning" - ); + assert!(!detail.contains(TOKEN), "the grant token never reaches the warning"); } async fn redirect_chain(hops: usize) -> (Value, tempfile::TempDir) { diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index 94b96550..da069fcb 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -110,9 +110,7 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!( - "^/v0/orgs/{ORG}/patches/by-package/.+$" - ))) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -340,10 +338,8 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { PYPROJECT, "pyproject untouched" ); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); + let ledger: serde_json::Value = + serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "ledger keyed by the artifact-qualified purl: {ledger}" @@ -368,11 +364,7 @@ async fn lock_only_pdm_project_redirects_attests_rescans_and_rolls_back() { // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!( - read(&lock_path), - redirected, - "re-scan must not touch the lock" - ); + assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); // 3. The committed state, manifest-less, attests (and only while wired). assert_manifestless_vex(tmp.path(), LOCK); @@ -431,10 +423,8 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { pyproject, "pyproject untouched" ); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); + let ledger: serde_json::Value = + serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "the pdm redirect must be confirmed and recorded despite the hatch backend: {ledger}" @@ -454,11 +444,7 @@ async fn hatchling_build_backend_does_not_veto_the_pdm_lock_redirect() { }) .await; assert_eq!(code, 0, "rollback must succeed"); - assert_eq!( - read(&lock_path), - LOCK, - "rollback must restore the pristine lock" - ); + assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock"); } /// The legacy `[metadata.files]` lock (lock_version 2) redirects the package @@ -476,10 +462,8 @@ async fn legacy_metadata_files_lock_redirects_both_fragments_and_warns() { assert_eq!(code, 0); let redirected = read(&lock_path); assert!(redirected.contains(HOSTED_URL), "{redirected}"); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); + let ledger: serde_json::Value = + serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))).unwrap(); assert_eq!( ledger["edits"].as_array().unwrap().len(), 2, diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs index f25ea3e1..43d789de 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs @@ -54,8 +54,7 @@ const MAJOR_ENV: &str = socket_patch_core::utils::pipenv::MAJOR_OVERRIDE_ENV; const LOCK: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile.lock"); -const PIPFILE: &str = - include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); +const PIPFILE: &str = include_str!("../../socket-patch-core/tests/fixtures/pipenv/2026.8.0/Pipfile"); /// The upstream and patched bytes of the record's one file, so the venv /// tests can materialize a real `Ready` (upstream) install. @@ -120,9 +119,7 @@ async fn mock_api(server: &MockServer) { .mount(server) .await; Mock::given(method("GET")) - .and(path_regex(format!( - "^/v0/orgs/{ORG}/patches/by-package/.+$" - ))) + .and(path_regex(format!("^/v0/orgs/{ORG}/patches/by-package/.+$"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "patches": [{ "uuid": UUID, "purl": RECORD_PURL, @@ -312,10 +309,7 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { serde_json::json!([format!("sha256:{}", sha256())]), "{redirected}" ); - assert!( - entry.get("version").is_none() && entry.get("index").is_none(), - "{entry}" - ); + assert!(entry.get("version").is_none() && entry.get("index").is_none(), "{entry}"); assert_eq!( entry["markers"], urllib3_entry(LOCK)["markers"], @@ -323,19 +317,11 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { ); let before: serde_json::Value = serde_json::from_str(LOCK).unwrap(); let after: serde_json::Value = serde_json::from_str(&redirected).unwrap(); - assert_eq!( - after["_meta"], before["_meta"], - "the Pipfile content hash stays" - ); - assert_eq!( - read(&tmp.path().join("Pipfile")), - PIPFILE, - "Pipfile untouched" - ); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); + assert_eq!(after["_meta"], before["_meta"], "the Pipfile content hash stays"); + assert_eq!(read(&tmp.path().join("Pipfile")), PIPFILE, "Pipfile untouched"); + let ledger: serde_json::Value = + serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) + .unwrap(); assert!( ledger["records"][RECORD_PURL].is_object(), "ledger keyed by the artifact-qualified purl: {ledger}" @@ -355,34 +341,17 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { let vex: serde_json::Value = serde_json::from_str(&read(&vex_path)).unwrap(); let statements = vex["statements"].as_array().expect("statements"); assert_eq!(statements.len(), 1, "{vex}"); - assert_eq!( - statements[0]["vulnerability"]["name"].as_str(), - Some(GHSA), - "{vex}" - ); - assert_eq!( - statements[0]["status"].as_str(), - Some("not_affected"), - "{vex}" - ); + assert_eq!(statements[0]["vulnerability"]["name"].as_str(), Some(GHSA), "{vex}"); + assert_eq!(statements[0]["status"].as_str(), Some("not_affected"), "{vex}"); // 2. Idempotent re-scan: no further edits, lock byte-identical. let code = run(hosted_args(tmp.path(), server.uri(), None)).await; assert_eq!(code, 0); - assert_eq!( - read(&lock_path), - redirected, - "re-scan must not touch the lock" - ); - let ledger: serde_json::Value = serde_json::from_str(&read( - &tmp.path().join(".socket/vendor/redirect-state.json"), - )) - .unwrap(); - assert_eq!( - ledger["edits"].as_array().map(Vec::len), - Some(1), - "one edit, not two" - ); + assert_eq!(read(&lock_path), redirected, "re-scan must not touch the lock"); + let ledger: serde_json::Value = + serde_json::from_str(&read(&tmp.path().join(".socket/vendor/redirect-state.json"))) + .unwrap(); + assert_eq!(ledger["edits"].as_array().map(Vec::len), Some(1), "one edit, not two"); // Manifest-less VEX over the committed state (the depscan / CI shape). manifestless_vex(tmp.path(), "pipenv lock-only", &|p: &Path| { @@ -391,11 +360,7 @@ async fn lock_only_pipenv_project_redirects_attests_rescans_and_rolls_back() { // 3. rollback unwinds the redirect and drops the record. roll_back(tmp.path(), server.uri()).await; - assert_eq!( - read(&lock_path), - LOCK, - "rollback must restore the pristine lock byte for byte" - ); + assert_eq!(read(&lock_path), LOCK, "rollback must restore the pristine lock byte for byte"); let ledger_path = tmp.path().join(".socket/vendor/redirect-state.json"); if ledger_path.exists() { let ledger: serde_json::Value = serde_json::from_str(&read(&ledger_path)).unwrap(); @@ -428,10 +393,7 @@ async fn legacy_installer_major_selects_path_references() { "Pipenv 7–11 install `path` references: {redirected}" ); assert!(entry.get("file").is_none(), "{entry}"); - assert_eq!( - entry["hashes"], - serde_json::json!([format!("sha256:{}", sha256())]) - ); + assert_eq!(entry["hashes"], serde_json::json!([format!("sha256:{}", sha256())])); // The legacy `path` reference is discovered just like `file`. manifestless_vex(tmp.path(), "pipenv legacy path", &|p: &Path| { @@ -452,9 +414,7 @@ async fn stale_pipfile_lock_does_not_veto_the_requirements_redirect() { write_project(tmp.path()); // The Pipfile.lock left behind pins a DIFFERENT package; the project // installs from requirements.txt. - let stale = LOCK - .replace("\"urllib3\"", "\"six\"") - .replace("==1.26.18", "==1.16.0"); + let stale = LOCK.replace("\"urllib3\"", "\"six\"").replace("==1.26.18", "==1.16.0"); std::fs::write(tmp.path().join("Pipfile.lock"), &stale).unwrap(); std::fs::write(tmp.path().join("requirements.txt"), "urllib3==1.26.18\n").unwrap(); @@ -502,27 +462,16 @@ async fn warm_venv_with_the_upstream_release_is_not_attested() { // attested and the embedded-VEX contract fails the command. let code = run(hosted_args(tmp.path(), server.uri(), Some(&vex_path))).await; let redirected = read(&lock_path); - assert!( - redirected.contains(HOSTED_URL), - "the lock is still repointed: {redirected}" - ); + assert!(redirected.contains(HOSTED_URL), "the lock is still repointed: {redirected}"); let attested = vex_path .exists() .then(|| serde_json::from_str::(&read(&vex_path)).unwrap()) .and_then(|v| v["statements"].as_array().map(Vec::len)) .unwrap_or(0); - assert_eq!( - attested, 0, - "a stale install must not be attested from the ledger" - ); + assert_eq!(attested, 0, "a stale install must not be attested from the ledger"); assert_ne!(code, 0, "nothing to attest fails the embedded-VEX run"); assert_eq!( - std::fs::read( - site_packages(tmp.path()) - .join("urllib3") - .join("response.py") - ) - .unwrap(), + std::fs::read(site_packages(tmp.path()).join("urllib3").join("response.py")).unwrap(), UPSTREAM, "the probe is read-only" ); diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs index ba5deecc..7075ba26 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted/vlt.rs @@ -509,10 +509,8 @@ async fn vlt_heal_follows_the_vlt_group_when_another_group_refuses() { let (_, doc) = scan_hosted(root, &server, &["--no-npm-allow-remote-config"], &[]); assert_eq!(redirected(&doc), 1, "the scan redirects both locks"); vlt_install_patched(root, &server); - let drifted = read(root, "package-lock.json").replace( - &artifact_url(&server), - "https://example.invalid/left-pad-1.3.0.tgz", - ); + let drifted = read(root, "package-lock.json") + .replace(&artifact_url(&server), "https://example.invalid/left-pad-1.3.0.tgz"); std::fs::write(root.join("package-lock.json"), &drifted).unwrap(); let cwd = root.to_str().unwrap().to_string(); @@ -528,18 +526,10 @@ async fn vlt_heal_follows_the_vlt_group_when_another_group_refuses() { vlt_lock(Era::V1, &[registry_node(TILDE_ID)]), "the vlt group restored the registry pins" ); - assert_eq!( - read(root, "package-lock.json"), - drifted, - "the refused group wrote nothing" - ); + assert_eq!(read(root, "package-lock.json"), drifted, "the refused group wrote nothing"); assert!( !store_dir(root, TILDE_ID).exists(), "the patched store copy is removed for the restored pins" ); - assert_eq!( - advisory_details(&doc), - [RESTORED], - "the heal advisory is reported" - ); + assert_eq!(advisory_details(&doc), [RESTORED], "the heal advisory is reported"); } diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index c50acf1d..ce1fae11 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -115,8 +115,9 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // closed. The previous design used a chunked read+mpsc loop // because it interleaved with a try_wait poll; the simplified // design serializes wait → drop master → read_to_end joins. - let reader_handle = - crate::pty_io::PtyOutput::spawn(pair.master.try_clone_reader().expect("clone reader")); + let reader_handle = crate::pty_io::PtyOutput::spawn( + pair.master.try_clone_reader().expect("clone reader"), + ); // Watchdog: detach a thread that kills the child after `timeout`. // The cloned ChildKiller is independent of the main `child` diff --git a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs index 31f45750..d4830cbd 100644 --- a/crates/socket-patch-cli/tests/rollback_duality_invariants.rs +++ b/crates/socket-patch-cli/tests/rollback_duality_invariants.rs @@ -533,7 +533,8 @@ fn bare_word_target_stays_identifier_error() { )], false, ); - let manifest_before = std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); + let manifest_before = + std::fs::read(socket.join("manifest.json")).expect("read manifest bytes"); let (code, stdout, stderr) = run(tmp.path(), &["--offline", "lodash"]); assert_eq!( diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index f3e5362b..d0b61f89 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -1817,8 +1817,7 @@ async fn report_only_scan_json_redirect_state_splits_records_from_live_proof() { ) .unwrap(); - let (code, stdout, stderr) = - run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; @@ -2028,8 +2027,7 @@ async fn silent_gates_scan_malformed_ledger_warning() { std::fs::write(vendor_dir.join("redirect-state.json"), "{ torn ledger").unwrap(); // Control: without --silent the corruption is surfaced on stderr. - let (code, stdout, stderr) = - run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( stderr.contains("malformed"), @@ -2042,11 +2040,7 @@ async fn silent_gates_scan_malformed_ledger_warning() { ); // --silent mutes the advisory warning; the run is otherwise identical. - let (code, stdout, stderr) = run_scan( - tmp.path(), - &mock.uri(), - &["--mode", "agent", "--dry-run", "--silent"], - ); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--silent"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); assert!( !stderr.contains("malformed"), @@ -2078,11 +2072,7 @@ async fn ecosystems_filter_keeps_records_but_not_wiring_live() { /*with_record=*/ true, ); - let (code, stdout, stderr) = run_scan( - tmp.path(), - &mock.uri(), - &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"], - ); + let (code, stdout, stderr) = run_scan(tmp.path(), &mock.uri(), &["--mode", "agent", "--dry-run", "--ecosystems", "pypi"]); assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); let state = &v["redirectState"]; diff --git a/crates/socket-patch-cli/tests/scan_paths_e2e.rs b/crates/socket-patch-cli/tests/scan_paths_e2e.rs index bc301b90..159b7394 100644 --- a/crates/socket-patch-cli/tests/scan_paths_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_paths_e2e.rs @@ -215,11 +215,7 @@ async fn paths_scope_narrows_the_query() { let tmp = tempfile::tempdir().unwrap(); write_two_subtree_project(tmp.path()); - let (code, stdout, stderr) = run_scan( - tmp.path(), - &server.uri(), - &["packages/app", "--mode", "agent", "--dry-run"], - ); + let (code, stdout, stderr) = run_scan(tmp.path(), &server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" @@ -480,11 +476,7 @@ async fn supplements_excluded_with_warning() { // purl reaches the API. let scoped_server = MockServer::start().await; mock_batch_empty(&scoped_server).await; - let (code, stdout, stderr) = run_scan( - tmp.path(), - &scoped_server.uri(), - &["packages/app", "--mode", "agent", "--dry-run"], - ); + let (code, stdout, stderr) = run_scan(tmp.path(), &scoped_server.uri(), &["packages/app", "--mode", "agent", "--dry-run"]); assert_eq!( code, 0, "scoped scan must exit 0; stdout={stdout}; stderr={stderr}" diff --git a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs index 65f4cb32..2926892d 100644 --- a/crates/socket-patch-cli/tests/scan_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/scan_vendor_e2e.rs @@ -3075,11 +3075,7 @@ snapshots: "{v}" ); assert!(events_for(&v, CARGO_SCOPE[0].0).is_empty(), "{v}"); - assert_eq!( - record_for(dl, CARGO_SCOPE[1].0)["action"], - "downloaded", - "{v}" - ); + assert_eq!(record_for(dl, CARGO_SCOPE[1].0)["action"], "downloaded", "{v}"); assert_eq!( events_for(&v, CARGO_SCOPE[1].0), vec![("skipped", "package_not_installed")], diff --git a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs index 04310864..a13fb56f 100644 --- a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs +++ b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs @@ -55,10 +55,7 @@ async fn npm_project_local_refuses_with_local_hint() { "a project install must get the in-project upgrade command: {stderr}" ); assert!(!stderr.contains("npm update -g"), "{stderr}"); - assert!( - stderr.starts_with("Error: This socket-patch binary ("), - "{stderr}" - ); + assert!(stderr.starts_with("Error: This socket-patch binary ("), "{stderr}"); } /// An npm-bundled binary (any `node_modules` component) refuses with the diff --git a/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs b/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs index b07e4327..4c581e70 100644 --- a/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor_rerun_no_network_e2e.rs @@ -1173,7 +1173,8 @@ fn a_package_absent_from_the_lock_keeps_the_not_installed_skip() { b"after\n", ); let home = cargo_home.to_string_lossy().into_owned(); - let (_code, v, stderr) = run_vendor(&root, &dead, &[], &[("CARGO_HOME", home.as_str())]); + let (_code, v, stderr) = + run_vendor(&root, &dead, &[], &[("CARGO_HOME", home.as_str())]); assert_eq!( purl_events(&v, purl), vec![("skipped", "package_not_installed")], @@ -1220,10 +1221,7 @@ fn a_stale_prestage_tree_is_swept_by_the_next_wet_run_only() { let (_code, v, stderr) = run_vendor(root, &dead, &["--dry-run"], &[]); for dir in &litter { - assert!( - root.join(dir).exists(), - "a dry run deletes nothing: {dir}\n{v:#}\n{stderr}" - ); + assert!(root.join(dir).exists(), "a dry run deletes nothing: {dir}\n{v:#}\n{stderr}"); } assert!( !v.to_string().contains("socket-prestage"), @@ -1233,10 +1231,7 @@ fn a_stale_prestage_tree_is_swept_by_the_next_wet_run_only() { for extra in [&["--offline"][..], &[][..]] { let (_code, v, stderr) = run_vendor(root, &dead, extra, &[]); for dir in &litter { - assert!( - !root.join(dir).exists(), - "{extra:?} sweeps {dir}\n{v:#}\n{stderr}" - ); + assert!(!root.join(dir).exists(), "{extra:?} sweeps {dir}\n{v:#}\n{stderr}"); } assert!( !root.join(format!(".socket/vendor/composer/{OLD}")).exists(), diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 80472361..395f790d 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -364,7 +364,12 @@ mod tests { "2020-01-01T00:00:00Z", &["critical", "high"] ), - search_multi("z_new_low", "free", "2026-08-01T00:00:00Z", &["low", "low"]), + search_multi( + "z_new_low", + "free", + "2026-08-01T00:00:00Z", + &["low", "low"] + ), ]), "z_new_low" ); diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler.rs b/crates/socket-patch-core/src/crawlers/npm_crawler.rs index 54816f3c..348c6377 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler.rs @@ -961,11 +961,7 @@ impl NpmCrawler { /// Inside a store entry (`store_entry`) a link is a dependency edge into /// a sibling entry, whose own visit records that copy, so only a real /// directory there matches. - fn visit_resolver_dir( - nm_path: PathBuf, - store_entry: bool, - pending: &[Target], - ) -> ResolverVisit { + fn visit_resolver_dir(nm_path: PathBuf, store_entry: bool, pending: &[Target]) -> ResolverVisit { let listing = list_dir_sync(&nm_path); let probe_filter = ProbeFilter::new(&listing); let matched = pending diff --git a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs index 2d6e225c..d71a0212 100644 --- a/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs +++ b/crates/socket-patch-core/src/crawlers/npm_crawler/oracle.rs @@ -9,9 +9,9 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use super::{ - build_npm_purl, is_legacy_pnpm_store_dir_name, is_safe_npm_component, parse_package_name, - read_package_json, NpmCrawler, StoreEntry, Target, NESTED_STORE_MAX_DEPTH, - NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME, + build_npm_purl, is_legacy_pnpm_store_dir_name, + is_safe_npm_component, parse_package_name, read_package_json, NpmCrawler, StoreEntry, + Target, NESTED_STORE_MAX_DEPTH, NESTED_STORE_MAX_DIRS, SKIP_DIRS, VLT_STORE_NAME, }; use crate::crawlers::types::{CrawledPackage, CrawlerOptions}; use crate::utils::fs::is_dir; diff --git a/crates/socket-patch-core/src/crawlers/python_crawler.rs b/crates/socket-patch-core/src/crawlers/python_crawler.rs index 3ba4bd3b..03814ab2 100644 --- a/crates/socket-patch-core/src/crawlers/python_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/python_crawler.rs @@ -1086,8 +1086,10 @@ pub async fn get_global_python_site_packages() -> Vec { } // 1. Ask Python for site-packages (subprocesses: on the blocking pool) - let site_output = - run_blocking(|| SITE_QUERY_MEMO.get_or_run(site_query_key(), run_site_query)).await; + let site_output = run_blocking(|| { + SITE_QUERY_MEMO.get_or_run(site_query_key(), run_site_query) + }) + .await; if let Some(stdout) = site_output { for p in parse_python_site_packages_output(&stdout) { add_path(p, &mut seen, &mut results); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 22ce57d3..ebbc7f92 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -24,9 +24,7 @@ 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::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, @@ -46,9 +44,9 @@ use super::guidance::{ 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, + 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; @@ -171,8 +169,7 @@ impl SkippedPatch { /// The `skipped[]` JSON entry (`{purl, uuid, reason[, detail]}`). pub fn to_json(&self) -> serde_json::Value { - serde_json::to_value(self) - .expect("SkippedPatch is plain strings: serialization cannot fail") + serde_json::to_value(self).expect("SkippedPatch is plain strings: serialization cannot fail") } } @@ -293,7 +290,9 @@ pub fn build_candidates( let token = reference .registry_override .as_ref() - .and_then(|o| crate::patch::redirect::grant_token_path_segment(&o.index_url, sel_uuid)) + .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 { @@ -413,9 +412,7 @@ impl CandidateFiles { /// The root-level Python lock names (sorted). async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(cwd) => { - crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default() - } + ProjectView::Disk(cwd) => crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default(), ProjectView::Memory(project) => project .children("") .into_iter() @@ -646,10 +643,7 @@ pub struct Rewritten { /// symbolic link (absent to the planner, refused by [`guard`]). fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, bool) { match view { - ProjectView::Disk(cwd) => ( - read_workspace_for_trust(&cwd.join(PNPM_WORKSPACE_REL)), - false, - ), + ProjectView::Disk(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), @@ -866,22 +860,10 @@ pub async fn rewrite( })); } - 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, - ); + 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 @@ -1005,12 +987,7 @@ fn pnpm_trust( pnpm_lock_texts.push(text); } if pnpm_lock_texts.is_empty() { - return ( - pnpm_warnings, - trust_config_write, - pnpm_rerun_only, - workspace_symlinked, - ); + 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 @@ -1133,12 +1110,7 @@ fn pnpm_trust( detail.trim_end_matches('.') ), })); - ( - pnpm_warnings, - trust_config_write, - pnpm_rerun_only, - workspace_symlinked, - ) + (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) } /// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every @@ -1216,28 +1188,26 @@ fn npm_allow_remote( 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) - } + 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(serde_json::json!({ @@ -1449,11 +1419,7 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { /// 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 { +pub fn guard(view: &ProjectView<'_>, done: &Rewritten, candidates: &[Candidate]) -> Option { if done.workspace_symlinked { return Some(symlink_refusal(PNPM_WORKSPACE_REL)); } diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index 2ffc10e3..01ed71ae 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -155,7 +155,9 @@ pub fn pnpm_lock_carries_hosted_redirect( 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.push(crate::utils::uri::encode_uri_component( + artifact_url, + )); needles } @@ -309,7 +311,11 @@ fn npm_allow_remote_preamble(hosts: &[&str]) -> String { /// 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 { +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", diff --git a/crates/socket-patch-core/src/hosted/ledger.rs b/crates/socket-patch-core/src/hosted/ledger.rs index d4e9e19c..066918b1 100644 --- a/crates/socket-patch-core/src/hosted/ledger.rs +++ b/crates/socket-patch-core/src/hosted/ledger.rs @@ -21,6 +21,7 @@ pub const REBASE_KINDS: &[&str] = &[ crate::patch::redirect::vlt::KIND, ]; + /// Merge this run's vlt node edits into the recorded ones. A fresh edit /// for the same `key` and DepID keeps the oldest recorded `original` (the /// pristine registry entry), takes the fresh `new` and drops the chain's @@ -37,7 +38,9 @@ pub fn rebase_vlt_edits( fresh: &[crate::patch::redirect::FileEdit], before_lock: Option<&str>, ) -> Vec { - use crate::patch::redirect::vlt::{carried_pin_original, edit_dep_id, lock_node_ids, KIND}; + use crate::patch::redirect::vlt::{ + carried_pin_original, edit_dep_id, lock_node_ids, KIND, + }; use crate::patch::redirect::FileEdit; fn superseding(edit: &FileEdit, old: &FileEdit) -> FileEdit { let mut next = edit.clone(); diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index 5f91d40a..b19a91ea 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -434,9 +434,7 @@ async fn engine( } let (entries, unsupported) = inventory_project_diagnosed_in(&ProjectView::Memory(project)).await; - for (code, detail) in - crate::vendor::lock_inventory::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); diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 7032d435..453e0ab2 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -32,10 +32,7 @@ pub fn vulnerabilities_for_manifest( /// `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 { +pub fn build_patch_record(patch: &PatchResponse, files: HashMap) -> PatchRecord { PatchRecord { uuid: patch.uuid.clone(), exported_at: patch.published_at.clone(), diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index 6398d800..d6fb3831 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -131,12 +131,11 @@ fn assert_same_with_metadata( bun_lockb_present, python_metadata, ); - let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)).map( - |mut merged| { + let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)) + .map(|mut merged| { merged.vlt_drives = vlt::vlt_drives(files, bun_lockb_present); merged - }, - ); + }); assert_eq!( merged.as_ref(), Some(&want), diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index be3d3227..9f03dbaa 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -11614,19 +11614,11 @@ mod tests { let redacted = format!( "https://patch.socket.dev/patch/npm/left-pad/1.3.0//{uuid}/left-pad-1.3.0.tgz?x=1" ); - assert_eq!( - redact_grant_token(&url, &url, uuid), - redacted, - "the URL alone" - ); + assert_eq!(redact_grant_token(&url, &url, uuid), redacted, "the URL alone"); let text = format!("vlt would fail to verify {url}: fetch error GET {url}: reset"); - let want = - format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); + let want = format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); assert_eq!(redact_grant_token(&text, &url, uuid), want, "every quote"); - assert!( - !redact_grant_token(&text, &url, uuid).contains(token), - "no token left" - ); + assert!(!redact_grant_token(&text, &url, uuid).contains(token), "no token left"); let registry = format!("https://patch.socket.dev/patch-registry/npm/{token}/{uuid}"); assert_eq!( redact_grant_token(®istry, ®istry, uuid), diff --git a/crates/socket-patch-core/src/patch/redirect/pdm.rs b/crates/socket-patch-core/src/patch/redirect/pdm.rs index e8b44145..51cab65d 100644 --- a/crates/socket-patch-core/src/patch/redirect/pdm.rs +++ b/crates/socket-patch-core/src/patch/redirect/pdm.rs @@ -278,18 +278,9 @@ mod tests { #[test] fn legacy_formats_warn_stale_install_risk_once() { for (fixture, warns) in [ - ( - include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), - true, - ), - ( - include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), - true, - ), - ( - include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), - false, - ), + (include_str!("../../../tests/fixtures/pdm-native/0.12.3.lock"), true), + (include_str!("../../../tests/fixtures/pdm-native/2.8.2.lock"), true), + (include_str!("../../../tests/fixtures/pdm-native/2.29.2.lock"), false), ] { let mut result = RewriteResult::default(); rewrite( diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs index f2c49a68..7725c9e6 100644 --- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs +++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs @@ -555,10 +555,7 @@ mod tests { let original = serde_json::to_string(&value).unwrap(); // A live lock (Pipfile beside it): conflicts veto the siblings. let files = BTreeMap::from([ - ( - "Pipfile".to_string(), - "[packages]\nurllib3 = \"*\"\n".to_string(), - ), + ("Pipfile".to_string(), "[packages]\nurllib3 = \"*\"\n".to_string()), ("Pipfile.lock".to_string(), original), ]); let mut result = RewriteResult::default(); @@ -598,30 +595,20 @@ mod tests { for stale in &stale_locks { let files = BTreeMap::from([ ("Pipfile.lock".to_string(), stale.clone()), - ( - "requirements.txt".to_string(), - "urllib3==1.26.18\n".to_string(), - ), + ("requirements.txt".to_string(), "urllib3==1.26.18\n".to_string()), ]); - let result = - super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); + let result = super::super::rewrite_registry_redirect(&files, std::slice::from_ref(&dep)); assert!( !result.refused_pipenv_uuids.contains("patch-one"), "a non-conflict must not veto: {stale}" ); assert!( - result - .warnings - .iter() - .any(|w| w.code == "redirect_pipenv_skipped"), + result.warnings.iter().any(|w| w.code == "redirect_pipenv_skipped"), "{:?}", result.warnings ); assert!( - result - .files - .get("requirements.txt") - .is_some_and(|t| t.contains("patch.socket.dev")), + result.files.get("requirements.txt").is_some_and(|t| t.contains("patch.socket.dev")), "requirements.txt must still be redirected past a stale Pipfile.lock: {result:?}" ); assert!(!result.files.contains_key("Pipfile.lock")); @@ -706,18 +693,10 @@ mod tests { ); let foreign = redirected.replacen( redirected_entry, - &format_entry( - &json!({"file": "https://example.org/fork.whl"}), - &redirected, - 0, - ) - .unwrap(), + &format_entry(&json!({"file": "https://example.org/fork.whl"}), &redirected, 0).unwrap(), 1, ); - assert!( - restore(&foreign, &edits[0]).is_err(), - "a foreign reference is drift" - ); + assert!(restore(&foreign, &edits[0]).is_err(), "a foreign reference is drift"); // Re-scan after the relock, then roll back newest-first. let (again, second) = plan(&relocked, &dep, None).unwrap(); @@ -735,10 +714,7 @@ mod tests { let files = |text: &str| BTreeMap::from([("Pipfile.lock".to_string(), text.to_string())]); assert!(lock_targets(&files(&lock()), std::slice::from_ref(&dep))); assert!(!lock_targets(&files(&lock()), std::slice::from_ref(&other))); - assert!(!lock_targets( - &files("{ not json"), - std::slice::from_ref(&dep) - )); + assert!(!lock_targets(&files("{ not json"), std::slice::from_ref(&dep))); assert!(!lock_targets(&BTreeMap::new(), std::slice::from_ref(&dep))); let mut npm = dep.clone(); npm.ecosystem = "npm".into(); @@ -763,10 +739,7 @@ mod tests { let entry: Value = serde_json::from_str(&fixed).unwrap(); assert!(entry["default"]["urllib3"].get("version").is_none()); assert!(entry["default"]["urllib3"].get("index").is_none()); - assert!(entry["default"]["urllib3"]["file"] - .as_str() - .unwrap() - .contains("patch-one")); + assert!(entry["default"]["urllib3"]["file"].as_str().unwrap().contains("patch-one")); value["default"]["urllib3"]["version"] = json!("==2.0.0"); let conflicting = serde_json::to_string(&value).unwrap(); @@ -787,10 +760,7 @@ mod tests { assert!(owned_url(public, &dep)); assert!(!owned_url("https://example.org/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl", &dep)); dep.artifact_url = "https://patches.internal.example:8443/patch/pypi/urllib3/1.26.18/tok/patch-one/urllib3-1.26.18-py3-none-any.whl".into(); - assert!( - owned_url(&dep.artifact_url, &dep), - "the grant's own origin is ours" - ); + assert!(owned_url(&dep.artifact_url, &dep), "the grant's own origin is ours"); assert!(owned_url(public, &dep), "and so is the public service"); assert!(!owned_url("https://patches.internal.example:8443/patch/pypi/urllib3/1.26.19/tok/patch-one/urllib3-1.26.19-py3-none-any.whl", &dep), "another version is not"); // Rotation on the custom origin restores through the chain. @@ -809,12 +779,7 @@ mod tests { fn restore_refuses_a_non_object_ledger_original() { let dep = dependency("urllib3", "1.26.18", "patch-one"); let (text, edits) = plan(&lock(), &dep, None).unwrap(); - for bad in [ - "\"just a string\"", - "[1, 2]", - "not json at all", - "{\"a\": 1}, \"injected\": {}", - ] { + for bad in ["\"just a string\"", "[1, 2]", "not json at all", "{\"a\": 1}, \"injected\": {}"] { let mut edit = edits[0].clone(); edit.original = Some(Value::String(bad.to_string())); assert!(restore(&text, &edit).is_err(), "{bad}"); @@ -848,28 +813,18 @@ mod tests { for edit in &first_edits { let replacement = edit.new.as_ref().unwrap().as_str().unwrap(); // A tampered reference (its `#sha256=` pin) is drift… - let drift = two.replacen( - replacement, - &replacement.replace("#sha256=", "#sha256=0"), - 1, - ); + let drift = two.replacen(replacement, &replacement.replace("#sha256=", "#sha256=0"), 1); assert!(restore(&drift, edit).is_err()); // …while a re-serialized entry that kept our reference (Pipenv // 2023+ relocking a marker-excluded entry restores the registry // `hashes` and `version` next to it) is still ours and restores. let mut value: Value = serde_json::from_str(&two).unwrap(); - let section: &str = serde_json::from_str::<[String; 2]>(edit.key.as_deref().unwrap()) - .unwrap()[0] - .clone() - .leak(); + let section: &str = serde_json::from_str::<[String; 2]>(edit.key.as_deref().unwrap()).unwrap()[0].clone().leak(); value[section]["urllib3"]["hashes"] = json!(["sha256:upstream-a", "sha256:upstream-b"]); value[section]["urllib3"]["version"] = json!("==1.26.18"); let kept = serde_json::to_string_pretty(&value).unwrap(); let restored: Value = serde_json::from_str(&restore(&kept, edit).unwrap()).unwrap(); - assert!( - restored[section]["urllib3"].get("file").is_none(), - "{restored}" - ); + assert!(restored[section]["urllib3"].get("file").is_none(), "{restored}"); let mut unsafe_edit = edit.clone(); unsafe_edit.path = "../Pipfile.lock".into(); assert!(restore(&two, &unsafe_edit).is_err()); @@ -928,10 +883,7 @@ mod compatibility_tests { assert!(!result.refused_pipenv_uuids.contains("patch-one")); assert!(result.files["requirements.txt"].contains("patch.socket.dev")); assert!(!result.files.contains_key("Pipfile.lock")); - assert!(result - .warnings - .iter() - .any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); + assert!(result.warnings.iter().any(|w| w.code == "redirect_pipenv_refused" && w.detail.contains("no Pipfile"))); } /// Rollback survives what git and Pipenv do to the lock between the @@ -963,17 +915,11 @@ mod compatibility_tests { value["default"]["urllib3"]["version"] = json!("==1.26.18"); value["default"]["urllib3"]["index"] = json!("pypi"); let hybrid = serde_json::to_string_pretty(&value).unwrap(); - let default_edit = edits - .iter() - .find(|e| e.key.as_deref() == Some(r#"["default","urllib3"]"#)) - .unwrap(); + let default_edit = edits.iter().find(|e| e.key.as_deref() == Some(r#"["default","urllib3"]"#)).unwrap(); let restored = restore(&hybrid, default_edit).unwrap(); let value: Value = serde_json::from_str(&restored).unwrap(); assert_eq!(value["default"]["urllib3"]["version"], json!("==1.26.18")); - assert!( - value["default"]["urllib3"].get("file").is_none(), - "{restored}" - ); + assert!(value["default"]["urllib3"].get("file").is_none(), "{restored}"); // Dropped entry (`pipenv uninstall`): nothing to unwind, retires. let mut value: Value = serde_json::from_str(&redirected).unwrap(); value["default"].as_object_mut().unwrap().remove("urllib3"); diff --git a/crates/socket-patch-core/src/patch/redirect/poetry.rs b/crates/socket-patch-core/src/patch/redirect/poetry.rs index eacc889a..a2798cd6 100644 --- a/crates/socket-patch-core/src/patch/redirect/poetry.rs +++ b/crates/socket-patch-core/src/patch/redirect/poetry.rs @@ -92,9 +92,7 @@ pub(super) fn rewrite_poetry( } } Err(detail) => { - result - .refused_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -102,9 +100,7 @@ pub(super) fn rewrite_poetry( continue; } } - result - .confirmed_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); content = rewrite.text; if !stale_warned { if let Some(format) = @@ -140,18 +136,14 @@ pub(super) fn rewrite_poetry( } // Already redirected to this artifact (idempotent re-scan). Ok(Some(_)) => { - result - .confirmed_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), Err(detail) => { - result - .refused_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -223,9 +215,7 @@ fn rewrite_poetry_reference( } } Err(detail) => { - result - .refused_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), @@ -233,9 +223,7 @@ fn rewrite_poetry_reference( continue; } } - result - .confirmed_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); content = rewritten; if !stale_warned { if let Some(format) = pre_1_4_writer(&content) { @@ -269,18 +257,14 @@ fn rewrite_poetry_reference( } // Already redirected to this artifact (idempotent re-scan). Ok(Some(_)) => { - result - .confirmed_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone()); } Ok(None) => result.warnings.push(RewriteWarning { code: "redirect_poetry_entry_not_found".into(), detail: format!("no {path} entry for {}@{}", dep.name, dep.version), }), Err(detail) => { - result - .refused_python_lock_uuids - .insert(dep.patch_uuid.clone()); + result.refused_python_lock_uuids.insert(dep.patch_uuid.clone()); result.warnings.push(RewriteWarning { code: "redirect_poetry_lock_unsupported".into(), detail: format!("{path}: {detail}"), diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 37e01cbc..5e0b921c 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -265,9 +265,7 @@ pub(super) fn rewrite( } } matched = true; - result - .confirmed_requirements_uuids - .insert(dep.patch_uuid.clone()); + result.confirmed_requirements_uuids.insert(dep.patch_uuid.clone()); let options = requirement_tokens(specifier) .into_iter() .skip_while(|token| !token.starts_with("--")) diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs index be1476b1..f176426c 100644 --- a/crates/socket-patch-core/src/update/download.rs +++ b/crates/socket-patch-core/src/update/download.rs @@ -741,10 +741,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let missing = tmp.path().join("never-existed"); sweep_stale_stages(&missing); - assert!( - !missing.exists(), - "sweep must not create the destination dir" - ); + assert!(!missing.exists(), "sweep must not create the destination dir"); } /// A write failure AFTER a successful open (EFBIG here, standing in @@ -760,7 +757,8 @@ mod tests { #[test] fn stage_write_failure_cleans_up_stage_file() { const CHILD_ENV: &str = "SOCKET_PATCH_CORE_TEST_STAGE_FSIZE_CHILD"; - const TEST_NAME: &str = "update::download::tests::stage_write_failure_cleans_up_stage_file"; + const TEST_NAME: &str = + "update::download::tests::stage_write_failure_cleans_up_stage_file"; if std::env::var_os(CHILD_ENV).is_none() { let exe = std::env::current_exe().expect("test binary path must resolve"); let output = std::process::Command::new(exe) @@ -826,10 +824,7 @@ mod tests { matches!(err, UpdateError::SwapFailed(_)), "expected SwapFailed, got: {err}" ); - assert!( - err.to_string().contains("error writing staged binary"), - "{err}" - ); + assert!(err.to_string().contains("error writing staged binary"), "{err}"); let leftovers: Vec = std::fs::read_dir(tmp.path()) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs index 7c3b04c2..5b286901 100644 --- a/crates/socket-patch-core/src/update/release.rs +++ b/crates/socket-patch-core/src/update/release.rs @@ -751,11 +751,9 @@ mod tests { .mount(&server) .await; - let client = metadata_client( - &short_timeouts(), - follow_redirect_policy(&default_endpoints()), - ) - .unwrap(); + let client = + metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -788,11 +786,9 @@ mod tests { .mount(&server) .await; - let client = metadata_client( - &short_timeouts(), - follow_redirect_policy(&default_endpoints()), - ) - .unwrap(); + let client = + metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints())) + .unwrap(); let err = client .get(format!("{}/start", server.uri())) .send() @@ -868,10 +864,7 @@ mod tests { .unwrap_err(); assert!(matches!(err, UpdateError::CheckFailed(_)), "{err:?}"); let msg = err.to_string(); - assert!( - msg.contains("expected a redirect to the latest tag"), - "{msg}" - ); + assert!(msg.contains("expected a redirect to the latest tag"), "{msg}"); assert!(msg.contains("API fallback:"), "{msg}"); assert!(msg.contains("returned 500"), "{msg}"); } @@ -952,14 +945,8 @@ mod tests { #[test] fn url_host_keeps_explicit_ports() { - assert_eq!( - url_host("http://127.0.0.1:9/x").as_deref(), - Some("127.0.0.1:9") - ); - assert_eq!( - url_host("https://github.com/a").as_deref(), - Some("github.com") - ); + assert_eq!(url_host("http://127.0.0.1:9/x").as_deref(), Some("127.0.0.1:9")); + assert_eq!(url_host("https://github.com/a").as_deref(), Some("github.com")); assert_eq!(url_host("not a url"), None); } @@ -972,9 +959,7 @@ mod tests { // code stays `check_failed` (stable contract). let server = MockServer::start().await; Mock::given(method("GET")) - .and(path( - "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", - )) + .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; @@ -1007,9 +992,7 @@ mod tests { // silently. let server = MockServer::start().await; Mock::given(method("GET")) - .and(path( - "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS", - )) + .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS")) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; diff --git a/crates/socket-patch-core/src/utils/group_commit.rs b/crates/socket-patch-core/src/utils/group_commit.rs index 85490b35..d00a2da3 100644 --- a/crates/socket-patch-core/src/utils/group_commit.rs +++ b/crates/socket-patch-core/src/utils/group_commit.rs @@ -296,9 +296,9 @@ where // write the lock edits beside the pre-run ledger. Put the caller's value // back before the unwind continues — the same value a caught-and- // continued caller holds. - if let Err(panic) = - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| edit(Arc::make_mut(value)))) - { + if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + edit(Arc::make_mut(value)) + })) { files.insert(key, captured(value)); drop(files); std::panic::resume_unwind(panic); @@ -1595,10 +1595,7 @@ mod tests { .unwrap(); remove_dir_after_commit(&dir).await; drop(dropped); - assert!( - dir.join("config.toml").exists(), - "an abandoned commit removes nothing" - ); + assert!(dir.join("config.toml").exists(), "an abandoned commit removes nothing"); let group = GroupCommit::begin(root); super::super::fs::remove_file(&dir.join("config.toml")) @@ -1607,10 +1604,7 @@ mod tests { remove_dir_after_commit(&dir).await; assert!(dir.join("config.toml").exists(), "captured, still on disk"); group.commit().await.unwrap(); - assert!( - !dir.exists(), - "the emptied directory is removed after the commit" - ); + assert!(!dir.exists(), "the emptied directory is removed after the commit"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("config.toml"), b"[patch]\n").unwrap(); @@ -1622,10 +1616,7 @@ mod tests { remove_dir_after_commit(&dir).await; group.commit().await.unwrap(); assert!(!dir.join("config.toml").exists()); - assert!( - dir.join("credentials.toml").exists(), - "a non-empty directory is kept" - ); + assert!(dir.join("credentials.toml").exists(), "a non-empty directory is kept"); remove_dir_after_commit(&root.join("gone")).await; std::fs::remove_file(dir.join("credentials.toml")).unwrap(); diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index a2e23dcb..80a6dda9 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -6,9 +6,9 @@ pub mod env_compat; pub mod failpoint; pub mod fs; pub mod group_commit; +pub mod notice; pub(crate) mod http; pub(crate) mod line_endings; -pub mod notice; pub mod pdm_lock; pub mod pipenv; pub mod poetry_lock; diff --git a/crates/socket-patch-core/src/utils/pdm_lock.rs b/crates/socket-patch-core/src/utils/pdm_lock.rs index 1954f109..4dbd1816 100644 --- a/crates/socket-patch-core/src/utils/pdm_lock.rs +++ b/crates/socket-patch-core/src/utils/pdm_lock.rs @@ -358,11 +358,9 @@ fn plan_pdm_rewrite( .filter_map(|&index| packages.get(index)?.get("version").and_then(Item::as_str)) .collect(); if locked_versions.len() > 1 { - return Err( - "PDM lock resolves this package at multiple versions (a marker or \ + return Err("PDM lock resolves this package at multiple versions (a marker or \ multi-target fork); patching one fork would leave the others unpatched" - .into(), - ); + .into()); } let mut variants = std::collections::BTreeSet::new(); let mut edits = Vec::new(); @@ -777,10 +775,7 @@ mod tests { &"a".repeat(64), ) .unwrap(); - assert!( - rewired.contains(&fresh) && !rewired.contains(&stale), - "{rewired}" - ); + assert!(rewired.contains(&fresh) && !rewired.contains(&stale), "{rewired}"); // A foreign (non-Socket) existing url is still refused. let foreign = fixture("2.29.2").replace( "name = \"urllib3\"", diff --git a/crates/socket-patch-core/src/utils/poetry_lock.rs b/crates/socket-patch-core/src/utils/poetry_lock.rs index e0d2473e..48b40b8a 100644 --- a/crates/socket-patch-core/src/utils/poetry_lock.rs +++ b/crates/socket-patch-core/src/utils/poetry_lock.rs @@ -71,10 +71,7 @@ fn lock_version_of(lock: &Table) -> Result<&str, String> { { Ok("0") } - None => Err( - "poetry.lock has neither a [metadata] lock-version nor a [metadata.hashes] table" - .into(), - ), + None => Err("poetry.lock has neither a [metadata] lock-version nor a [metadata.hashes] table".into()), } } @@ -633,15 +630,7 @@ mod tests { Ok(other) => panic!("{label}: expected a refusal, got {other:?}"), } // The vendored (file-source) spelling takes the same guarded path. - match rewrite_poetry_lock( - &text, - "urllib3", - "1.26.18", - "file", - ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", - WHEEL, - &sha(), - ) { + match rewrite_poetry_lock(&text, "urllib3", "1.26.18", "file", ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", WHEEL, &sha()) { Err(err) => assert!(!err.is_empty(), "{label}"), Ok(other) => panic!("{label}: expected a refusal, got {other:?}"), } @@ -659,10 +648,7 @@ mod tests { assert!(rewritten.contains(URL)); assert!(rewritten.contains("lock-version = \"2.2\"")); for bad in ["3.0", "2", "2.x", "1.2"] { - let lock = fixture("2.4.3").replace( - "lock-version = \"2.1\"", - &format!("lock-version = \"{bad}\""), - ); + let lock = fixture("2.4.3").replace("lock-version = \"2.1\"", &format!("lock-version = \"{bad}\"")); let err = hosted(&lock).unwrap_err(); assert!(err.contains(bad), "{bad}: {err}"); } @@ -685,47 +671,28 @@ mod tests { // Poetry 1.0 carries a `#sha256=…&` fragment; the comparison ignores it. let lock10 = fixture("1.0.10"); let first10 = hosted(&lock10).unwrap().unwrap(); - let second10 = rewrite_poetry_lock( - &first10, - "urllib3", - "1.26.18", - "url", - &rotated, - WHEEL, - &"b".repeat(64), - ) - .unwrap() - .unwrap(); + let second10 = rewrite_poetry_lock(&first10, "urllib3", "1.26.18", "url", &rotated, WHEEL, &"b".repeat(64)) + .unwrap() + .unwrap(); assert!(second10.contains(&format!("{rotated}#sha256={}&", "b".repeat(64)))); // A user's own url source on another origin stays untouched. let foreign = first.replace("https://patch.socket.dev", "https://mirror.example"); - assert!(hosted(&foreign) - .unwrap_err() - .contains("existing Poetry source")); + assert!(hosted(&foreign).unwrap_err().contains("existing Poetry source")); // A vendored file source is never taken over by the hosted path here. - let vendored = rewrite_poetry_lock( - &lock, - "urllib3", - "1.26.18", - "file", - ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", - WHEEL, - &sha(), - ) - .unwrap() - .unwrap(); - assert!(hosted(&vendored) - .unwrap_err() - .contains("existing Poetry source")); + let vendored = rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "file", ".socket/vendor/pypi/x/urllib3-1.26.18-py2.py3-none-any.whl", WHEEL, &sha()) + .unwrap() + .unwrap(); + assert!(hosted(&vendored).unwrap_err().contains("existing Poetry source")); } #[test] fn sha256_is_written_lowercase() { let lock = fixture("2.4.3"); let upper = "A".repeat(64); - let rewritten = rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "url", URL, WHEEL, &upper) - .unwrap() - .unwrap(); + let rewritten = + rewrite_poetry_lock(&lock, "urllib3", "1.26.18", "url", URL, WHEEL, &upper) + .unwrap() + .unwrap(); assert!(rewritten.contains(&format!("sha256:{}", "a".repeat(64)))); assert!(!rewritten.contains(&upper)); } @@ -745,10 +712,7 @@ mod tests { let lock = format!("{lock}{sibling}"); let rewritten = hosted(&lock).unwrap().unwrap(); assert!(rewritten.contains(URL)); - assert!( - rewritten.contains(&sibling), - "sibling entry must survive verbatim" - ); + assert!(rewritten.contains(&sibling), "sibling entry must survive verbatim"); let edits = poetry_lock_edits(&lock, &rewritten, "urllib3").unwrap(); assert_eq!(edits.len(), 2); assert!(edits[1].0.starts_with('\n')); @@ -769,10 +733,7 @@ mod tests { "{version}: {original:?}" ); assert!(new.ends_with("[metadata]") || new.ends_with("[extras]")); - assert!( - !new.contains(original.as_str()), - "{version}: pristine must not be a prefix of new" - ); + assert!(!new.contains(original.as_str()), "{version}: pristine must not be a prefix of new"); // A relock that keeps `[package.source]` but drops the inserted // `files` line must NOT contain the pristine fragment either. let drifted: String = rewritten @@ -786,20 +747,12 @@ mod tests { // header, the second starts with it; both splice independently. let lock = fixture("2.4.3"); let mut doc: DocumentMut = lock.parse().unwrap(); - let mut second = doc["package"] - .as_array_of_tables() - .unwrap() - .get(0) - .unwrap() - .clone(); + let mut second = doc["package"].as_array_of_tables().unwrap().get(0).unwrap().clone(); second["name"] = value("six"); second["version"] = value("1.16.0"); second.set_position(None); second.remove("extras"); - doc["package"] - .as_array_of_tables_mut() - .unwrap() - .push(second); + doc["package"].as_array_of_tables_mut().unwrap().push(second); let two = doc.to_string(); let first = hosted(&two).unwrap().unwrap(); let edits = poetry_lock_edits(&two, &first, "urllib3").unwrap(); @@ -811,29 +764,11 @@ mod tests { fn absent_or_other_version_yields_none_not_error() { let lock = fixture("2.4.3"); assert_eq!( - rewrite_poetry_lock( - &lock, - "six", - "1.16.0", - "url", - &URL.replace("urllib3", "six").replace("1.26.18", "1.16.0"), - "six-1.16.0-py2.py3-none-any.whl", - &sha() - ) - .unwrap(), + rewrite_poetry_lock(&lock, "six", "1.16.0", "url", &URL.replace("urllib3", "six").replace("1.26.18", "1.16.0"), "six-1.16.0-py2.py3-none-any.whl", &sha()).unwrap(), None ); assert_eq!( - rewrite_poetry_lock( - &lock, - "urllib3", - "1.26.17", - "url", - &URL.replace("1.26.18", "1.26.17"), - "urllib3-1.26.17-py2.py3-none-any.whl", - &sha() - ) - .unwrap(), + rewrite_poetry_lock(&lock, "urllib3", "1.26.17", "url", &URL.replace("1.26.18", "1.26.17"), "urllib3-1.26.17-py2.py3-none-any.whl", &sha()).unwrap(), None ); } diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs index 5eb99700..2ca51e10 100644 --- a/crates/socket-patch-core/src/utils/python_script.rs +++ b/crates/socket-patch-core/src/utils/python_script.rs @@ -627,12 +627,7 @@ mod rendering_tests { "{direct}" ); assert!(uv_line.ends_with('}'), "{direct}"); - assert!( - direct.starts_with( - "[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n" - ), - "{direct}" - ); + assert!(direct.starts_with("[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"), "{direct}"); assert_settled(&direct); let transitive = rewrite_project_metadata( 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 de782d40..8858b27d 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 mod view; pub(crate) mod vlt; +pub mod view; pub(crate) mod wired; pub(crate) mod yarn; @@ -210,7 +210,9 @@ pub struct UnsupportedNpmLayout { /// (`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)> { +pub fn unsupported_layout_warnings( + unsupported: &[UnsupportedNpmLayout], +) -> Vec<(String, String)> { unsupported .iter() .map(|diag| match diag.code { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs index ba132444..f84eed67 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/vlt.rs @@ -6,8 +6,8 @@ use std::path::Path; use serde_json::{Map, Value}; -use super::view::ProjectView; use crate::constants::npm_family::VLT_LOCK; +use super::view::ProjectView; use crate::vendor::vlt_lock_text::{ is_default_registry, sniff_lock, split_dep_id, DepId, DepIdKind, LockSniff, }; diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index a675937d..82cfd580 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -1249,10 +1249,8 @@ mod tests { let why = gitignore_probe(&root, &outside).await.unwrap_err(); assert!(why.contains("`git check-ignore` exited 128"), "{why}"); assert_eq!(gitignored(&root, &outside).await, None); - assert!( - gitignore_unchecked_detail(".socket/vendor/npm/u/a-1.0.0", &why) - .contains("make sure no ignore rule covers .socket/") - ); + assert!(gitignore_unchecked_detail(".socket/vendor/npm/u/a-1.0.0", &why) + .contains("make sure no ignore rule covers .socket/")); } #[cfg(unix)] diff --git a/crates/socket-patch-core/src/vendor/prestage.rs b/crates/socket-patch-core/src/vendor/prestage.rs index b4cf64fb..b3149cca 100644 --- a/crates/socket-patch-core/src/vendor/prestage.rs +++ b/crates/socket-patch-core/src/vendor/prestage.rs @@ -464,10 +464,7 @@ mod sweep_tests { for dir in &kept { assert!(v.join(dir).exists(), "{dir} kept"); } - assert!( - !v.join("gem").exists(), - "the levels only the tree kept alive are pruned" - ); + assert!(!v.join("gem").exists(), "the levels only the tree kept alive are pruned"); assert!(!v.join(format!("composer/{u}/psr/log@3.0.2")).exists()); assert!(v.join("state.json").exists()); assert_eq!(sweep_stale(root).await, 0, "idempotent"); diff --git a/crates/socket-patch-core/src/vendor/toml_surgery.rs b/crates/socket-patch-core/src/vendor/toml_surgery.rs index 2a10e5fb..23c26154 100644 --- a/crates/socket-patch-core/src/vendor/toml_surgery.rs +++ b/crates/socket-patch-core/src/vendor/toml_surgery.rs @@ -506,8 +506,7 @@ mod tests { // CRLF, and a hand edit can leave a mixed-ending file, so the // removal helpers must never normalize: every byte outside the // removed segment survives verbatim. - let wired = - "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; + let wired = "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\nfoo = { path = \"w.whl\" }\n"; let after = remove_exact_line(wired, "foo = { path = \"w.whl\" }").unwrap(); assert_eq!(after, "[project]\r\nname = \"x\"\r\n\n[tool.uv.sources]\n"); assert_eq!( diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 335aa175..34b3c3ef 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -2951,10 +2951,7 @@ 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/tests/covgap_api_blob_fetcher.rs b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs index 75124d3b..460bb2e6 100644 --- a/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs +++ b/crates/socket-patch-core/tests/covgap_api_blob_fetcher.rs @@ -571,8 +571,5 @@ async fn fetch_missing_blobs_mixed_outcomes_aggregate_and_format() { // End-to-end formatter exercise with a genuinely mixed result. let rendered = format_fetch_result(&result); assert!(rendered.contains("Downloaded 1 blob\n"), "{rendered}"); - assert!( - rendered.contains("Failed to download 2 blobs"), - "{rendered}" - ); + assert!(rendered.contains("Failed to download 2 blobs"), "{rendered}"); } diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index fbbda629..24a50d9b 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -95,11 +95,7 @@ async fn get_vendor_paths_global_nonexistent_composer_home_falls_back() { fn write_composer_shim(dir: &Path, echo_path: &Path) { use std::os::unix::fs::PermissionsExt; let shim = dir.join("composer"); - std::fs::write( - &shim, - format!("#!/bin/sh\necho '{}'\n", echo_path.display()), - ) - .unwrap(); + std::fs::write(&shim, format!("#!/bin/sh\necho '{}'\n", echo_path.display())).unwrap(); std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); } diff --git a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs index 7842d44b..5f712da1 100644 --- a/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs +++ b/crates/socket-patch-core/tests/covgap_vendor_nuget_feed.rs @@ -88,7 +88,9 @@ async fn stage_tempdir_creation_failure_is_reported_not_fatal() { drop(guard); match outcome { - VendorOutcome::Done { result, entry, .. } => { + VendorOutcome::Done { + result, entry, .. + } => { assert!(!result.success, "the stage failure must fail the vendor"); assert!(entry.is_none(), "no ledger entry for a failed vendor"); let err = result.error.as_deref().unwrap_or(""); diff --git a/crates/socket-patch-core/tests/poetry_hosted.rs b/crates/socket-patch-core/tests/poetry_hosted.rs index dc1e89cd..74ad3cd6 100644 --- a/crates/socket-patch-core/tests/poetry_hosted.rs +++ b/crates/socket-patch-core/tests/poetry_hosted.rs @@ -65,11 +65,7 @@ async fn native_lock_generations_redirect_idempotently_and_restore_every_byte() let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - if pre_1_4 { - vec!["redirect_poetry_stale_install_risk"] - } else { - vec![] - }, + if pre_1_4 { vec!["redirect_poetry_stale_install_risk"] } else { vec![] }, "{version}: {:?}", result.warnings ); @@ -115,24 +111,12 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert!( - lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), - "{lock10}" - ); + assert!(lock10.contains(&format!("url = \"{URL}#sha256={sha}&\"")), "{lock10}"); assert!(lock10.contains("reference = \"\""), "{lock10}"); - assert!( - lock10.contains(&format!( - "urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]" - )), - "{lock10}" - ); + assert!(lock10.contains(&format!("urllib3 = [{{ file = \"{WHEEL}\", hash = \"sha256:{sha}\" }}]")), "{lock10}"); // Poetry >= 1.2 consuming this 1.0 lock verifies the package `files` // entry, so it is written too (1.0 ignores the extra key). - assert_eq!( - lock10.matches(&format!("sha256:{sha}")).count(), - 2, - "{lock10}" - ); + assert_eq!(lock10.matches(&format!("sha256:{sha}")).count(), 2, "{lock10}"); let doc: toml_edit::DocumentMut = lock10.parse().unwrap(); assert!(doc["package"][0]["files"].is_array(), "{lock10}"); @@ -142,15 +126,8 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!( - lock11.matches(&format!("sha256:{sha}")).count(), - 2, - "package files + metadata.files:\n{lock11}" - ); - assert!( - lock11.contains(&format!("url = \"{URL}\"")), - "no fragment on 1.1" - ); + assert_eq!(lock11.matches(&format!("sha256:{sha}")).count(), 2, "package files + metadata.files:\n{lock11}"); + assert!(lock11.contains(&format!("url = \"{URL}\"")), "no fragment on 1.1"); assert!(!lock11.contains("reference"), "{lock11}"); let doc: toml_edit::DocumentMut = lock11.parse().unwrap(); assert!(doc["package"][0]["files"].is_array()); @@ -162,19 +139,11 @@ fn hosted_shapes_match_each_lock_generations_installer() { ) .files["poetry.lock"] .clone(); - assert_eq!( - lock21.matches(&format!("sha256:{sha}")).count(), - 1, - "{lock21}" - ); + assert_eq!(lock21.matches(&format!("sha256:{sha}")).count(), 1, "{lock21}"); assert!(!lock21.contains("reference")); let pristine: toml_edit::DocumentMut = original("2.4.3").parse().unwrap(); let doc: toml_edit::DocumentMut = lock21.parse().unwrap(); - assert_eq!( - doc["metadata"].to_string(), - pristine["metadata"].to_string(), - "[metadata] untouched on 2.x" - ); + assert_eq!(doc["metadata"].to_string(), pristine["metadata"].to_string(), "[metadata] untouched on 2.x"); } #[test] @@ -391,21 +360,14 @@ fn absent_entries_warn_once_and_missing_sha256_is_gated_once_per_dep() { let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); assert_eq!( codes, - vec![ - "redirect_poetry_entry_not_found", - "redirect_poetry_entry_not_found" - ] + vec!["redirect_poetry_entry_not_found", "redirect_poetry_entry_not_found"] ); let mut missing_hash = patch(); missing_hash.integrity.sha256 = None; let result = rewrite_registry_redirect(&files, &[missing_hash]); assert!(result.files.is_empty()); let codes: Vec<&str> = result.warnings.iter().map(|w| w.code.as_str()).collect(); - assert_eq!( - codes, - vec!["redirect_poetry_missing_sha256"], - "gated once, not once per lock" - ); + assert_eq!(codes, vec!["redirect_poetry_missing_sha256"], "gated once, not once per lock"); } /// A future Poetry that bumps the lock minor (2.2) is rewritten like 2.1 in @@ -419,12 +381,9 @@ async fn newer_2x_minor_redirects_and_reverts() { assert!(result.warnings.is_empty(), "{:?}", result.warnings); assert!(result.files["poetry.lock"].contains(URL)); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write( - directory.path().join("poetry.lock"), - &result.files["poetry.lock"], - ) - .await - .unwrap(); + tokio::fs::write(directory.path().join("poetry.lock"), &result.files["poetry.lock"]) + .await + .unwrap(); let mut state = RedirectState { edits: result.edits, ..RedirectState::default() @@ -432,9 +391,7 @@ async fn newer_2x_minor_redirects_and_reverts() { let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")) - .await - .unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), lock ); } @@ -448,22 +405,13 @@ fn rotated_grant_token_supersedes_the_prior_hosted_url() { let first = rewrite_registry_redirect(&files, &[patch()]); let mut rotated = patch(); rotated.token = "00000000-0000-4000-8000-000000000000".into(); - rotated.artifact_url = URL.replace( - "7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", - "00000000-0000-4000-8000-000000000000", - ); + rotated.artifact_url = URL.replace("7e52b8b6-53f2-4dc8-860a-1ae7ebd8be0e", "00000000-0000-4000-8000-000000000000"); let second = rewrite_registry_redirect(&first.files, &[rotated.clone()]); assert!(second.warnings.is_empty(), "{:?}", second.warnings); let lock = &second.files["poetry.lock"]; assert!(lock.contains(&rotated.artifact_url) && !lock.contains(URL)); assert_eq!(second.edits.len(), 1); - assert!(second.edits[0] - .original - .as_ref() - .unwrap() - .as_str() - .unwrap() - .contains(URL)); + assert!(second.edits[0].original.as_ref().unwrap().as_str().unwrap().contains(URL)); } /// A relock (or hand edit) that drops the inserted `files` line but keeps @@ -484,35 +432,22 @@ async fn dropped_files_line_with_source_kept_is_refused_not_converged() { .collect::>() .join("\n") + "\n"; - assert_ne!( - drifted, *redirected, - "{version}: the files line must have been removed" - ); + assert_ne!(drifted, *redirected, "{version}: the files line must have been removed"); assert!(drifted.contains("[package.source]")); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write(directory.path().join("poetry.lock"), &drifted) - .await - .unwrap(); + tokio::fs::write(directory.path().join("poetry.lock"), &drifted).await.unwrap(); let mut state = RedirectState { edits: result.edits.clone(), ..RedirectState::default() }; let outcome = revert_remaining_redirect_edits(directory.path(), &mut state, false).await; - assert!( - !outcome.fully_reverted(), - "{version}: must refuse, not report success" - ); + assert!(!outcome.fully_reverted(), "{version}: must refuse, not report success"); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")) - .await - .unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), drifted, "{version}: a refused revert writes nothing" ); - assert!( - !state.edits.is_empty(), - "{version}: the ledger keeps its edits for a re-scan" - ); + assert!(!state.edits.is_empty(), "{version}: the ledger keeps its edits for a re-scan"); } } @@ -527,9 +462,7 @@ async fn lock_1_0_rollback_converges_on_a_hand_restored_lock() { let result = rewrite_registry_redirect(&files, &[patch()]); assert!(!result.edits.is_empty()); let directory = tempfile::tempdir().unwrap(); - tokio::fs::write(directory.path().join("poetry.lock"), &pristine) - .await - .unwrap(); + tokio::fs::write(directory.path().join("poetry.lock"), &pristine).await.unwrap(); let mut state = RedirectState { edits: result.edits, ..RedirectState::default() @@ -538,9 +471,7 @@ async fn lock_1_0_rollback_converges_on_a_hand_restored_lock() { assert!(outcome.fully_reverted(), "{:?}", outcome.refusals); assert!(state.edits.is_empty()); assert_eq!( - tokio::fs::read_to_string(directory.path().join("poetry.lock")) - .await - .unwrap(), + tokio::fs::read_to_string(directory.path().join("poetry.lock")).await.unwrap(), pristine ); } diff --git a/crates/socket-patch-node/src/lib.rs b/crates/socket-patch-node/src/lib.rs index 29fa8e6a..d83403b8 100644 --- a/crates/socket-patch-node/src/lib.rs +++ b/crates/socket-patch-node/src/lib.rs @@ -15,11 +15,11 @@ 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_core::api::client::PatchApi; use socket_patch_core::hosted::memory::{ - self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, - SelectOptions, SessionBuilder, TreeEntryInput, + self as hosted_memory, EngineError, HostedScanOptions, HostedScanOutput, PresentKind, SelectOptions, + SessionBuilder, TreeEntryInput, }; +use socket_patch_core::api::client::PatchApi; use tokio_util::sync::CancellationToken; use provider::{JsPatchApi, ProviderRefs}; From 5dcf9882aa6b43922a99d14f5b72dd48d80d25b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 23:08:46 +0000 Subject: [PATCH 4/7] Read the patch stores through one Ledgers view and ProjectContext `socket_patch_core::ledgers` holds the one owner-precedence rule for the manifest, the vendor ledger and the hosted redirect ledger (manifest > vendored > hosted by ledger key; a manifest key claims every vendor entry filed under it or naming it as base purl) and the views every reader derives from it: `owned` (one group per owner key, losers as alternates), `listed` (every copy worth showing), `matching` (remove/rollback identifiers) and `hosted_vendored_overlap`. It replaces list's combined_entries, fold_vendor_records / vendor_record_is_unowned, scan's merge_ledger_records_for_updates, vex_sources' build_candidates and overlap_from_states, and rollback/remove's per-store matching loops. `LoadedLedgers::load` loads the three stores once, each with its own outcome so every caller keeps its error posture. `commands::context::ProjectContext` lazily loads the stores, the lockfile inventory and the wiring discovery at most once per run; scan's discovery phase, list and get read through it. `get`'s installed-version narrowing now reuses scan's lockfile and vendored-ledger supplements instead of its own inventory and ledger reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- CHANGELOG.md | 15 + .../socket-patch-cli/src/commands/context.rs | 91 +++ crates/socket-patch-cli/src/commands/get.rs | 30 +- crates/socket-patch-cli/src/commands/list.rs | 174 +++--- crates/socket-patch-cli/src/commands/mod.rs | 101 ++-- .../socket-patch-cli/src/commands/remove.rs | 34 +- .../socket-patch-cli/src/commands/rollback.rs | 29 +- .../src/commands/scan/discovery.rs | 96 ++-- .../socket-patch-cli/src/commands/scan/mod.rs | 131 ++--- crates/socket-patch-cli/src/commands/setup.rs | 2 +- crates/socket-patch-cli/src/commands/vex.rs | 14 +- .../src/commands/vex_sources.rs | 93 +-- crates/socket-patch-core/src/ledgers.rs | 541 ++++++++++++++++++ crates/socket-patch-core/src/lib.rs | 1 + 14 files changed, 935 insertions(+), 417 deletions(-) create mode 100644 crates/socket-patch-cli/src/commands/context.rs create mode 100644 crates/socket-patch-core/src/ledgers.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a44ba3..bf8cbbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1590,6 +1590,21 @@ into the new version's section — see docs/releasing.md. ### Changed +- **One owner rule for the patch stores.** `list`, `vex`, `scan`'s + `updates[]`, `setup --check`, `rollback` and `remove` now read the + manifest, the vendor ledger and the hosted redirect ledger through one + view (`socket_patch_core::ledgers`) with one precedence: manifest, then + vendor ledger, then redirect ledger, by ledger key; a manifest key claims + every vendor entry filed under it or naming it as base purl. Visible + differences: when both ledgers record the same purl, `scan`'s + `updates[].oldUuid` now names the vendor entry's patch (was the redirect + record's); `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); `setup --check` no longer folds + a detached vendor entry whose base purl the manifest records. `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/crates/socket-patch-cli/src/commands/context.rs b/crates/socket-patch-cli/src/commands/context.rs new file mode 100644 index 00000000..55f2ecf9 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/context.rs @@ -0,0 +1,91 @@ +//! 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. +//! +//! 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. + +use std::path::PathBuf; + +use socket_patch_core::ledgers::{Ledgers, LoadedLedgers}; +use socket_patch_core::vendor::lock_inventory::{LockfileEntry, 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, + 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, + 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( + &self.common.cwd, + ) + .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(self.common, &self.common.cwd)) + .await + } +} diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 40369f8f..f7ea1735 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1491,9 +1491,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 @@ -1532,24 +1533,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 6bbc5e76..74b08918 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,11 +1,10 @@ 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::{RedirectState, REDIRECT_STATE_REL}; 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::{ @@ -18,25 +17,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 redirect-ledger record: `scan --mode hosted` records its - /// patches ONLY in `.socket/vendor/redirect-state.json` and never - /// writes the manifest. - 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 ledger-sourced record — the shared @@ -58,52 +53,36 @@ struct ListEntry<'a> { source: Source, } -/// 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 / -/// `BTreeMap` redirect ledger) 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 from all three stores +/// ([`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), sorted by PURL then +/// [`display_rank`]. 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>, redirect: Option<&'a RedirectState>, - 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, - })); - } - if let Some(redirect) = redirect { - entries.extend(redirect.records.iter().map(|(purl, record)| ListEntry { - purl, - record, - source: Source::Hosted, - })); - } - 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, - }) - })); - } - entries.sort_by(|a, b| a.purl.cmp(b.purl).then(a.source.cmp(&b.source))); + let ledgers = socket_patch_core::ledgers::Ledgers { + manifest, + vendor, + redirect, + }; + let mut entries: Vec> = ledgers + .listed() + .into_iter() + .map(|l| ListEntry { + purl: l.key, + record: l.record, + source: l.store, + }) + .collect(); + entries.sort_by(|a, b| { + a.purl + .cmp(b.purl) + .then(display_rank(a.source).cmp(&display_rank(b.source))) + }); entries } @@ -312,8 +291,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` @@ -327,7 +310,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; @@ -347,35 +330,28 @@ pub async fn run(args: ListArgs) -> i32 { // Under --json a corrupt redirect ledger rides the envelope's // `warnings[]` (stdout is the machine channel; a stderr-only warning // would vanish for JSON consumers), the same split `update` uses. - let project_root = args.common.project_root(); let mut warnings: Vec = Vec::new(); - let redirect_state = - 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 redirect_state = 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 vendor_state = - crate::commands::load_vendor_state_lenient(&project_root, args.common.silent).await; + None + } + }; + let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent); // `combined_entries` folds only ledger RECORDS in (an edits-only // redirect ledger — post-takeover residue / a degraded record-fetch- // failed run — and a record-less legacy vendor entry assert no // patches), so entry emptiness is the whole exit predicate. - let entries = combined_entries( - manifest.as_ref(), - redirect_state.as_ref(), - vendor_state.as_ref().map(|s| &s.entries), - ); + let entries = combined_entries(manifest, redirect_state, vendor_state); if manifest.is_none() && entries.is_empty() { // No manifest AND no ledger records: nothing is listable anywhere. emit_error( @@ -392,7 +368,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, @@ -420,6 +396,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 redirect ledger) — the shape @@ -704,6 +681,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", @@ -752,7 +736,7 @@ mod tests { let env = build_list_envelope(&combined_entries( Some(&manifest), Some(&redirect), - 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"] @@ -786,7 +770,7 @@ mod tests { "the ledger's embedded record is the one listed: {v}" ); - let only = build_list_envelope(&combined_entries(None, None, Some(&vendor))); + let only = build_list_envelope(&combined_entries(None, 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}"); @@ -850,7 +834,11 @@ mod tests { }; assert_eq!( - listed(&combined_entries(Some(&manifest), None, Some(&vendor))), + listed(&combined_entries( + Some(&manifest), + None, + Some(&as_state(&vendor)) + )), vec![ ( "pkg:npm/left-pad@1.3.0".to_string(), @@ -867,7 +855,7 @@ mod tests { ); // No manifest at all: every fallback copy stands on its own. - let only = listed(&combined_entries(None, None, Some(&vendor))); + let only = listed(&combined_entries(None, 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 ddda23e2..53d34a9b 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; @@ -62,7 +63,7 @@ pub(crate) async fn discover_wiring( socket_patch_core::vex::discover_patched_refs_with(root, &opts).await } -/// Read-only lenient load of the hosted redirect ledger: missing → `None` +/// Read-only lenient view of a loaded hosted redirect ledger: missing → `None` /// (a fresh start); malformed → `None` with the corruption surfaced on /// stderr unless `silent`. This is the "read-only consumers may degrade a /// malformed ledger to nothing-to-consult, but must surface it" posture @@ -72,12 +73,15 @@ pub(crate) async fn discover_wiring( /// instead. Used by scan's empty-discovery `redirectState` consult; the /// main-path consult inlines the same posture so it can flush telemetry /// before the warning. -pub(crate) async fn load_redirect_state_lenient( - cwd: &Path, +pub(crate) fn redirect_state_lenient( + loaded: &Result< + Option, + socket_patch_core::patch::redirect::CorruptRedirectState, + >, silent: bool, -) -> Option { - match socket_patch_core::patch::redirect::load_redirect_state(cwd).await { - Ok(state) => state, +) -> Option<&socket_patch_core::patch::redirect::RedirectState> { + match loaded { + Ok(state) => state.as_ref(), Err(corrupt) => { if !silent { eprintln!("Warning: {corrupt}"); @@ -87,18 +91,18 @@ pub(crate) async fn load_redirect_state_lenient( } } -/// 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`. The vendor twin of -/// [`load_redirect_state_lenient`], with the same posture: a read-only +/// [`redirect_state_lenient`], with the same posture: 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 { @@ -111,57 +115,30 @@ 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, -/// `list`, and `setup --check` through [`fold_vendor_records`]), 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) - }) -} - /// Fold the vendor ledger's embedded records into a manifest view. /// Vendored mode is manifest-free (every `scan`/`get --mode vendored` entry /// carries `detached: true` plus its embedded patch `record`), so the /// ledger is the only copy of those records: verification (`setup --check`, -/// property 4) must see them exactly like manifest entries (`vex` gathers -/// them through its own gated plan, `commands::vex_sources`). A standalone -/// `vendor` entry's fallback copy folds in under the same -/// [`vendor_record_is_unowned`] rule `vex` and `list` apply — only when the -/// manifest does not cover the entry. Keyed by the ledger key; an existing -/// manifest entry wins a collision (that purl is manifest-owned and -/// verifies against the manifest's record). Ownership is judged against -/// the manifest as given, never against records folded earlier in the same -/// pass (`HashMap` order must not decide which entries fold). +/// property 4) must see them exactly like manifest entries. Folds exactly +/// the entries the shared owner rule +/// ([`socket_patch_core::ledgers::Ledgers::owned`]) gives the vendor ledger +/// — keyed by the ledger key; an entry the manifest claims (its key or base +/// purl) stays behind the manifest's record. Record-less legacy entries +/// never fold. pub(crate) fn fold_vendor_records( manifest: &mut socket_patch_core::manifest::schema::PatchManifest, - entries: &std::collections::HashMap, + vendor: &socket_patch_core::vendor::VendorState, ) { - let folded: Vec<(String, socket_patch_core::manifest::schema::PatchRecord)> = entries - .iter() - .filter(|(key, entry)| { - vendor_record_is_unowned(key, entry, Some(&*manifest)) - && !manifest.patches.contains_key(key.as_str()) - }) - .filter_map(|(key, entry)| Some((key.clone(), entry.record.clone()?))) + let ledgers = socket_patch_core::ledgers::Ledgers { + manifest: Some(&*manifest), + vendor: Some(vendor), + redirect: None, + }; + let folded: Vec<(String, socket_patch_core::manifest::schema::PatchRecord)> = ledgers + .owned() + .into_iter() + .filter(|o| o.store == socket_patch_core::ledgers::Store::Vendored) + .filter_map(|o| Some((o.key.to_string(), o.record?.clone()))) .collect(); manifest.patches.extend(folded); } @@ -201,11 +178,9 @@ mod vendor_record_fold_tests { .expect("vendor entry fixture deserializes") } - /// `setup --check`'s fold follows the rule `vex` attests by: detached - /// records always fold (a manifest entry wins its own key), a standalone - /// `vendor` entry's fallback copy folds only when the manifest covers - /// neither its key nor its base purl, and a record-less legacy entry - /// never folds. Ownership is judged against the manifest as given. + /// `setup --check`'s fold follows the shared owner rule: an entry folds + /// only when the manifest covers neither its key nor its base purl, and + /// a record-less legacy entry never folds. #[test] fn standalone_vendor_fallback_folds_only_when_uncovered() { let mut entries = HashMap::new(); @@ -230,6 +205,10 @@ mod vendor_record_fold_tests { entry("pkg:npm/legacy@1.0.0", "u-legacy", false, false), ); + let entries = socket_patch_core::vendor::VendorState { + entries, + ..socket_patch_core::vendor::VendorState::new() + }; let mut manifest = PatchManifest::default(); manifest .patches diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 9ce95401..878bd0fe 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -24,30 +24,26 @@ 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 } /// Hosted redirect records matching a remove identifier, sorted. fn hosted_records_matching(state: &RedirectState, identifier: &str) -> Vec { - let mut matches: Vec = state - .records - .iter() - .filter(|(purl, rec)| patch_matches(purl, &rec.uuid, identifier)) - .map(|(purl, _)| purl.clone()) - .collect(); - matches.sort(); - matches + socket_patch_core::ledgers::Ledgers { + redirect: Some(state), + ..Default::default() + } + .matching(identifier) + .hosted } /// Drop every manifest entry matching `identifier` except `exclusions` diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index 14806e79..69543149 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -1376,26 +1376,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: redirect_state_result.as_ref().ok().and_then(Option::as_ref), + }; 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; - } - } - for (purl, uuid) in &redirect_records { - if patch_matches(purl, uuid, id) { - hosted_scope.insert(purl.clone()); - matched = true; - } - } + let found = ledgers.matching(id); + let matched = !found.is_empty(); + manifest_scope.extend(found.manifest); + vendor_scope.extend(found.vendor.into_iter().map(|(k, _)| k)); + hosted_scope.extend(found.hosted); if !matched { let hint = if id.starts_with("pkg:") || looks_like_uuid(id) { String::new() diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index b70af594..bc69e65f 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -31,18 +31,18 @@ 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, } pub(crate) use socket_patch_core::vendor::lock_inventory::unsupported_layout_warnings; @@ -58,19 +58,19 @@ pub(crate) use socket_patch_core::vendor::lock_inventory::unsupported_layout_war /// 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; } @@ -81,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; } @@ -91,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 } @@ -134,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, @@ -369,11 +369,12 @@ pub(super) async fn preverify_vendor_baselines( /// consults. Hosted mode records purl→uuid ONLY in /// `.socket/vendor/redirect-state.json` 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 > redirect ledger > vendor ledger (matching VEX's -/// candidate merge in `commands::vex_sources`), then the lockfile's hosted -/// pins (`hosted_pins`, uuid only). Vendor entries are keyed by their -/// manifest-form ledger key (`detect_updates` bridges the spellings); a +/// vendored project's `updates[]` would always be empty. One record per +/// owner key under the shared rule +/// ([`socket_patch_core::ledgers::Ledgers::owned`]: manifest > vendor +/// ledger > redirect ledger), then the lockfile's hosted pins +/// (`hosted_pins`, uuid only) for keys no store owns. 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>( @@ -382,44 +383,33 @@ pub(super) fn merge_ledger_records_for_updates<'a>( vendor: Option<&VendorState>, hosted_pins: &[(String, String)], ) -> Option> { - let redirect_records = redirect.map(|s| &s.records).filter(|r| !r.is_empty()); - let vendor_entries = vendor.map(|s| &s.entries).filter(|e| !e.is_empty()); - if redirect_records.is_none() && vendor_entries.is_none() && hosted_pins.is_empty() { + use socket_patch_core::ledgers::{uuid_only_record, Ledgers, Store}; + let redirect = redirect.filter(|s| !s.records.is_empty()); + let vendor = vendor.filter(|s| !s.entries.is_empty()); + if redirect.is_none() && vendor.is_none() && hosted_pins.is_empty() { return manifest.map(Cow::Borrowed); } let mut merged = manifest.cloned().unwrap_or_default(); - for (purl, record) in redirect_records.into_iter().flatten() { - merged - .patches - .entry(purl.clone()) - .or_insert_with(|| record.clone()); - } - for (purl, entry) in vendor_entries.into_iter().flatten() { - merged.patches.entry(purl.clone()).or_insert_with(|| { - entry.record.clone().unwrap_or_else(|| PatchRecord { - uuid: entry.uuid.clone(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }) - }); + let ledgers = Ledgers { + manifest, + vendor, + redirect, + }; + for owned in ledgers.owned() { + if owned.store == Store::Manifest { + continue; + } + let record = owned + .record + .cloned() + .unwrap_or_else(|| uuid_only_record(owned.uuid)); + merged.patches.insert(owned.key.to_string(), record); } for (purl, uuid) in hosted_pins { merged .patches .entry(purl.clone()) - .or_insert_with(|| PatchRecord { - uuid: uuid.clone(), - exported_at: String::new(), - files: HashMap::new(), - vulnerabilities: HashMap::new(), - description: String::new(), - license: String::new(), - tier: String::new(), - }); + .or_insert_with(|| uuid_only_record(uuid)); } Some(Cow::Owned(merged)) } @@ -959,9 +949,9 @@ mod tests { #[test] fn manifest_entry_wins_a_collision_with_a_ledger_record() { - // A PURL present in every store is manifest-owned (same precedence as - // VEX's candidate merge): the manifest's uuid is the "old" side; - // between the ledgers, the redirect record wins. + // A PURL present in every store is manifest-owned (the shared owner + // rule, `socket_patch_core::ledgers`): the manifest's uuid is the + // "old" side; between the ledgers, the vendor entry wins. let manifest = crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-manifest")]); let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]); @@ -974,7 +964,7 @@ mod tests { assert_eq!(updates[0].old_uuid, "uuid-manifest"); let merged = merge_ledger_records_for_updates(None, Some(&ledger), Some(&vendor), &[]); let updates = detect_updates(merged.as_deref(), &pkgs); - assert_eq!(updates[0].old_uuid, "uuid-ledger"); + assert_eq!(updates[0].old_uuid, "uuid-vendor"); } #[test] diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 20ab9896..836bc47f 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -15,13 +15,12 @@ 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, }; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; -use socket_patch_core::utils::purl::{normalize_purl, purl_name_version, strip_purl_qualifiers}; +use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; use socket_patch_core::vendor::VendorState; use socket_patch_core::vex::discover::{LedgerLiveness, WiringMode}; use std::collections::{HashMap, HashSet}; @@ -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; @@ -864,58 +866,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() { - 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)); - } - if !redirect.records.is_empty() { - let redirect_purls: std::collections::BTreeSet = - redirect.records.keys().map(|p| canon(p)).collect(); - return redirect_purls - .intersection(&vendor_purls) - .cloned() - .collect(); - } - // The records map can be EMPTY while the ledger still asserts stale lock - // wiring (every per-uuid record fetch failed: `record_fetch_failed`), so - // fall back to matching the vendored purls against the recorded edit - // keys — npm `node_modules/` (possibly nested), pnpm/yarn/cargo/uv - // `@` (vlt `@~`), bun - // `/`, gem/composer/pypi bare ``. Name-level matching - // can over-claim across versions, but `classify_overlap_takeover` still - // requires the live lock to prove one side before anything is reported. - if redirect.edits.is_empty() { - return Vec::new(); + socket_patch_core::ledgers::Ledgers { + manifest: None, + vendor: Some(vendor), + redirect, } - vendor_purls - .into_iter() - .filter(|purl| { - let Some((name, version)) = purl_name_version(strip_purl_qualifiers(purl)) else { - return false; - }; - redirect - .edits - .iter() - .filter_map(|e| e.key.as_deref()) - .any(|key| { - key == name - || key == format!("{name}@{version}") - || key.starts_with(&format!("{name}@{version}~")) - || key.ends_with(&format!("/{name}")) - }) - }) - .collect() + .hosted_vendored_overlap() } /// The overlapping PURLs split by which mode the LIVE lockfile actually wires @@ -1641,6 +1597,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(); @@ -1689,7 +1649,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); @@ -1720,9 +1680,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; @@ -1881,12 +1841,11 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // `redirectState` rides the empty-discovery envelope too // (same rule as the ≥1-package path). `wiringLive` is empty // by construction: this run covered zero packages. - let redirect_state = crate::commands::load_redirect_state_lenient( - &args.common.cwd, + let redirect_state = crate::commands::redirect_state_lenient( + &ctx.loaded().await.redirect, args.common.silent, - ) - .await; - if let Some(state) = redirect_state_json(redirect_state.as_ref(), &[]) { + ); + if let Some(state) = redirect_state_json(redirect_state, &[]) { result["redirectState"] = state; } } @@ -2142,7 +2101,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 and vendored modes record their patches ONLY in their ledgers, // so both ledgers' purl→uuid records are folded into update detection // (otherwise their `updates[]` would stay empty). A malformed redirect @@ -2152,15 +2111,15 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { // BEFORE the engine (empty discovery, nothing downloadable, a // detail-fetch failure) print it via `warn_unreported_corrupt_ledger`. let (redirect_state, hosted_corrupt_ledger) = if hosted { - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { - Ok(state) => (state, None), + match &ctx.loaded().await.redirect { + Ok(state) => (state.as_ref(), None), Err(corrupt) => (None, Some(corrupt.to_string())), } } else { - // `load_redirect_state_lenient`, with the scan event flushed before + // `redirect_state_lenient`, with the scan event flushed before // its warning (possibly this run's first write since the event). - match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await { - Ok(state) => (state, None), + match &ctx.loaded().await.redirect { + Ok(state) => (state.as_ref(), None), Err(corrupt) => { if !args.common.silent { telemetry.flush().await; @@ -2172,21 +2131,20 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { }; // The hosted pins the lockfiles wire count too: the lockfile is the // record of a hosted redirect even where no ledger was committed. - let hosted_pins: Vec<(String, String)> = - if args.common.is_global() { - Vec::new() - } else { - crate::commands::discover_wiring(&args.common, &args.common.cwd) - .await - .refs - .into_iter() - .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) - .map(|r| (r.purl, r.uuid)) - .collect() - }; + let hosted_pins: Vec<(String, String)> = if args.common.is_global() { + Vec::new() + } else { + ctx.discovery() + .await + .refs + .iter() + .filter(|r| r.mode == socket_patch_core::vex::discover::WiringMode::Hosted) + .map(|r| (r.purl.clone(), r.uuid.clone())) + .collect() + }; let update_manifest = merge_ledger_records_for_updates( - existing_manifest.as_ref(), - redirect_state.as_ref(), + existing_manifest, + redirect_state, vendor_state.as_ref().ok(), &hosted_pins, ); @@ -2264,10 +2222,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; } } @@ -2308,7 +2266,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| { @@ -2911,7 +2869,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 ({HOSTED_WIRING_RETAINED}): {}", @@ -2940,6 +2898,7 @@ async fn run_scan(mut args: ScanArgs, telemetry: &mut PendingTelemetry) -> i32 { #[cfg(test)] mod tests { use super::*; + use socket_patch_core::utils::purl::purl_name_version; #[test] fn project_dirs_resolve_directories_and_globs() { diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 63106bdc..300ecbd5 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -1210,7 +1210,7 @@ async fn append_patch_consistency_entries( let mut manifest = manifest.unwrap_or_default(); let ledger = match socket_patch_core::vendor::load_state(&common.cwd).await { Ok(state) => { - crate::commands::fold_vendor_records(&mut manifest, &state.entries); + crate::commands::fold_vendor_records(&mut manifest, &state); Ok(state) } Err(e) => { diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 4ece01fb..b7943805 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -21,7 +21,6 @@ use std::path::{Path, PathBuf}; use clap::Args; 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::{track_vex_failed, track_vex_generated}; use socket_patch_core::vendor::state::VendorState; @@ -1054,7 +1053,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 @@ -1068,8 +1073,7 @@ async fn generate_vex_from_manifest_path_inner( // wiring liveness gates them), so a MALFORMED one is a hard error: // attesting with its contents silently dropped would produce a false — // or silently partial — document. A missing ledger is simply empty. - let redirect = match socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await - { + let redirect = match redirect { Ok(state) => state, Err(corrupt) => { // Not core's Display: that text ("... so it will not be @@ -1084,7 +1088,7 @@ async fn generate_vex_from_manifest_path_inner( return Err(fail(common, "redirect_ledger_corrupt", message).await); } }; - 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 0d6c08d5..3a185384 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-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs new file mode 100644 index 00000000..2fda66a5 --- /dev/null +++ b/crates/socket-patch-core/src/ledgers.rs @@ -0,0 +1,541 @@ +//! 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 redirect ledger +//! (`.socket/vendor/redirect-state.json`). +//! +//! 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 ledger only ever joins as the +//! last store, so removing it is a matter of dropping [`Store::Hosted`]. + +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, purl_name_version, 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, + /// `.socket/vendor/redirect-state.json` (hosted mode, legacy). + 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, + /// Absent is `Ok(None)`. + pub redirect: Result, CorruptRedirectState>, +} + +impl LoadedLedgers { + /// Load the manifest at `manifest_path` and both ledgers 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 ledger holds a record 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> { + /// The vendored entry manifest key `key` claims (rule 1 of the module + /// docs): the entry filed under `key`, else the lowest-keyed entry + /// whose `base_purl` is `key`. + pub fn claimed_entry(&self, key: &str) -> Option<(&'a String, &'a VendorEntry)> { + let entries = &self.vendor?.entries; + entries.get_key_value(key).or_else(|| { + entries + .iter() + .filter(|(_, e)| e.base_purl == key) + .min_by(|a, b| a.0.cmp(b.0)) + }) + } + + /// 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(); + if let Some(manifest) = self.manifest { + for key in sorted_keys(&manifest.patches) { + let record = &manifest.patches[key]; + let vendor = self.claimed_entry(key); + 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( + self.vendor + .map(|v| sorted_keys(&v.entries)) + .unwrap_or_default() + .into_iter() + .filter(|k| vendor.is_none_or(|(vk, _)| vk != *k)) + .filter_map(|k| { + let e = &self.vendor?.entries[k]; + (self.claimant(k, e) == Some(key.as_str())) + .then_some(e.record.as_ref()) + .flatten() + }), + ); + 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 and the vendored ledger claim, canonical + /// (qualifiers dropped, percent-decoded), sorted: each is stale in + /// exactly one ledger, which only the live lockfile can tell. With an + /// edits-only hosted ledger (every record fetch failed), the vendored + /// purls whose name (and version) a recorded edit key names. + 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)); + } + if !redirect.records.is_empty() { + let redirect_purls: std::collections::BTreeSet = + redirect.records.keys().map(|p| canon(p)).collect(); + return redirect_purls + .intersection(&vendor_purls) + .cloned() + .collect(); + } + // The records map can be EMPTY while the ledger still asserts stale lock + // wiring (every per-uuid record fetch failed: `record_fetch_failed`), so + // fall back to matching the vendored purls against the recorded edit + // keys — npm `node_modules/` (possibly nested), pnpm/yarn/cargo/uv + // `@` (vlt `@~`), bun + // `/`, gem/composer/pypi bare ``. Name-level matching + // can over-claim across versions, but the CLI's `classify_overlap_takeover` still + // requires the live lock to prove one side before anything is reported. + if redirect.edits.is_empty() { + return Vec::new(); + } + vendor_purls + .into_iter() + .filter(|purl| { + let Some((name, version)) = purl_name_version(strip_purl_qualifiers(purl)) else { + return false; + }; + redirect + .edits + .iter() + .filter_map(|e| e.key.as_deref()) + .any(|key| { + key == name + || key == format!("{name}@{version}") + || key.starts_with(&format!("{name}@{version}~")) + || key.ends_with(&format!("/{name}")) + }) + }) + .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 6022b4e8..f0c79fcf 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod constants; pub mod crawlers; pub mod hash; pub mod hosted; +pub mod ledgers; pub mod manifest; pub mod package_json; pub mod patch; From 04b3d96d28952301354f28e7ead4aee3a0d901e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:11:35 +0000 Subject: [PATCH 5/7] Index vendor-ledger claims once per view `Ledgers::owned` re-sorted and scanned every vendor key for each manifest key. Group the vendor entries under their claiming manifest key in one pass over the sorted ledger (`claims`), so each view is O(V log V + M) instead of O(M * V log V). Same ordering and alternates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- crates/socket-patch-core/src/ledgers.rs | 59 +++++++++++++++---------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs index 2fda66a5..169bd7b0 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -131,17 +131,32 @@ fn sorted_keys(map: &HashMap) -> Vec<&String> { } impl<'a> Ledgers<'a> { - /// The vendored entry manifest key `key` claims (rule 1 of the module - /// docs): the entry filed under `key`, else the lowest-keyed entry - /// whose `base_purl` is `key`. - pub fn claimed_entry(&self, key: &str) -> Option<(&'a String, &'a VendorEntry)> { - let entries = &self.vendor?.entries; - entries.get_key_value(key).or_else(|| { - entries - .iter() - .filter(|(_, e)| e.base_purl == key) - .min_by(|a, b| a.0.cmp(b.0)) - }) + /// 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` @@ -169,26 +184,24 @@ impl<'a> Ledgers<'a> { 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 vendor = self.claimed_entry(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( - self.vendor - .map(|v| sorted_keys(&v.entries)) - .unwrap_or_default() - .into_iter() - .filter(|k| vendor.is_none_or(|(vk, _)| vk != *k)) - .filter_map(|k| { - let e = &self.vendor?.entries[k]; - (self.claimant(k, e) == Some(key.as_str())) - .then_some(e.record.as_ref()) - .flatten() - }), + 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 { From f4e8d549ce92471614cba16cba85980fd55eb759 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:24:10 +0000 Subject: [PATCH 6/7] Return typed warnings from the hosted engine; JSON in hosted::render The engine's own warnings (rush repo-state, pnpm trustLockfile, npm allow-remote, vlt artifact-unverifiable, record_fetch_failed, the in-memory takeover refusal) were built as serde_json values inside orchestration. They are now RewriteWarning values; the new hosted::render module holds the only JSON spelling (warnings, skipped entries, the nested redirect block), consumed by the disk adapter and the in-memory engine. No output change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- .../src/commands/scan/hosted.rs | 35 ++-- .../src/commands/scan/hosted/vlt.rs | 10 +- crates/socket-patch-core/src/hosted/engine.rs | 191 +++++++++--------- .../src/hosted/memory/mod.rs | 31 +-- .../src/hosted/memory/stages.rs | 22 +- crates/socket-patch-core/src/hosted/mod.rs | 1 + crates/socket-patch-core/src/hosted/render.rs | 43 ++++ crates/socket-patch-core/src/hosted/vlt.rs | 10 +- 8 files changed, 202 insertions(+), 141 deletions(-) create mode 100644 crates/socket-patch-core/src/hosted/render.rs diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6ae85979..fba81ad9 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -26,7 +26,6 @@ pub(crate) mod vlt; pub(crate) use vlt::rollback_heal as vlt_rollback_heal; pub(crate) use vlt::takeover_heal as vlt_takeover_heal; -pub(crate) use socket_patch_core::hosted::engine::redirect_json_block; #[cfg(test)] pub(crate) use socket_patch_core::hosted::guidance::{ npm_allow_remote_already_detail, npm_allow_remote_configured_detail, @@ -35,11 +34,12 @@ pub(crate) use socket_patch_core::hosted::guidance::{ 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, + pnpm_trust_workspace_unreadable_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, + TrustPlan, }; #[cfg(test)] pub(crate) use socket_patch_core::hosted::ledger::{rebase_vlt_edits, REBASE_KINDS}; +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 @@ -667,7 +667,11 @@ pub(crate) async fn run_redirect_selected( // (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()); + return refuse( + common, + scan_result.take(), + &engine::bun_lockb_symlink_refusal(), + ); } // vlt artifact preflight: before any takeover or rewrite (dry runs @@ -926,7 +930,7 @@ 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(); + 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 @@ -1216,12 +1220,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 = engine::rewrite_warnings_json(&rewrite.warnings); - warnings.extend(vlt_preflight.warnings.iter().cloned()); - warnings.extend(record_warnings.iter().cloned()); - warnings.extend(done.rush_warnings.iter().cloned()); - warnings.extend(done.pnpm_warnings.iter().cloned()); - warnings.extend(done.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()); @@ -1237,7 +1243,10 @@ pub(crate) async fn run_redirect_selected( let redirect = redirect_json_block( confirmed.len(), done.rewritten.clone(), - skipped.iter().map(SkippedPatch::to_json).collect(), + skipped + .iter() + .map(socket_patch_core::hosted::render::skipped_json) + .collect(), warnings, common.dry_run, ); @@ -2142,7 +2151,6 @@ pub(crate) fn boxed_run_redirect_selected<'a>( #[cfg(test)] mod tests { - use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use super::{ build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, gem_stale_install_warnings, installed_stale_positive_evidence, @@ -2164,6 +2172,7 @@ mod tests { use super::{rebase_vlt_edits, REBASE_KINDS}; use super::{wheel_metadata_concurrency, WHEEL_METADATA_CONCURRENCY}; use socket_patch_core::constants::npm_family; + use socket_patch_core::hosted::engine::REDIRECT_CANDIDATE_FILES; use socket_patch_core::patch::redirect::{DepOverride, FileEdit}; use socket_patch_core::utils::concurrent::API_CONCURRENCY_ENV; 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 4034ddb8..8b8f08a5 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/vlt.rs @@ -6,14 +6,12 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use socket_patch_core::constants::npm_family::{ - VLT_HIDDEN_LOCK_REL, VLT_LOCK, -}; +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::hosted::vlt::{self as hosted_vlt, Preflight}; use socket_patch_core::patch::redirect::vlt_preflight; use socket_patch_core::patch::redirect::{vlt, DepOverride}; use socket_patch_core::vendor::lock_inventory::ProjectView; @@ -617,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" @@ -698,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-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index ebbc7f92..9436b39c 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -24,7 +24,9 @@ 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::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, @@ -44,9 +46,9 @@ use super::guidance::{ 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, + 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; @@ -166,11 +168,6 @@ impl SkippedPatch { detail: None, } } - - /// The `skipped[]` JSON entry (`{purl, uuid, reason[, detail]}`). - pub fn to_json(&self) -> serde_json::Value { - serde_json::to_value(self).expect("SkippedPatch is plain strings: serialization cannot fail") - } } /// A whole-project refusal: nothing is written. @@ -290,9 +287,7 @@ pub fn build_candidates( let token = reference .registry_override .as_ref() - .and_then(|o| { - crate::patch::redirect::grant_token_path_segment(&o.index_url, sel_uuid) - }) + .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 { @@ -412,7 +407,9 @@ impl CandidateFiles { /// The root-level Python lock names (sorted). async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(cwd) => crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default(), + ProjectView::Disk(cwd) => { + crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default() + } ProjectView::Memory(project) => project .children("") .into_iter() @@ -626,9 +623,9 @@ pub struct Rewritten { 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, + 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. @@ -643,7 +640,10 @@ pub struct Rewritten { /// symbolic link (absent to the planner, refused by [`guard`]). fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, bool) { match view { - ProjectView::Disk(cwd) => (read_workspace_for_trust(&cwd.join(PNPM_WORKSPACE_REL)), false), + ProjectView::Disk(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), @@ -844,26 +844,37 @@ pub async fn rewrite( // 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(); + 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(serde_json::json!({ - "code": "redirect_rush_repo_state_stale", - "detail": - "pnpm-lock.yaml was edited outside `rush update`; if \ + 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 redirect 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); + 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 @@ -933,8 +944,8 @@ fn pnpm_trust( overrides: &[DepOverride], takeover_previews: &[TakeoverPreview], options: &RewriteOptions<'_>, -) -> (Vec, ConfigWrite, bool, bool) { - let mut pnpm_warnings: Vec = Vec::new(); +) -> (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; @@ -987,7 +998,12 @@ fn pnpm_trust( pnpm_lock_texts.push(text); } if pnpm_lock_texts.is_empty() { - return (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked); + 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 @@ -1097,9 +1113,9 @@ fn pnpm_trust( } else { "" }; - pnpm_warnings.push(serde_json::json!({ - "code": "redirect_pnpm_trust_lockfile", - "detail": format!( + 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 \ @@ -1109,8 +1125,13 @@ fn pnpm_trust( the patched files.", detail.trim_end_matches('.') ), - })); - (pnpm_warnings, trust_config_write, pnpm_rerun_only, workspace_symlinked) + )); + ( + pnpm_warnings, + trust_config_write, + pnpm_rerun_only, + workspace_symlinked, + ) } /// npm >= 12 ships `allow-remote=none`: it refuses (EALLOWREMOTE) every @@ -1141,8 +1162,8 @@ fn npm_allow_remote( overrides: &[DepOverride], takeover_previews: &[TakeoverPreview], options: &RewriteOptions<'_>, -) -> (Vec, ConfigWrite) { - let mut npm_warnings: Vec = Vec::new(); +) -> (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 @@ -1188,32 +1209,31 @@ fn npm_allow_remote( 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) + 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(serde_json::json!({ - "code": "redirect_npm_allow_remote", - "detail": detail, - })); + npm_warnings.push(warning("redirect_npm_allow_remote", detail)); (npm_warnings, npmrc_config_write) } @@ -1419,7 +1439,11 @@ fn file_ecosystem(rel: &str) -> Option<&'static str> { /// 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 { +pub fn guard( + view: &ProjectView<'_>, + done: &Rewritten, + candidates: &[Candidate], +) -> Option { if done.workspace_symlinked { return Some(symlink_refusal(PNPM_WORKSPACE_REL)); } @@ -1463,46 +1487,23 @@ pub fn guard(view: &ProjectView<'_>, done: &Rewritten, candidates: &[Candidate]) /// The `record_fetch_failed` warning for a confirmed redirect whose patch /// record could not be fetched. -pub fn record_fetch_failed_warning(purl: &str) -> serde_json::Value { - serde_json::json!({ - "code": "record_fetch_failed", - "detail": format!( +pub fn record_fetch_failed_warning(purl: &str) -> RewriteWarning { + warning( + "record_fetch_failed", + format!( "{purl} redirected, but its patch record could not be fetched; \ it will be missing from VEX until `socket-patch scan --mode \ hosted` is re-run" ), - }) + ) } -/// The rewriters' own 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, - }) +/// A `{code, detail}` warning. +pub fn warning(code: &str, detail: impl Into) -> RewriteWarning { + RewriteWarning { + code: code.to_string(), + detail: detail.into(), + } } #[cfg(test)] diff --git a/crates/socket-patch-core/src/hosted/memory/mod.rs b/crates/socket-patch-core/src/hosted/memory/mod.rs index b19a91ea..f36d0115 100644 --- a/crates/socket-patch-core/src/hosted/memory/mod.rs +++ b/crates/socket-patch-core/src/hosted/memory/mod.rs @@ -434,7 +434,9 @@ async fn engine( } let (entries, unsupported) = inventory_project_diagnosed_in(&ProjectView::Memory(project)).await; - for (code, detail) in crate::vendor::lock_inventory::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); @@ -659,7 +661,7 @@ async fn engine( } let redirect = match &state.error { Some(_) => serde_json::json!({ "mode": "hosted" }), - None => crate::hosted::engine::redirect_json_block( + None => crate::hosted::render::redirect_json_block( 0, Vec::new(), Vec::new(), @@ -740,7 +742,7 @@ fn finish_root( } = done; let root = state.root.clone(); let mut record_map: BTreeMap = BTreeMap::new(); - 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) { @@ -850,16 +852,19 @@ fn finish_root( .or_insert_with(|| (root.clone(), bytes)); } - let mut redirect_warnings: Vec = - crate::hosted::engine::rewrite_warnings_json(&rewrite.warnings); - redirect_warnings.extend(record_warnings); - redirect_warnings.extend(rush_warnings); - redirect_warnings.extend(pnpm_warnings); - redirect_warnings.extend(npm_warnings); - redirect_warnings.extend(pre_warnings); - let skipped_values: Vec = - skipped.iter().map(SkippedPatch::to_json).collect(); - let redirect = crate::hosted::engine::redirect_json_block( + // 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(crate::hosted::render::skipped_json) + .collect(); + let redirect = crate::hosted::render::redirect_json_block( confirmed.len(), rewritten, skipped_values, diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index a20717c9..a2b8d403 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -90,7 +90,7 @@ fn refuse_takeovers( project: &MemoryProject, candidates: &mut Vec, skipped: &mut Vec, - pre_warnings: &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/") @@ -115,17 +115,21 @@ fn refuse_takeovers( if refused.is_empty() { return; } - pre_warnings.push(serde_json::json!({ - "code": VENDORED_TAKEOVER_UNSUPPORTED, - "detail": format!( + 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" }, + 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, @@ -143,7 +147,7 @@ pub(crate) struct Planned { pub(crate) project: MemoryProject, pub(crate) candidates: Vec, pub(crate) skipped: Vec, - pub(crate) pre_warnings: Vec, + pub(crate) pre_warnings: Vec, pub(crate) read: CandidateFiles, /// `(artifact url, sha256)` of every pypi wheel whose metadata a /// native lock rewrite needs. @@ -185,7 +189,7 @@ pub(crate) async fn plan( &vlt_preflight.withheld_everywhere, &mut skipped, ); - let mut pre_warnings: Vec = vlt_preflight.warnings.clone(); + let mut pre_warnings = vlt_preflight.warnings.clone(); refuse_takeovers(&project, &mut candidates, &mut skipped, &mut pre_warnings); let read = if candidates.is_empty() { @@ -213,7 +217,7 @@ pub(crate) async fn plan( pub(crate) struct Rewritten { pub(crate) project: MemoryProject, pub(crate) skipped: Vec, - pub(crate) pre_warnings: Vec, + pub(crate) pre_warnings: Vec, pub(crate) done: engine::Rewritten, } diff --git a/crates/socket-patch-core/src/hosted/mod.rs b/crates/socket-patch-core/src/hosted/mod.rs index 6b6932f7..6fb0ce4a 100644 --- a/crates/socket-patch-core/src/hosted/mod.rs +++ b/crates/socket-patch-core/src/hosted/mod.rs @@ -18,4 +18,5 @@ pub mod engine; pub mod guidance; pub mod ledger; 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 index c6ff5e2f..32ef32b4 100644 --- a/crates/socket-patch-core/src/hosted/vlt.rs +++ b/crates/socket-patch-core/src/hosted/vlt.rs @@ -66,7 +66,7 @@ pub struct Preflight { pub passed: BTreeSet, /// Artifact bytes by URL, for the heal's no-record comparison. pub artifacts: BTreeMap>, - pub warnings: Vec, + pub warnings: Vec, } /// The files `vlt_drives` and the preflight scope read: `vlt-lock.json` @@ -172,10 +172,10 @@ pub fn judge( dep.already_pinned, everywhere, ); - out.warnings.push(serde_json::json!({ - "code": ARTIFACT_UNVERIFIABLE, - "detail": redact_grant_token(&detail, &dep.artifact_url, &dep.patch_uuid), - })); + 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()); From f39ed1cd946d309220b77e1d01643cb26f2506dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:36:12 +0000 Subject: [PATCH 7/7] Read the lock set and discovery through one per-run snapshot ProjectView gains a Snapshot variant: the disk under a read-through cache (DiskSnapshot), so each lock or config file is read at most once per run and every reader sees the same bytes; probes that are not content reads still go to the disk. Lockfile discovery's guarded reads now go through a ProjectView (discover_patched_refs_in), and ProjectContext backs both locks() and discovery() with one snapshot of --cwd. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHZ8uzdXfkG2zH8ZYDG8ju --- .../socket-patch-cli/src/commands/context.rs | 19 ++- crates/socket-patch-cli/src/commands/mod.rs | 17 +- crates/socket-patch-core/src/hosted/engine.rs | 34 ++-- crates/socket-patch-core/src/hosted/vlt.rs | 10 +- .../src/utils/cargo_workspace.rs | 5 +- .../src/vendor/lock_inventory/cargo.rs | 6 +- .../src/vendor/lock_inventory/mod.rs | 8 +- .../src/vendor/lock_inventory/pnpm.rs | 6 +- .../src/vendor/lock_inventory/pypi.rs | 5 +- .../src/vendor/lock_inventory/view.rs | 150 +++++++++++++++++- .../socket-patch-core/src/vex/discover/mod.rs | 36 ++++- crates/socket-patch-core/src/vex/mod.rs | 5 +- 12 files changed, 256 insertions(+), 45 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/context.rs b/crates/socket-patch-cli/src/commands/context.rs index 55f2ecf9..3ae3b005 100644 --- a/crates/socket-patch-cli/src/commands/context.rs +++ b/crates/socket-patch-cli/src/commands/context.rs @@ -5,14 +5,21 @@ //! 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. +//! 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::{LockfileEntry, UnsupportedNpmLayout}; +use socket_patch_core::vendor::lock_inventory::{ + DiskSnapshot, LockfileEntry, ProjectView, UnsupportedNpmLayout, +}; use socket_patch_core::vex::discover::Discovery; use tokio::sync::OnceCell; @@ -29,6 +36,7 @@ pub(crate) struct ProjectContext<'a> { /// 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, @@ -45,6 +53,7 @@ impl<'a> ProjectContext<'a> { Self { common, root, + snapshot: DiskSnapshot::new(&common.cwd), ledgers: OnceCell::new(), locks: OnceCell::new(), discovery: OnceCell::new(), @@ -70,8 +79,8 @@ impl<'a> ProjectContext<'a> { self.locks .get_or_init(|| async { let (entries, unsupported) = - socket_patch_core::vendor::lock_inventory::inventory_project_diagnosed( - &self.common.cwd, + socket_patch_core::vendor::lock_inventory::inventory_project_diagnosed_in( + &ProjectView::Snapshot(&self.snapshot), ) .await; LockSet { @@ -85,7 +94,7 @@ impl<'a> ProjectContext<'a> { /// The lockfile wiring discovery of `--cwd` ([`super::discover_wiring`]). pub(crate) async fn discovery(&self) -> &Discovery { self.discovery - .get_or_init(|| super::discover_wiring(self.common, &self.common.cwd)) + .get_or_init(|| super::discover_wiring_in(self.common, &self.snapshot)) .await } } diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 53d34a9b..ca6c9573 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -52,15 +52,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 + } } /// Read-only lenient view of a loaded hosted redirect ledger: missing → `None` diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 9436b39c..db6d7209 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -313,7 +313,10 @@ pub fn build_candidates( /// Bun's precedence when both lock spellings are present. pub fn bun_lock_present(view: &ProjectView<'_>) -> bool { match view { - ProjectView::Disk(cwd) => cwd.join("bun.lock").exists(), + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join("bun.lock").exists(), ProjectView::Memory(project) => project.contains("bun.lock"), } } @@ -373,7 +376,7 @@ impl CandidateFiles { // (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(_) => view.read_text(rel).await.ok(), + 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()); @@ -407,9 +410,10 @@ impl CandidateFiles { /// The root-level Python lock names (sorted). async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { match view { - ProjectView::Disk(cwd) => { - crate::utils::python_lock::python_lock_paths(cwd).unwrap_or_default() - } + 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() @@ -424,7 +428,10 @@ async fn python_lock_paths(view: &ProjectView<'_>) -> Vec { /// Whether the project is a Rush monorepo (disk: `rush.json` is a file). fn rush_repo(view: &ProjectView<'_>) -> bool { match view { - ProjectView::Disk(cwd) => cwd.join("rush.json").is_file(), + 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"), } } @@ -640,7 +647,10 @@ pub struct Rewritten { /// symbolic link (absent to the planner, refused by [`guard`]). fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, bool) { match view { - ProjectView::Disk(cwd) => ( + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => ( read_workspace_for_trust(&cwd.join(PNPM_WORKSPACE_REL)), false, ), @@ -670,7 +680,10 @@ fn read_workspace(view: &ProjectView<'_>) -> (std::io::Result>, b /// [`read_npmrc_for_allow_remote`]). fn read_npmrc(view: &ProjectView<'_>) -> Result, String> { match view { - ProjectView::Disk(cwd) => read_npmrc_for_allow_remote(&cwd.join(NPMRC_REL)), + 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) => { @@ -690,7 +703,10 @@ fn read_npmrc(view: &ProjectView<'_>) -> Result, String> { /// 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) => cwd.join(RUSH_REPO_STATE_REL).is_file(), + 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), } } diff --git a/crates/socket-patch-core/src/hosted/vlt.rs b/crates/socket-patch-core/src/hosted/vlt.rs index 32ef32b4..e2e83aaf 100644 --- a/crates/socket-patch-core/src/hosted/vlt.rs +++ b/crates/socket-patch-core/src/hosted/vlt.rs @@ -25,7 +25,10 @@ pub const WITHHELD_REASON: &str = ARTIFACT_UNVERIFIABLE; /// megabytes and is never read into the rewriter's input. pub fn install_state_present(view: &ProjectView<'_>) -> bool { match view { - ProjectView::Disk(cwd) => { + 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 { @@ -49,7 +52,10 @@ pub fn install_state_present(view: &ProjectView<'_>) -> bool { /// Whether `bun.lockb` is present (disk: `exists`, which follows links). pub(crate) fn bun_lockb_present(view: &ProjectView<'_>) -> bool { match view { - ProjectView::Disk(cwd) => cwd.join(BUN_LOCKB).exists(), + ProjectView::Disk(cwd) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { + root: cwd, .. + }) => cwd.join(BUN_LOCKB).exists(), ProjectView::Memory(project) => project.contains(BUN_LOCKB), } } 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 f334b46e..cfec8fbb 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/cargo.rs @@ -42,7 +42,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 8858b27d..6d56c507 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; @@ -77,7 +77,7 @@ pub(crate) use self::npm_family::inventory_npm_lock; pub(crate) use self::pnpm::pnpm_registry_key; pub(crate) use self::pypi::pipfile_lock_entries; pub use self::recover::recover_lock_entry; -pub use self::view::{MemoryEntry, MemoryProject, ProjectView}; +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 @@ -210,9 +210,7 @@ pub struct UnsupportedNpmLayout { /// (`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)> { +pub fn unsupported_layout_warnings(unsupported: &[UnsupportedNpmLayout]) -> Vec<(String, String)> { unsupported .iter() .map(|diag| match diag.code { diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs index 9d78c288..32642c94 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pnpm.rs @@ -269,7 +269,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 597cd880..646b6e0b 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 4f603f1d..9860aa5b 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; @@ -190,19 +193,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 + } + }, } } @@ -211,6 +313,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, } } @@ -219,13 +322,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), } } @@ -233,7 +339,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), } } @@ -241,7 +349,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) @@ -252,7 +362,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), @@ -262,7 +372,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 { @@ -301,7 +411,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, @@ -518,4 +628,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 34b3c3ef..5c61eae1 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) @@ -2951,7 +2968,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::{