diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 24ab66cd..250852d8 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -109,6 +109,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). +**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. + **Mode ledgers (contract surfaces).** Each committable mode persists its state at a stable repo-relative path; external tools (and the depscan backend's GitHub-app PR flows) read and write these files, so path + schema are part of the contract: * `.socket/vendor/state.json` — the **vendored**-mode ledger (see "Ownership, state, and reversal" below): wiring edits with verbatim pre-vendor originals, artifact fingerprints, optional `detached` records. @@ -888,6 +890,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `vendor_uuid_mismatch` | `skipped` | repair: the manifest's patch uuid moved past the vendored artifact — a re-vendor (`vendor` / `scan --vendor`) is pending; repair does not cross patch generations. | | `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. `--strict` turns this case into a `failed` event instead. | | `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | +| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed `vendor/cache` archive) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. | | `pypi_{poetry,pdm,pipenv}_no_lockfile` | `failed` | vendor (pypi): a lock-less tool marker with no `requirements.txt` fallback — run ` lock`. | | `vendor_*` / `pypi_*` / `gemfile_*` / `lock_*` / `locked_version_mismatch` / `user_authored_*` / `native_extensions_unsupported` / `platform_gem_unsupported` | `failed`/`skipped` | vendor: per-ecosystem refusal + drift vocabulary; see the Vendor command contract section. New tags are additive (MINOR). | diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index 9313fcbb..4add8b69 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -91,7 +91,8 @@ pub async fn run(args: RepairArgs) -> i32 { if !has_vendor_traces { if tokio::fs::metadata(&redirect_state).await.is_ok() { let msg = "hosted redirects need no local repair; re-run \ - `scan --mode hosted` to refresh the lockfile redirects"; + `scan --mode hosted` to refresh the lockfile redirects \ + (it also re-checks for stale pre-redirect installs)"; if args.common.json { let mut env = Envelope::new(Command::Repair); env.dry_run = args.common.dry_run; diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index b717a26f..ee600df1 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3,6 +3,8 @@ //! Socket's hosted vendored patches. Self-contained — reuses `run`'s //! discovery, then returns without touching the apply/vendor branches. +use std::path::Path; + use socket_patch_core::api::types::BatchPackagePatches; use crate::commands::vex::generate_vex_from_manifest_path; @@ -371,6 +373,335 @@ fn build_redirect_json_envelope( result } +/// The gem stale-install probe's outcome: warnings for both output channels, +/// plus the stale purls STRUCTURALLY, so the same-run `--vex` can exclude +/// them from `assume_applied` — an envelope must never attest a CVE its own +/// warnings say is live. Excluded purls fall back to `vex`'s normal +/// installed-tree verification: a patched install still attests (with hash +/// evidence), a stale one is omitted. +#[derive(Default)] +struct GemStaleOutcome { + warnings: Vec, + stale_purls: std::collections::BTreeSet, +} + +/// The `redirect_gem_stale_install` warning for one stale installed +/// materialization (defect facts + verified/disproven remedies: the "Gem +/// stale-install guard" section of CLI_CONTRACT.md). Wording splits on +/// blast radius: a PROJECT-LOCAL dir gets the verified delete-list remedy — +/// installed dir + cache `.gem` + `specifications` entry, plus the project's +/// committed `vendor/cache` archive when the caller passes one (bundler +/// installs from it in preference to fetching, so a remedy that leaves it +/// behind silently reinstates the stale bytes) — while a SHARED gem-env +/// home affects every project on the machine, so that flavor prefers moving +/// the project to a local bundle path and only conditionally names the +/// shared files. +fn gem_stale_install_warning( + purl: &str, + gem_dir: &Path, + leaf: &str, + cwd: &Path, + project_cache_gem: Option<&Path>, +) -> serde_json::Value { + let home = gem_dir + .parent() + .and_then(Path::parent) + .expect("crawler-resolved gem dirs always live under /gems/"); + let cache = home.join("cache").join(format!("{leaf}.gem")); + let spec = home.join("specifications").join(format!("{leaf}.gemspec")); + let mut paths = vec![ + gem_dir.display().to_string(), + cache.display().to_string(), + spec.display().to_string(), + ]; + if let Some(extra) = project_cache_gem { + paths.push(extra.display().to_string()); + } + let list = paths.join(", "); + let detail = if gem_dir.starts_with(cwd) { + format!( + "{purl} was redirected to the Socket patch registry, but a stale \ + UNPATCHED install is already materialized at {} — `bundle install` \ + reuses the installed gem (and its cached .gem) without refetching, \ + and `--force`/`--redownload` reinstall from the stale cache, so \ + the vulnerable upstream code stays live. Remove the stale \ + materialization — {list} — then run `bundle install` so bundler \ + fetches the patched gem", + gem_dir.display() + ) + } else { + format!( + "{purl} was redirected to the Socket patch registry, but a stale \ + UNPATCHED install is materialized in the shared gem home at {} — \ + `bundle install` reuses it without refetching, so the vulnerable \ + upstream code stays live. That gem home is shared by every \ + project on this machine: prefer switching this project to a \ + project-local bundle path (`bundle config set --local path \ + vendor/bundle`, then `bundle install`); remove {list} directly \ + only if no other project relies on the stale gem", + gem_dir.display() + ) + }; + serde_json::json!({ "code": "redirect_gem_stale_install", "detail": detail }) +} + +/// The vendor/cache flavor of `redirect_gem_stale_install`: the project's +/// committed `bundle cache` archive (`vendor/cache/.gem`) is not the +/// patched artifact. Bundler installs from vendor/cache in preference to +/// fetching, so every install — a fresh checkout included — re-materializes +/// the unpatched bytes no matter what the redirected Gemfile + lock say. +fn gem_stale_cache_warning(purl: &str, cache_path: &Path) -> serde_json::Value { + serde_json::json!({ + "code": "redirect_gem_stale_install", + "detail": format!( + "{purl} was redirected to the Socket patch registry, but the \ + project's committed bundler cache still holds an UNPATCHED \ + archive at {} — bundler installs from vendor/cache in preference \ + to fetching, so installs (fresh checkouts included) keep \ + materializing the vulnerable upstream bytes. Remove that file, \ + run `bundle install` so bundler fetches the patched gem, and \ + re-run `bundle cache` if the project commits its cache", + cache_path.display() + ), + }) +} + +/// POSITIVE staleness evidence: at least one record file whose on-disk +/// content was actually read and hashed to something other than its +/// `afterHash` (`Ready` = pristine upstream bytes, `HashMismatch` = neither +/// hash). Missing or unreadable files are NEVER evidence — `verify_file_patch` +/// folds IO errors into `NotFound`, and a transiently unreadable file in an +/// already-patched install must not produce a delete prescription. +/// (`current_hash` is `Some` only when the bytes were really hashed, which +/// also excludes the absent-new-file `Ready`.) +async fn gem_stale_positive_evidence( + gem_dir: &Path, + record: &socket_patch_core::manifest::schema::PatchRecord, +) -> bool { + use socket_patch_core::patch::apply::{verify_file_patch, VerifyStatus}; + for (file_name, info) in &record.files { + let result = verify_file_patch(gem_dir, file_name, info).await; + if matches!( + result.status, + VerifyStatus::Ready | VerifyStatus::HashMismatch + ) && result.current_hash.is_some() + { + return true; + } + } + false +} + +/// Post-rewrite stale-materialization probe for gem redirects — the guard +/// for the live-verified warm-path defect where `bundle install` never +/// refetches an already-materialized gem (full narrative: the "Gem +/// stale-install guard" section of CLI_CONTRACT.md). +/// +/// Judgment sources and rules: +/// * Discovery is [`socket_patch_core::crawlers::RubyCrawler`] — the same +/// installed-gem APIs `apply` uses, honoring `--global`/`--global-prefix` +/// exactly like scan's own discovery; layouts the crawler grows into are +/// covered automatically. +/// * Records are found BY UUID (the fetch key, stable across purl +/// spellings): this run's fetched records first, then the redirect +/// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed +/// transiently still re-fires from the ledger instead of silently +/// dropping the warning (`record_fetch_failed` covers the fetch failure +/// itself). Record availability is part of the candidate filter, and the +/// probe returns before any crawler work (or `gem env` subprocess spawn) +/// when no judgment is possible. +/// * PATCHED means [`verify_patch_record`] `Ok` — the one shared oracle. +/// Judgments are grouped BY INSTALLED DIR: platform-variant purls of one +/// gem resolve to the same dir, and if ANY variant's record proves the +/// dir patched, the dir is patched — never warned. +/// * STALE requires [`gem_stale_positive_evidence`] — never inferred from +/// missing/unreadable files. +/// * A committed `vendor/cache/.gem` whose sha256 differs from the +/// patched artifact's is stale too (bundler installs from it first, fresh +/// checkouts included): folded into a project-local install warning's +/// delete list, or warned standalone. +/// +/// Read-only by contract: nothing is ever deleted — the remedy is +/// prescribed to the user. +async fn gem_stale_install_warnings( + cwd: &Path, + global: bool, + global_prefix: Option, + confirmed: &[(String, String)], + records: &std::collections::BTreeMap, + ledger_records: &std::collections::BTreeMap< + String, + socket_patch_core::manifest::schema::PatchRecord, + >, + gem_artifact_shas: &std::collections::BTreeMap<(String, String), String>, +) -> GemStaleOutcome { + use socket_patch_core::crawlers::types::CrawlerOptions; + use socket_patch_core::crawlers::RubyCrawler; + use socket_patch_core::manifest::schema::PatchRecord; + use socket_patch_core::vendor::file_sha256_hex; + use socket_patch_core::vex::verify::verify_patch_record; + + let mut out = GemStaleOutcome::default(); + let find_record = |uuid: &str| -> Option<&PatchRecord> { + records + .values() + .chain(ledger_records.values()) + .find(|r| r.uuid == uuid) + }; + // Record availability folds into the candidate filter (a zero-file map + // included: nothing to hash means no judgment either way) so the no-op + // cases return here, before the crawler is built. On `--dry-run` the + // caller skips the probe entirely — see the call site. + let candidates: Vec<(&str, &PatchRecord)> = confirmed + .iter() + .filter(|(purl, _)| purl.starts_with("pkg:gem/")) + .filter_map(|(purl, uuid)| find_record(uuid).map(|r| (purl.as_str(), r))) + .filter(|(_, r)| !r.files.is_empty()) + .collect(); + if candidates.is_empty() { + return out; + } + + // Pass 1: resolve every candidate's installed materializations and judge + // them, grouped by installed dir (see the fn doc's variant rule). + struct DirJudgment { + purl: String, + leaf: String, + patched: bool, + positive: bool, + } + let crawler = RubyCrawler::new(); + let options = CrawlerOptions { + cwd: cwd.to_path_buf(), + global, + global_prefix, + }; + let gem_paths = crawler.get_gem_paths(&options).await.unwrap_or_default(); + let mut dir_state: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (purl, record) in &candidates { + let stripped = socket_patch_core::utils::purl::strip_purl_qualifiers(purl).to_string(); + for gems_dir in &gem_paths { + let found = crawler + .find_by_purls(gems_dir, std::slice::from_ref(&stripped)) + .await + .unwrap_or_default(); + let Some(pkg) = found.get(&stripped) else { + continue; + }; + // A dir whose leaf isn't clean UTF-8 cannot be a real crawler + // coordinate — skip it rather than interpolate a garbled leaf + // into the remedy paths. + let Some(leaf) = pkg.path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let entry = dir_state + .entry(pkg.path.clone()) + .or_insert_with(|| DirJudgment { + purl: (*purl).to_string(), + leaf: leaf.to_string(), + patched: false, + positive: false, + }); + if verify_patch_record(&pkg.path, record).await.is_ok() { + entry.patched = true; + } else if !entry.positive && gem_stale_positive_evidence(&pkg.path, record).await { + entry.positive = true; + entry.purl = (*purl).to_string(); + } + } + } + + // Pass 2: warn per stale dir. A project-local dir's delete list also + // carries the committed vendor/cache archive when one is present and not + // proven to be the patched artifact — bundler installs from it first, so + // a remedy that leaves it behind silently reinstates the stale bytes. + let mut cache_covered: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + for (dir, j) in &dir_state { + if j.patched || !j.positive { + continue; + } + let mut folded_cache: Option = None; + if dir.starts_with(cwd) { + let project_cache = cwd + .join("vendor") + .join("cache") + .join(format!("{}.gem", j.leaf)); + if project_cache.is_file() { + let proven_patched = match ( + gem_artifact_shas.get(&gem_sha_key(&j.purl)), + file_sha256_hex(&project_cache).await, + ) { + (Some(want), Some(got)) => &got == want, + // Unknown sha (or unreadable archive): include it — + // removal is safe either way, `bundle install` refetches. + _ => false, + }; + if !proven_patched { + folded_cache = Some(project_cache); + } + } + } + if folded_cache.is_some() { + if let Some((purl, _)) = candidates.iter().find(|(p, _)| *p == j.purl) { + cache_covered.insert(purl); + } + } + out.warnings.push(gem_stale_install_warning( + &j.purl, + dir, + &j.leaf, + cwd, + folded_cache.as_deref(), + )); + out.stale_purls.insert(j.purl.clone()); + } + + // Pass 3: standalone vendor/cache staleness — a committed archive whose + // sha256 is readable and differs from the patched artifact's, for purls + // whose project-local install warning did not already fold it in (a + // fresh checkout with a committed stale cache has no installed dir at + // all, and would otherwise never warn). + for (purl, _) in &candidates { + if cache_covered.contains(purl) { + continue; + } + let Some(want_sha) = gem_artifact_shas.get(&gem_sha_key(purl)) else { + continue; + }; + let Some((_, name, version)) = parse_purl_simple(purl) else { + continue; + }; + let cache_path = cwd + .join("vendor") + .join("cache") + .join(format!("{name}-{version}.gem")); + if !cache_path.is_file() { + continue; + } + // Unreadable → no positive evidence, never a guess. + let Some(got) = file_sha256_hex(&cache_path).await else { + continue; + }; + if &got == want_sha { + continue; // the PATCHED archive — healthy commit, nothing stale + } + out.warnings + .push(gem_stale_cache_warning(purl, &cache_path)); + out.stale_purls.insert((*purl).to_string()); + } + out +} + +/// The `(name, version)` key the gem artifact-sha map uses — derived from +/// the purl so overrides (which carry no purl) and confirmed purls meet on +/// neutral ground. +fn gem_sha_key(purl: &str) -> (String, String) { + parse_purl_simple(purl) + .map(|(_, name, version)| (name, version)) + .unwrap_or_default() +} + /// `scan --redirect`: resolve hosted-patch references for the selected patches, /// then rewrite ONLY those dependencies' lockfile/registry-config entries to /// point at the hosted vendored patches (the byte-identical counterpart of the @@ -586,6 +917,19 @@ pub(super) async fn run_redirect( } }; + // Snapshot the persisted patch records BEFORE the ledger value is + // consumed by the write below: they are the gem stale-install probe's + // fallback judgment source when this run's /patches/view fetch fails + // transiently (the warning must keep firing until the stale + // materialization is gone, not until the first flaky fetch). + let ledger_records: std::collections::BTreeMap< + String, + socket_patch_core::manifest::schema::PatchRecord, + > = existing_ledger + .as_ref() + .map(|l| l.records.clone()) + .unwrap_or_default(); + // 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 @@ -1288,6 +1632,45 @@ pub(super) async fn run_redirect( } } + // Gem stale-install probe (see `gem_stale_install_warnings`): runs after + // the writes so the warning describes the project as this run leaves it. + // Idempotent re-scans re-confirm and re-probe, so the warning keeps + // firing until the stale materialization is actually gone. The gate is + // deliberately EXPLICIT, not derived from empty fresh records: --dry-run + // rewrites nothing (there is no post-rewrite state to warn about), but + // the probe's ledger-record fallback could still judge an + // already-redirected project, so without this gate a dry-run would warn + // about state the run did not (re)create. + let gem_stale: GemStaleOutcome = if args.common.dry_run { + GemStaleOutcome::default() + } else { + // 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 + .iter() + .filter(|o| o.ecosystem == "gem") + .filter_map(|o| { + let sha = o + .registry_override + .as_ref() + .and_then(|ro| ro.identifiers.gem_checksum_sha256.clone()) + .or_else(|| o.integrity.sha256.clone())?; + Some(((o.name.clone(), o.version.clone()), sha)) + }) + .collect(); + gem_stale_install_warnings( + &args.common.cwd, + args.common.global, + args.common.global_prefix.clone(), + &confirmed, + &records, + &ledger_records, + &gem_artifact_shas, + ) + .await + }; + // Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`) // may still claim package(s) this project also has a hosted redirect ledger // for — their tarballs would then be orphaned and that ledger stale. But the @@ -1338,7 +1721,19 @@ pub(super) async fn run_redirect( let mut vex_code = 0; if args.vex.vex.is_some() && !args.common.dry_run { let mut params = args.vex.to_build_params(); - params.assume_applied = confirmed.iter().map(|(purl, _)| purl.clone()).collect(); + // Stale-flagged purls are EXCLUDED from assume_applied: the same-run + // envelope carries a redirect_gem_stale_install warning proving the + // installed materialization unpatched, so attesting that purl from + // the ledger would contradict the run's own warning. Excluded purls + // fall back to `vex`'s normal installed-tree verification — a + // patched install still attests (with hash evidence), a stale one is + // omitted (and "nothing to attest" fails the command, per the + // embedded-VEX contract). + params.assume_applied = confirmed + .iter() + .map(|(purl, _)| purl.clone()) + .filter(|purl| !gem_stale.stale_purls.contains(purl)) + .collect(); let manifest_path = args.common.resolved_manifest_path(); match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => vex_statements = Some(summary.statements), @@ -1363,6 +1758,7 @@ pub(super) async fn run_redirect( warnings.extend(migration_warnings.iter().cloned()); warnings.extend(rush_warnings.iter().cloned()); warnings.extend(pnpm_warnings.iter().cloned()); + warnings.extend(gem_stale.warnings.iter().cloned()); warnings.extend(takeover_pre_warnings.iter().cloned()); warnings.extend(takeover_warnings.iter().cloned()); warnings.extend(prune_warnings.iter().cloned()); @@ -1440,6 +1836,16 @@ pub(super) async fn run_redirect( for w in &pnpm_warnings { eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } + for w in &gem_stale.warnings { + // Code included: the stale-install hazard is a silent-CVE + // state, so the stderr line must be greppable by its stable + // code in CI logs, same as the JSON envelope. + eprintln!( + " warning ({}): {}", + w["code"].as_str().unwrap_or_default(), + w["detail"].as_str().unwrap_or_default() + ); + } for w in &takeover_pre_warnings { eprintln!(" warning: {}", w["detail"].as_str().unwrap_or_default()); } @@ -1475,11 +1881,12 @@ pub(super) async fn run_redirect( #[cfg(test)] mod tests { use super::{ - build_redirect_json_envelope, parse_purl_simple, plan_workspace_trust, pnpm_heal_root, - 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, read_workspace_for_trust, TrustPlan, - REDIRECT_CANDIDATE_FILES, + build_redirect_json_envelope, gem_stale_cache_warning, gem_stale_install_warning, + gem_stale_install_warnings, gem_stale_positive_evidence, parse_purl_simple, + plan_workspace_trust, pnpm_heal_root, 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, + read_workspace_for_trust, TrustPlan, REDIRECT_CANDIDATE_FILES, }; use socket_patch_core::constants::npm_family; use socket_patch_core::patch::redirect::DepOverride; @@ -1938,6 +2345,586 @@ mod tests { assert_eq!(r["dryRun"], false); } + // ── gem stale-install probe (redirect_gem_stale_install) ────────── + // + // Defect facts + verified/disproven remedies live in CLI_CONTRACT.md's + // "Gem stale-install guard" section; these tests pin the probe's + // judgment rules and the warning wording's load-bearing parts. + + use std::path::PathBuf; + + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; + use socket_patch_core::manifest::schema::{PatchFileInfo, PatchRecord}; + + const GEM_UUID: &str = "8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d"; + const GEM_PURL: &str = "pkg:gem/stale-unit@1.0.0"; + const GEM_LEAF: &str = "stale-unit-1.0.0"; + const GEM_UPSTREAM: &[u8] = b"module StaleUnit; STATUS = :vulnerable; end\n"; + const GEM_PATCHED: &[u8] = b"module StaleUnit; STATUS = :patched; end\n"; + + fn gem_record() -> PatchRecord { + gem_record_with(GEM_UUID, GEM_UPSTREAM, GEM_PATCHED) + } + + fn gem_record_with(uuid: &str, before: &[u8], after: &[u8]) -> PatchRecord { + let mut files = std::collections::HashMap::new(); + files.insert( + "lib/stale_unit.rb".to_string(), + PatchFileInfo { + before_hash: compute_git_sha256_from_bytes(before), + after_hash: compute_git_sha256_from_bytes(after), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2026-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: std::collections::HashMap::new(), + description: String::new(), + license: String::new(), + tier: "free".to_string(), + } + } + + /// Bundler's deployment gem home under `cwd`, built COMPONENT-WISE — + /// the same join operations the crawler uses, so `display()` matches + /// the production paths byte-for-byte on every platform (embedded + /// `a/b/c` literals diverge from Windows' backslash joins). + fn gem_home(cwd: &std::path::Path) -> PathBuf { + cwd.join("vendor").join("bundle").join("ruby").join("3.3.0") + } + + /// Materialize the gem in the deployment layout (installed dir + cached + /// .gem + specifications entry — what a real `bundle install` leaves). + /// Returns the installed gem dir. + fn materialize_gem(cwd: &std::path::Path, lib: &[u8]) -> PathBuf { + let home = gem_home(cwd); + let gem_dir = home.join("gems").join(GEM_LEAF); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib").join("stale_unit.rb"), lib).unwrap(); + std::fs::create_dir_all(home.join("cache")).unwrap(); + std::fs::write( + home.join("cache").join(format!("{GEM_LEAF}.gem")), + b"upstream .gem", + ) + .unwrap(); + std::fs::create_dir_all(home.join("specifications")).unwrap(); + std::fs::write( + home.join("specifications") + .join(format!("{GEM_LEAF}.gemspec")), + b"#", + ) + .unwrap(); + gem_dir + } + + fn one_confirmed() -> Vec<(String, String)> { + vec![(GEM_PURL.to_string(), GEM_UUID.to_string())] + } + + fn one_record() -> std::collections::BTreeMap { + let mut records = std::collections::BTreeMap::new(); + records.insert(GEM_PURL.to_string(), gem_record()); + records + } + + /// Probe invocation with the default surface (project-local discovery, + /// no ledger fallback, no artifact shas) — tests override the knobs + /// they exercise. + async fn probe( + cwd: &std::path::Path, + confirmed: &[(String, String)], + records: &std::collections::BTreeMap, + ) -> super::GemStaleOutcome { + gem_stale_install_warnings( + cwd, + false, + None, + confirmed, + records, + &std::collections::BTreeMap::new(), + &std::collections::BTreeMap::new(), + ) + .await + } + + fn detail_of(w: &serde_json::Value) -> &str { + assert_eq!(w["code"], "redirect_gem_stale_install"); + w["detail"].as_str().expect("detail is a string") + } + + /// PROJECT-LOCAL flavor: names the purl and all three stale paths — + /// each built with the same joins production uses, so this holds on + /// Windows' backslash-joined paths too — steers away from the + /// empirically disproven `--force`/`--redownload`, and prescribes the + /// verified removal + `bundle install` remedy. + #[test] + fn gem_stale_install_warning_project_local_names_paths_and_remedy() { + let cwd = PathBuf::from("proj"); + let home = gem_home(&cwd); + let gem_dir = home.join("gems").join(GEM_LEAF); + let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, None); + let detail = detail_of(&w); + assert!(detail.contains(GEM_PURL), "{detail}"); + assert!(detail.contains(&gem_dir.display().to_string()), "{detail}"); + let cache = home.join("cache").join(format!("{GEM_LEAF}.gem")); + let spec = home + .join("specifications") + .join(format!("{GEM_LEAF}.gemspec")); + assert!(detail.contains(&cache.display().to_string()), "{detail}"); + assert!(detail.contains(&spec.display().to_string()), "{detail}"); + assert!(detail.contains("UNPATCHED"), "{detail}"); + assert!( + detail.contains("--force") && detail.contains("--redownload"), + "the disproven flags must be steered away from: {detail}" + ); + assert!( + detail.contains("Remove the stale materialization") + && detail.contains("`bundle install`"), + "the verified remedy must be prescribed: {detail}" + ); + assert!( + !detail.contains("shared gem home"), + "a project-local dir must not get the shared-home caveat: {detail}" + ); + } + + /// SHARED-HOME flavor: a materialization outside the project must NOT + /// get an unconditional delete prescription — the home is shared by + /// every project on the machine — and must prefer the project-local + /// bundle-path migration instead. + #[test] + fn gem_stale_install_warning_shared_home_prefers_local_path_over_deletion() { + let cwd = PathBuf::from("proj"); + let home = PathBuf::from("shared-gem-home").join("ruby").join("3.3.0"); + let gem_dir = home.join("gems").join(GEM_LEAF); + let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, None); + let detail = detail_of(&w); + assert!(detail.contains("shared gem home"), "{detail}"); + assert!( + detail.contains("bundle config set --local path"), + "the shared flavor must prefer the project-local migration: {detail}" + ); + assert!( + detail.contains("only if no other project relies"), + "shared files must never get an unconditional delete: {detail}" + ); + assert!( + !detail.contains("Remove the stale materialization —"), + "the unconditional delete-list phrasing is project-local only: {detail}" + ); + // The paths are still named (inside the conditional clause). + assert!(detail.contains(&gem_dir.display().to_string()), "{detail}"); + } + + /// A committed project `vendor/cache` archive passed by the caller joins + /// the delete list — bundler installs from it in preference to fetching, + /// so a remedy that leaves it behind silently reinstates stale bytes. + #[test] + fn gem_stale_install_warning_folds_project_cache_into_delete_list() { + let cwd = PathBuf::from("proj"); + let gem_dir = gem_home(&cwd).join("gems").join(GEM_LEAF); + let committed = cwd + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + let w = gem_stale_install_warning(GEM_PURL, &gem_dir, GEM_LEAF, &cwd, Some(&committed)); + let detail = detail_of(&w); + assert!( + detail.contains(&committed.display().to_string()), + "the committed cache archive must be in the delete list: {detail}" + ); + } + + /// Staleness needs POSITIVE evidence — readable bytes hashing to + /// something other than afterHash. Missing files, unreadable paths + /// (a directory where a file is expected — the same NotFound that IO + /// errors fold into), and absent new-files are never evidence: a + /// transiently unreadable file in a patched install must not produce + /// a delete prescription. + #[tokio::test] + async fn gem_stale_positive_evidence_requires_readable_mismatched_bytes() { + let tmp = tempfile::tempdir().unwrap(); + let record = gem_record(); + + // Pristine upstream bytes → evidence. + let upstream = tmp.path().join("upstream"); + std::fs::create_dir_all(upstream.join("lib")).unwrap(); + std::fs::write(upstream.join("lib").join("stale_unit.rb"), GEM_UPSTREAM).unwrap(); + assert!(gem_stale_positive_evidence(&upstream, &record).await); + + // Tampered bytes (neither hash) → evidence. + let tampered = tmp.path().join("tampered"); + std::fs::create_dir_all(tampered.join("lib")).unwrap(); + std::fs::write(tampered.join("lib").join("stale_unit.rb"), b"other").unwrap(); + assert!(gem_stale_positive_evidence(&tampered, &record).await); + + // Patched bytes → no evidence. + let patched = tmp.path().join("patched"); + std::fs::create_dir_all(patched.join("lib")).unwrap(); + std::fs::write(patched.join("lib").join("stale_unit.rb"), GEM_PATCHED).unwrap(); + assert!(!gem_stale_positive_evidence(&patched, &record).await); + + // Missing file → no evidence (never a guess). + let hollow = tmp.path().join("hollow"); + std::fs::create_dir_all(hollow.join("lib")).unwrap(); + assert!(!gem_stale_positive_evidence(&hollow, &record).await); + + // A DIRECTORY at the file path (the unreadable-NotFound class) → + // no evidence. + let blocked = tmp.path().join("blocked"); + std::fs::create_dir_all(blocked.join("lib").join("stale_unit.rb")).unwrap(); + assert!(!gem_stale_positive_evidence(&blocked, &record).await); + + // Absent new-file (empty beforeHash routes to Ready with NO + // current_hash) → no evidence. + let mut new_file = gem_record(); + new_file + .files + .get_mut("lib/stale_unit.rb") + .expect("fixture file entry") + .before_hash = String::new(); + assert!(!gem_stale_positive_evidence(&hollow, &new_file).await); + } + + /// The probe end to end over a real deployment layout: a STALE + /// materialization of a confirmed gem redirect produces exactly one + /// warning naming the on-disk paths and lands the purl in + /// `stale_purls` (the same-run `--vex` exclusion set); already-patched, + /// missing-record, zero-file-record, missing-file, and non-gem inputs + /// all stay silent; and the probe never touches the tree. + #[tokio::test] + async fn gem_stale_install_warnings_probe_end_to_end() { + let confirmed = one_confirmed(); + let records = one_record(); + + // STALE: upstream bytes materialized → one warning, real paths named. + let stale = tempfile::tempdir().unwrap(); + let gem_dir = materialize_gem(stale.path(), GEM_UPSTREAM); + let out = probe(stale.path(), &confirmed, &records).await; + assert_eq!( + out.warnings.len(), + 1, + "one stale materialization, one warning" + ); + let detail = detail_of(&out.warnings[0]); + assert!(detail.contains(&gem_dir.display().to_string()), "{detail}"); + let home = gem_home(stale.path()); + let cache = home.join("cache").join(format!("{GEM_LEAF}.gem")); + let spec = home + .join("specifications") + .join(format!("{GEM_LEAF}.gemspec")); + assert!(detail.contains(&cache.display().to_string()), "{detail}"); + assert!(detail.contains(&spec.display().to_string()), "{detail}"); + assert_eq!( + out.stale_purls, + std::collections::BTreeSet::from([GEM_PURL.to_string()]), + "the stale purl must be returned structurally for the vex exclusion" + ); + // Read-only: the stale tree is intact after the probe. + assert_eq!( + std::fs::read(gem_dir.join("lib").join("stale_unit.rb")).unwrap(), + GEM_UPSTREAM + ); + assert!(cache.is_file() && spec.is_file(), "probe must not delete"); + + // PATCHED: every record file at afterHash → silent (the + // cannot-false-positive contract; agent-mode applies leave exactly + // this state with an upstream cache .gem beside it). + let patched = tempfile::tempdir().unwrap(); + materialize_gem(patched.path(), GEM_PATCHED); + let out = probe(patched.path(), &confirmed, &records).await; + assert!(out.warnings.is_empty(), "patched install must never warn"); + assert!(out.stale_purls.is_empty()); + + // MISSING RECORD (fresh AND ledger): no afterHash map, no judgment. + let none = std::collections::BTreeMap::new(); + let out = probe(stale.path(), &confirmed, &none).await; + assert!(out.warnings.is_empty()); + + // ZERO-FILE RECORD: nothing to hash → silent, never a guess. + let mut hollow_records = std::collections::BTreeMap::new(); + let mut hollow = gem_record(); + hollow.files.clear(); + hollow_records.insert(GEM_PURL.to_string(), hollow); + let out = probe(stale.path(), &confirmed, &hollow_records).await; + assert!(out.warnings.is_empty()); + + // NON-GEM confirmed purls never engage the probe. + let npm_confirmed = vec![("pkg:npm/x@1.0.0".to_string(), GEM_UUID.to_string())]; + let out = probe(stale.path(), &npm_confirmed, &records).await; + assert!(out.warnings.is_empty()); + } + + /// FALSE-POSITIVE hardening: an install whose record file is MISSING + /// (or unreadable — same NotFound class) is not positive evidence, so + /// the probe stays quiet instead of prescribing deletion on a tree it + /// could not actually read. + #[tokio::test] + async fn gem_stale_probe_never_warns_without_positive_evidence() { + let tmp = tempfile::tempdir().unwrap(); + let gem_dir = materialize_gem(tmp.path(), GEM_UPSTREAM); + std::fs::remove_file(gem_dir.join("lib").join("stale_unit.rb")).unwrap(); + let out = probe(tmp.path(), &one_confirmed(), &one_record()).await; + assert!( + out.warnings.is_empty(), + "a missing/unreadable file is never staleness evidence: {:?}", + out.warnings + ); + assert!(out.stale_purls.is_empty()); + } + + /// Records are found BY UUID — the fetch key, stable across purl + /// spellings — so a record keyed under a qualified purl still judges + /// the bare confirmed purl. + #[tokio::test] + async fn gem_stale_probe_record_lookup_is_uuid_keyed() { + let stale = tempfile::tempdir().unwrap(); + materialize_gem(stale.path(), GEM_UPSTREAM); + let mut records = std::collections::BTreeMap::new(); + records.insert(format!("{GEM_PURL}?platform=ruby"), gem_record()); + let out = probe(stale.path(), &one_confirmed(), &records).await; + assert_eq!( + out.warnings.len(), + 1, + "the uuid lookup must find the record under any purl spelling" + ); + } + + /// RE-FIRE guarantee: when this run's record fetch failed (fresh records + /// empty) the probe falls back to the redirect ledger's persisted + /// records, so a transient /patches/view failure cannot silently retire + /// the warning while the stale materialization is still there. + #[tokio::test] + async fn gem_stale_probe_falls_back_to_ledger_records() { + let stale = tempfile::tempdir().unwrap(); + materialize_gem(stale.path(), GEM_UPSTREAM); + let fresh = std::collections::BTreeMap::new(); + let out = gem_stale_install_warnings( + stale.path(), + false, + None, + &one_confirmed(), + &fresh, + &one_record(), // the ledger snapshot + &std::collections::BTreeMap::new(), + ) + .await; + assert_eq!( + out.warnings.len(), + 1, + "the ledger records must keep the warning firing across flaky fetches" + ); + } + + /// `--global-prefix` discovery parity: the probe threads the run's + /// global surface into the crawler exactly like scan's own discovery, + /// so a stale materialization in the prefix store is found too. + #[tokio::test] + async fn gem_stale_probe_honors_global_prefix() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path().join("proj"); + std::fs::create_dir_all(&cwd).unwrap(); + // The prefix IS a gems dir (the crawler's global_prefix contract). + let store = tmp.path().join("prefix-store").join("gems"); + let gem_dir = store.join(GEM_LEAF); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib").join("stale_unit.rb"), GEM_UPSTREAM).unwrap(); + let out = gem_stale_install_warnings( + &cwd, + true, + Some(store.clone()), + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &std::collections::BTreeMap::new(), + ) + .await; + assert_eq!( + out.warnings.len(), + 1, + "the global-prefix store must be probed like scan's own discovery" + ); + let detail = detail_of(&out.warnings[0]); + assert!(detail.contains(&gem_dir.display().to_string()), "{detail}"); + assert!( + detail.contains("shared gem home"), + "a store outside the project gets the shared-home flavor: {detail}" + ); + } + + /// PLATFORM-VARIANT guard: multiple confirmed purls of one gem resolve + /// to the same installed dir; when ANY of their records judges the dir + /// fully patched, the dir is patched — the sibling record's stale + /// judgment must not warn. + #[tokio::test] + async fn gem_stale_probe_variant_records_stay_quiet_when_any_judges_patched() { + const UUID_B: &str = "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"; + let tmp = tempfile::tempdir().unwrap(); + // On disk: content X. + materialize_gem(tmp.path(), GEM_PATCHED); + // Record A (uuid GEM_UUID): afterHash == hash(X) → judges PATCHED. + // Record B (uuid B): beforeHash == hash(X), different afterHash → + // judges positive-stale. + let mut records = std::collections::BTreeMap::new(); + records.insert(GEM_PURL.to_string(), gem_record()); + records.insert( + format!("{GEM_PURL}?platform=java"), + gem_record_with(UUID_B, GEM_PATCHED, b"some other patched bytes"), + ); + let confirmed = vec![ + (GEM_PURL.to_string(), GEM_UUID.to_string()), + (format!("{GEM_PURL}?platform=java"), UUID_B.to_string()), + ]; + let out = probe(tmp.path(), &confirmed, &records).await; + assert!( + out.warnings.is_empty(), + "any variant judging the dir patched must suppress the warning: {:?}", + out.warnings + ); + assert!(out.stale_purls.is_empty()); + } + + /// Committed `vendor/cache` handling, folded flavor: a stale install + /// whose project also commits `vendor/cache/.gem` gets that + /// archive in the SAME delete list — bundler installs from it first, + /// so a remedy that leaves it behind silently reinstates stale bytes. + #[tokio::test] + async fn gem_stale_probe_folds_committed_vendor_cache_into_the_remedy() { + let tmp = tempfile::tempdir().unwrap(); + materialize_gem(tmp.path(), GEM_UPSTREAM); + let committed = tmp + .path() + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(committed.parent().unwrap()).unwrap(); + std::fs::write(&committed, b"upstream archive bytes").unwrap(); + let mut shas = std::collections::BTreeMap::new(); + shas.insert( + ("stale-unit".to_string(), "1.0.0".to_string()), + "0".repeat(64), // the patched artifact's sha — differs + ); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &shas, + ) + .await; + assert_eq!(out.warnings.len(), 1, "one warning, cache folded in"); + let detail = detail_of(&out.warnings[0]); + assert!( + detail.contains(&committed.display().to_string()), + "the committed archive must join the delete list: {detail}" + ); + } + + /// Committed `vendor/cache` handling, standalone flavor: a fresh + /// checkout (no installed dir at all) whose committed archive hashes to + /// something other than the patched artifact still warns — bundler + /// installs from vendor/cache first, so that checkout materializes + /// stale bytes forever. The PATCHED archive, an unknown artifact sha, + /// and an absent archive all stay quiet. + #[tokio::test] + async fn gem_stale_probe_warns_on_stale_committed_vendor_cache_without_install() { + use sha2::{Digest, Sha256}; + let tmp = tempfile::tempdir().unwrap(); + let committed = tmp + .path() + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(committed.parent().unwrap()).unwrap(); + let stale_bytes: &[u8] = b"upstream archive bytes"; + std::fs::write(&committed, stale_bytes).unwrap(); + let key = ("stale-unit".to_string(), "1.0.0".to_string()); + let mut shas = std::collections::BTreeMap::new(); + shas.insert(key.clone(), "0".repeat(64)); + + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &shas, + ) + .await; + assert_eq!(out.warnings.len(), 1, "stale committed cache must warn"); + let detail = detail_of(&out.warnings[0]); + assert!( + detail.contains(&committed.display().to_string()), + "{detail}" + ); + assert!(detail.contains("bundle cache"), "{detail}"); + assert_eq!( + out.stale_purls, + std::collections::BTreeSet::from([GEM_PURL.to_string()]) + ); + + // The PATCHED archive (sha matches) is a healthy commit — quiet. + let mut patched_shas = std::collections::BTreeMap::new(); + patched_shas.insert(key, hex::encode(Sha256::digest(stale_bytes))); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &patched_shas, + ) + .await; + assert!(out.warnings.is_empty(), "a patched archive must not warn"); + + // No artifact sha known → no sound judgment → quiet. + let out = probe(tmp.path(), &one_confirmed(), &one_record()).await; + assert!(out.warnings.is_empty(), "unknown sha must never guess"); + + // Archive absent → quiet. + std::fs::remove_file(&committed).unwrap(); + let mut shas = std::collections::BTreeMap::new(); + shas.insert( + ("stale-unit".to_string(), "1.0.0".to_string()), + "0".repeat(64), + ); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &std::collections::BTreeMap::new(), + &shas, + ) + .await; + assert!(out.warnings.is_empty()); + } + + /// The standalone cache-flavor warning's load-bearing wording. + #[test] + fn gem_stale_cache_warning_names_archive_and_remedy() { + let cache = PathBuf::from("proj") + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + let w = gem_stale_cache_warning(GEM_PURL, &cache); + let detail = detail_of(&w); + assert!(detail.contains(GEM_PURL), "{detail}"); + assert!(detail.contains(&cache.display().to_string()), "{detail}"); + assert!(detail.contains("UNPATCHED"), "{detail}"); + assert!(detail.contains("fresh checkouts included"), "{detail}"); + assert!( + detail.contains("`bundle install`") && detail.contains("bundle cache"), + "{detail}" + ); + } + #[test] fn redirect_candidates_match_the_shared_npm_family_table() { // Drift guard, both directions, without classifying the non-npm diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs new file mode 100644 index 00000000..68bedc38 --- /dev/null +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs @@ -0,0 +1,521 @@ +//! Hermetic e2e for the gem hosted-mode stale-install guard +//! (`redirect_gem_stale_install`) — the live-verified warm-path defect where +//! `bundle install` never refetches an already-materialized gem after the +//! redirect, and neither do `--force`/`--redownload` (they reinstall from +//! the stale cached `.gem`). Full narrative + verified remedies: the "Gem +//! stale-install guard" section of CLI_CONTRACT.md. +//! +//! Coverage (real binary + wiremock; the "installed" gems are laid down by +//! hand in bundler's deployment layout, so no ruby/gem/bundler is needed): +//! +//! 1. STALE materialization → loud warning in the JSON envelope naming +//! the installed dir, cache `.gem`, and specifications entry, plus the +//! code-tagged stderr mirror in human mode. +//! 2. ALREADY-PATCHED materialization → quiet (never false-positive). +//! 3. FRESH checkout (no materialization) → quiet. +//! 4. TWO gem homes both stale → one warning per home, each naming its +//! own paths. +//! 5. RE-FIRE: a re-scan whose /patches/view fetch fails transiently +//! still warns, judged from the redirect ledger's persisted record. +//! 6. Same-run `--vex`: the stale purl is excluded from the ledger-based +//! attestation — the envelope must never attest a CVE its own warning +//! says is live. + +use std::path::{Path, PathBuf}; + +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use wiremock::matchers::{method, path, path_regex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[path = "common/mod.rs"] +mod common; + +const ORG: &str = "test-org"; +const DEP: &str = "stale-probe-gem"; +const DEP_VERSION: &str = "1.0.0"; +const PURL: &str = "pkg:gem/stale-probe-gem@1.0.0"; +const UUID: &str = "8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d"; +const TOKEN: &str = "66666666-6666-4666-8666-666666666666"; +const GHSA: &str = "GHSA-gem-stale-e2e"; + +const UPSTREAM_LIB: &str = + "module StaleProbeGem\n def self.status\n \"VULNERABLE\"\n end\nend\n"; +const PATCHED_LIB: &str = "module StaleProbeGem\n def self.status\n \"PATCHED\"\n end\nend\n"; + +/// Mount the whole patches API: batch discovery, by-package selection, the +/// granted reference (rubygems-compact-index override), and — unless +/// `view_budget` caps it — the patch view whose file map carries the REAL +/// before/after hashes of the fixture lib. With `view_budget: Some(n)` the +/// view answers 200 exactly n times and 500 afterwards (the transient-fetch +/// re-fire arm). +async fn mount_api(server: &MockServer, view_budget: Option) { + let hosted_url = format!( + "{}/patch/gem/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.gem", + server.uri() + ); + let index_url = format!("{}/patch-registry/gem/{TOKEN}/{UUID}/", server.uri()); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "gem stale-install fixture" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .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": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "x", "license": "MIT", "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/package"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "results": { + UUID: { + "status": "granted", + "url": hosted_url, + "purl": PURL, + "artifacts": [{ + "kind": "tarball", + "url": hosted_url, + "integrity": { "sha256": "c0ffee".repeat(10) + "abcd" } + }], + "registryOverride": { + "kind": "rubygems-compact-index", + "indexUrl": index_url, + "identifiers": { + "name": DEP, + "version": DEP_VERSION, + "gemChecksumSha256": "c0ffee".repeat(10) + "abcd", + } + } + } + } + }))) + .mount(server) + .await; + let view = Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "lib/stale_probe_gem.rb": { + "beforeHash": compute_git_sha256_from_bytes(UPSTREAM_LIB.as_bytes()), + "afterHash": compute_git_sha256_from_bytes(PATCHED_LIB.as_bytes()), + } + }, + "vulnerabilities": { + GHSA: { + "cves": ["CVE-2026-4444"], + "summary": "gem stale-install fixture vuln", + "severity": "high", + "description": "d" + } + }, + "description": "x", "license": "MIT", "tier": "free" + }))); + match view_budget { + Some(n) => { + view.up_to_n_times(n).mount(server).await; + // After the budget: transient server failure — the re-fire arm. + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(500)) + .mount(server) + .await; + } + None => view.mount(server).await, + } +} + +/// The committable pair: a plain Gemfile + a pre-CHECKSUMS Gemfile.lock (the +/// bundler 1/2 lock shape — the guard must not depend on the CHECKSUMS-era +/// grammar). +fn write_manifest_pair(proj: &Path) { + std::fs::write( + proj.join("Gemfile"), + format!("source \"https://rubygems.org\"\ngem \"{DEP}\", \"{DEP_VERSION}\"\n"), + ) + .unwrap(); + std::fs::write( + proj.join("Gemfile.lock"), + format!( + "GEM\n remote: https://rubygems.org/\n specs:\n {DEP} ({DEP_VERSION})\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n {DEP} (= {DEP_VERSION})\n\n\ + BUNDLED WITH\n 1.17.3\n" + ), + ) + .unwrap(); +} + +/// Materialize the gem in bundler's deployment layout under one ruby-version +/// home — installed dir + cached `.gem` + specifications entry, exactly what +/// a real `bundle install` leaves. Paths are built COMPONENT-WISE (the same +/// join operations the crawler uses) so their `display()` matches the +/// production warning text byte-for-byte on every platform. +/// Returns (installed gem dir, cache .gem path, specifications path). +fn materialize_installed_gem( + proj: &Path, + ruby_version: &str, + lib_content: &str, +) -> (PathBuf, PathBuf, PathBuf) { + let home = proj + .join("vendor") + .join("bundle") + .join("ruby") + .join(ruby_version); + let gem_dir = home.join("gems").join(format!("{DEP}-{DEP_VERSION}")); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), lib_content).unwrap(); + let cache = home.join("cache").join(format!("{DEP}-{DEP_VERSION}.gem")); + std::fs::create_dir_all(cache.parent().unwrap()).unwrap(); + std::fs::write(&cache, b"upstream-gem-archive-bytes").unwrap(); + let spec = home + .join("specifications") + .join(format!("{DEP}-{DEP_VERSION}.gemspec")); + std::fs::create_dir_all(spec.parent().unwrap()).unwrap(); + std::fs::write(&spec, "# stub gemspec\n").unwrap(); + (gem_dir, cache, spec) +} + +fn hosted_scan_json(proj: &Path, api: &str) -> (i32, String, String) { + common::run_with_env( + proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().expect("utf8 tmp path"), + "--api-url", + api, + "--org", + ORG, + "--api-token", + "fake", + ], + &[], + ) +} + +fn stale_warnings(env: &serde_json::Value) -> Vec { + env["redirect"]["warnings"] + .as_array() + .expect("redirect.warnings") + .iter() + .filter(|w| w["code"] == "redirect_gem_stale_install") + .map(|w| w["detail"].as_str().unwrap_or_default().to_string()) + .collect() +} + +/// STALE materialization: the redirect succeeds AND the envelope carries the +/// loud machine-readable warning naming every stale path plus the verified +/// remedy. The human path mirrors the same code-tagged detail to stderr. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_redirect_over_stale_install_warns_loudly() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + let (gem_dir, cache, spec) = materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB); + + let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); + assert_eq!( + code, 0, + "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = common::parse_json_envelope(&stdout); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + let gemfile = std::fs::read_to_string(proj.join("Gemfile")).unwrap(); + assert!( + gemfile.contains("/patch-registry/gem/"), + "Gemfile must gain the patch-registry source block:\n{gemfile}" + ); + + let details = stale_warnings(&env); + assert_eq!( + details.len(), + 1, + "exactly one stale-install warning for the one stale materialization: {env}" + ); + let detail = &details[0]; + // The detail must name the dep, every stale path, and the VERIFIED remedy. + assert!(detail.contains(PURL), "detail must name the purl: {detail}"); + for p in [&gem_dir, &cache, &spec] { + assert!( + detail.contains(&p.display().to_string()), + "detail must name the stale path {}: {detail}", + p.display() + ); + } + assert!( + detail.contains("`bundle install`"), + "detail must prescribe the re-install: {detail}" + ); + // The empirically DISPROVEN remedies are steered away from, never + // prescribed (they reinstall from the stale cache). + assert!( + detail.contains("--redownload") && detail.contains("--force"), + "detail must steer away from --force/--redownload: {detail}" + ); + + // The installed tree was NOT touched — detection is read-only (no + // destructive deletion by default). + assert_eq!( + std::fs::read_to_string(gem_dir.join("lib").join("stale_probe_gem.rb")).unwrap(), + UPSTREAM_LIB, + "detection must never modify the installed tree" + ); + assert!( + cache.is_file() && spec.is_file(), + "detection must not delete" + ); + + // Human-mode re-scan (idempotent) re-detects and mirrors the SAME detail + // to stderr — a re-run keeps warning until the stale install is gone. + let (code, _stdout, stderr) = common::run_with_env( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + ], + &[], + ); + assert_eq!(code, 0, "human re-scan must succeed:\n{stderr}"); + assert!( + stderr.contains("redirect_gem_stale_install"), + "human mode must carry the greppable code tag on stderr:\n{stderr}" + ); + assert!( + stderr.contains(&gem_dir.display().to_string()), + "human mode must name the stale gem dir on stderr:\n{stderr}" + ); +} + +/// ALREADY-PATCHED materialization (every record file at afterHash): the +/// warning must NOT fire — the check may never false-positive on a patched +/// install (e.g. agent-mode apply preceded the mode switch). +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_redirect_over_patched_install_stays_quiet() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + materialize_installed_gem(&proj, "3.3.0", PATCHED_LIB); + + let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); + assert_eq!( + code, 0, + "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = common::parse_json_envelope(&stdout); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert!( + stale_warnings(&env).is_empty(), + "an already-patched materialization must never trip the stale warning: {env}" + ); +} + +/// FRESH CHECKOUT (no materialization): quiet — the redirect's normal +/// `bundle install` flow fetches the patched gem; there is nothing stale. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_redirect_fresh_checkout_stays_quiet() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + // No vendor/bundle at all — the lockfile supplement carries discovery. + + let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); + assert_eq!( + code, 0, + "hosted scan must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = common::parse_json_envelope(&stdout); + assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}"); + assert!( + stale_warnings(&env).is_empty(), + "a fresh checkout must not trip the stale warning: {env}" + ); +} + +/// TWO gem homes (two ruby versions under vendor/bundle) both stale: one +/// warning per home, each naming its own home's paths — multiplicity is +/// per materialization, not per purl. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_redirect_warns_once_per_stale_gem_home() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + let (dir_a, cache_a, _) = materialize_installed_gem(&proj, "3.2.0", UPSTREAM_LIB); + let (dir_b, cache_b, _) = materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB); + + let (code, stdout, stderr) = hosted_scan_json(&proj, &server.uri()); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + let details = stale_warnings(&env); + assert_eq!(details.len(), 2, "one warning per stale gem home: {env}"); + let a = details + .iter() + .find(|d| d.contains(&dir_a.display().to_string())) + .unwrap_or_else(|| panic!("no warning names {}: {details:?}", dir_a.display())); + assert!(a.contains(&cache_a.display().to_string()), "{a}"); + let b = details + .iter() + .find(|d| d.contains(&dir_b.display().to_string())) + .unwrap_or_else(|| panic!("no warning names {}: {details:?}", dir_b.display())); + assert!(b.contains(&cache_b.display().to_string()), "{b}"); +} + +/// RE-FIRE guarantee: scan 1 warns and persists the patch record in the +/// redirect ledger; scan 2's /patches/view fetch fails transiently (500) — +/// the warning must STILL fire, judged from the ledger's persisted record, +/// alongside the record_fetch_failed warning for the fetch itself. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_stale_warning_refires_when_record_fetch_fails() { + let server = MockServer::start().await; + mount_api(&server, Some(1)).await; // view answers 200 exactly once + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + let (gem_dir, ..) = materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB); + + // Scan 1: fresh record, warning fires, ledger persists the record. + let (code, stdout, _) = hosted_scan_json(&proj, &server.uri()); + assert_eq!(code, 0); + let env = common::parse_json_envelope(&stdout); + assert_eq!(stale_warnings(&env).len(), 1, "scan 1 must warn: {env}"); + let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap(); + assert!( + ledger.contains(UUID), + "the ledger must persist the record scan 2 falls back to: {ledger}" + ); + + // Scan 2: view 500s → record_fetch_failed, but the stale warning + // re-fires from the ledger record. + let (code, stdout, _) = hosted_scan_json(&proj, &server.uri()); + assert_eq!(code, 0); + let env = common::parse_json_envelope(&stdout); + let codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .expect("warnings") + .iter() + .filter_map(|w| w["code"].as_str()) + .collect(); + assert!( + codes.contains(&"record_fetch_failed"), + "the transient fetch failure itself is surfaced: {env}" + ); + let details = stale_warnings(&env); + assert_eq!( + details.len(), + 1, + "a flaky record fetch must not retire the stale warning: {env}" + ); + assert!(details[0].contains(&gem_dir.display().to_string()), "{env}"); +} + +/// Same-run `--vex` consistency: a stale-flagged purl is EXCLUDED from the +/// ledger-based `assume_applied` attestation — the envelope must never +/// attest a CVE its own warning says is live. With the only patch stale, +/// verification finds nothing attestable, so the run fails the VEX step +/// (the embedded-VEX fail-the-command contract) and no document attests +/// the purl. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB); + + let vex_path = proj.join("out.vex.json"); + let (code, stdout, stderr) = common::run_with_env( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--org", + ORG, + "--api-token", + "fake", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "pkg:gem/app@1.0.0", + ], + &[], + ); + let env = common::parse_json_envelope(&stdout); + // The warning fired… + assert_eq!( + stale_warnings(&env).len(), + 1, + "the stale warning must be in the same envelope: {env}" + ); + // …so the purl must NOT be attested by any written document. + if let Ok(doc) = std::fs::read_to_string(&vex_path) { + assert!( + !doc.contains(PURL), + "a stale purl must never be attested by the same run's VEX:\n{doc}" + ); + } + // With the only patch stale-excluded there is nothing to attest — the + // embedded-VEX contract fails the command rather than writing an + // evidence-free document. + assert_ne!( + code, 0, + "an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!(env["status"], "error", "envelope: {env}"); +} diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 576c49d1..a7f3948c 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -158,7 +158,12 @@ pub async fn applied_patches_with_vendor( /// zero-file record offers nothing to hash, so — per the module's /// "omit when unconfirmed" contract — it is reported as `no_files` and /// dropped from the VEX document rather than vacuously attested. -async fn verify_patch_record(pkg_path: &Path, record: &PatchRecord) -> Result<(), String> { +/// +/// `pub`: this is the ONE "is this installed tree patched?" oracle — the +/// CLI's gem stale-install probe (`scan --mode hosted`) reuses it rather +/// than growing a second copy of the all-files-AlreadyPatched + zero-file +/// semantics that would inevitably drift. +pub async fn verify_patch_record(pkg_path: &Path, record: &PatchRecord) -> Result<(), String> { if record.files.is_empty() { return Err("no_files".to_string()); } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 308e91f5..59fc4b8a 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun | ✅ any install layout; `setup` postinstall hook | ✅ six lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v5.4/v6.0/v9 — every major since pnpm 7), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | -| RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | +| RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) |