diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index b435b786..c4bdcd73 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -15,9 +15,33 @@ //! from the lockfile path itself (the contract's uuid-in-path rule), the //! record from the manifest (or the patch API, yielding a detached entry), //! and a fresh ledger entry is re-synthesized so sweep/GC/revert know the -//! artifact again. Reconstructed entries carry no pre-vendor wiring -//! originals — `--revert` degrades to its documented -//! `vendor_lock_entry_drifted` re-resolve guidance. +//! artifact again. WIRING reconstruction is per-ecosystem: gem recognizes +//! its own Gemfile/lock wiring and rebuilds full revert-capable records +//! ([`socket_patch_core::vendor::gem::reconstruct_gem_wiring`]); the other +//! ecosystems' pre-vendor originals are registry integrity material no +//! offline source can reproduce, so their entries keep empty wiring and the +//! gap is surfaced loudly (`vendor_wiring_unknown`) — a gem `--revert` of +//! such an entry refuses instead of stranding the pair edit. Existing gem +//! entries with EMPTY wiring (persisted by pre-reconstruction repairs) are +//! backfilled the same way during the ledger-driven pass while healthy. +//! +//! Dir-shaped rebuilds are always LOCAL (the pristine ladder + the recorded +//! patch), while `vendor` may have used the patch service's prebuilt +//! artifact (a converter-generated stub gemspec the local build cannot +//! reproduce): a rebuild whose patched members verify but whose tree +//! differs from the recorded fileInventory refreshes the inventory from +//! the verified rebuild (`vendor_inventory_refreshed`) instead of failing +//! deterministically on every repair. +//! +//! Reconstruction never fingerprints the LIVE artifact into the restored +//! ledger (trust-on-first-use: a tampered unpatched file would become the +//! canonical tree later repairs enforce and VEX attests). A surviving +//! artifact is only restored as-is when an independent anchor vouches for +//! its exact bytes (the rewired npm-family lockfile integrity); otherwise +//! its fingerprint is derived from a member-verified local rebuild, and +//! when no trustworthy pristine source exists the entry is restored +//! fingerprint-less with `vendor_inventory_unverified` — the legacy +//! member-only state — never from the unverifiable live tree. use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -29,10 +53,11 @@ use socket_patch_core::patch::copy_tree::remove_tree; use socket_patch_core::utils::purl::{ normalize_purl, percent_decode_purl_component, strip_purl_qualifiers, }; -use socket_patch_core::vendor::state::VendorArtifact; +use socket_patch_core::vendor::state::{VendorArtifact, WiringRecord}; use socket_patch_core::vendor::{ - self, check_vendored_artifact, file_sha256_hex, load_state, lock_inventory, parse_vendor_path, - registry_fetch, ArtifactHealth, VendorEntry, VendorOutcome, + self, artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, + load_state, lock_inventory, parse_vendor_path, registry_fetch, ArtifactHealth, VendorEntry, + VendorOutcome, VendorWarning, }; use socket_patch_core::vex::time::now_rfc3339; @@ -55,6 +80,15 @@ struct Candidate { /// reference (it must be persisted after a successful rebuild). reconstructed: bool, reason: &'static str, + /// True for a healthy-by-members RECONSTRUCTED entry with no + /// independent integrity anchor (dir-shaped trees; file artifacts no + /// npm-family lock records an integrity for): the live bytes must never + /// be fingerprinted into the restored ledger (trust-on-first-use), so + /// the fingerprint is derived from a member-verified local rebuild — + /// and every pre-rebuild failure falls back to a fingerprint-less + /// restore plus a `vendor_inventory_unverified` warning instead of a + /// hard failure (the artifact itself still verifies member-wise). + soft: bool, } /// Files the vendor backends rewire — the search space for @@ -131,6 +165,7 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, @@ -146,6 +181,37 @@ fn synth_entry(eco: &str, uuid: &str, artifact_path: &str, base_purl: &str) -> V } } +/// What wiring a re-synthesized ledger entry could recover. +enum WiringReconstruction { + /// The backend recognized its own wiring in the live project files: + /// full revert-capable records, plus any degradation notes to surface. + Wired(Vec, Vec), + /// No wiring recoverable — unsupported ecosystem, or files vendor's + /// grammar does not recognize. The entry keeps empty wiring and the + /// gap is surfaced loudly. + Unknown(String), +} + +/// Per-ecosystem wiring reconstruction for a no-ledger repair. gem is the +/// one ecosystem whose wiring is fully self-describing (the pair edit's +/// originals are derivable from its own emitted forms); the npm family and +/// the rest record pre-vendor REGISTRY integrity fragments that no offline +/// source can reproduce — never guessed at. +async fn reconstruct_entry_wiring( + project_root: &Path, + entry: &VendorEntry, +) -> WiringReconstruction { + match entry.ecosystem.as_str() { + "gem" => match vendor::gem::reconstruct_gem_wiring(project_root, entry).await { + Ok((wiring, notes)) => WiringReconstruction::Wired(wiring, notes), + Err(detail) => WiringReconstruction::Unknown(detail), + }, + _ => WiringReconstruction::Unknown( + "this ecosystem's pre-vendor lock fragments are not offline-recoverable".to_string(), + ), + } +} + fn fail(env: &mut Envelope, quiet: bool, purl: &str, code: &str, detail: String) { if !quiet { eprintln!( @@ -157,6 +223,42 @@ fn fail(env: &mut Envelope, quiet: bool, purl: &str, code: &str, detail: String) env.mark_partial_failure(); } +/// A soft (healthy-by-members, unanchored) reconstruction whose trustworthy +/// rebuild cannot proceed: the entry stays restored WITHOUT a whole-file +/// fingerprint — the legacy member-only state pass 1 keeps warning about +/// (`vendor_inventory_missing` for gems) — and the gap is surfaced, instead +/// of either failing the repair or canonizing the unverifiable live tree. +/// The entry itself was already persisted by the pre-rebuild restore. +fn soft_restore_without_fingerprint( + env: &mut Envelope, + common: &GlobalArgs, + purl: &str, + artifact_path: &str, + why: &str, +) { + record_warning( + env, + purl, + &VendorWarning::new( + "vendor_inventory_unverified", + format!( + "the ledger entry was reconstructed but its artifact has no independent \ + integrity anchor and {why}; the entry was restored without a whole-file \ + fingerprint (only the patched members were verified) — run `socket-patch \ + vendor` to re-vendor and record one" + ), + ), + common, + ); + env.record( + PatchEvent::new(PatchAction::Rebuilt, purl.to_string()).with_details(serde_json::json!({ + "path": artifact_path, + "ledgerRestored": true, + "artifactRebuilt": false, + })), + ); +} + /// Best-effort removal of a vendored uuid dir — ahead of a rebuild (corrupt /// bytes must never blend into one) or after a failed post-verify (never /// leave unverifiable bytes behind). @@ -248,7 +350,96 @@ pub(crate) async fn repair_vendored_artifacts( continue; } match check_vendored_artifact(&common.cwd, &entry, &record).await { - ArtifactHealth::Healthy => {} + ArtifactHealth::Healthy => { + // Dir-shaped artifacts from pre-inventory vendors: the + // health check above could only verify the PATCHED members + // — unpatched-file drift is invisible until a re-vendor + // records the whole-tree inventory. Name the gap — for gem + // only, the one backend that records inventories; the other + // dir-shaped backends (cargo/golang/composer) don't yet, so + // a re-vendor there records nothing and the advice would be + // permanent per-run noise. + if entry.ecosystem == "gem" + && !artifact_is_file_shaped(&entry.artifact.path) + && entry.artifact.file_inventory.is_none() + { + record_warning( + env, + purl, + &VendorWarning::new( + "vendor_inventory_missing", + format!( + "the ledger entry for {} records no file inventory \ + (pre-inventory vendor); only the patched members were \ + verified — re-vendor to make unpatched-file drift \ + detectable", + normalize_purl(purl) + ), + ), + common, + ); + } + // Empty-wiring gem entries (pre-reconstruction repairs + // persisted these): backfill full revert-capable wiring + // from the live pair via the same recognizers the + // no-ledger reconstruction trusts, so `vendor --revert` + // stops refusing with manual cleanup steps. + if entry.ecosystem == "gem" && entry.wiring.is_empty() { + match vendor::gem::reconstruct_gem_wiring(&common.cwd, &entry).await { + Ok((wiring, notes)) => { + if common.dry_run { + env.record( + PatchEvent::new(PatchAction::Verified, purl.clone()) + .with_details(serde_json::json!({ + "vendorArtifact": true, + "wouldRestoreWiring": true, + })), + ); + continue; + } + for w in ¬es { + record_warning(env, purl, w, common); + } + let mut healed = entry.clone(); + healed.wiring = wiring; + let detached = healed.detached; + if persist_vendor_entry( + common, env, &mut state, purl, healed, detached, &record, + ) + .await + { + continue; + } + env.record( + PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( + serde_json::json!({ + "path": entry.artifact.path, + "wiringRestored": true, + "artifactRebuilt": false, + }), + ), + ); + rebuilt += 1; + } + Err(detail) => { + record_warning( + env, + purl, + &VendorWarning::new( + "vendor_wiring_unknown", + format!( + "the ledger entry records no pre-vendor wiring \ + originals and they cannot be reconstructed from \ + the live files ({detail}); `vendor --revert` \ + cannot restore the project files for this entry" + ), + ), + common, + ); + } + } + } + } ArtifactHealth::StaleUuid => { env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( @@ -280,6 +471,7 @@ pub(crate) async fn repair_vendored_artifacts( detached, reconstructed: false, reason, + soft: false, }); } } @@ -324,15 +516,48 @@ pub(crate) async fn repair_vendored_artifacts( if detached { entry.record = Some(record.clone()); } + // Wiring reconstruction (fail-closed): gem rebuilds full + // revert-capable records from its own recognizable pair edit; the + // rest keep empty wiring with the gap surfaced loudly — reverting + // such an entry cannot restore the project files. + match reconstruct_entry_wiring(&common.cwd, &entry).await { + WiringReconstruction::Wired(wiring, notes) => { + entry.wiring = wiring; + for w in ¬es { + record_warning(env, &purl, w, common); + } + } + WiringReconstruction::Unknown(detail) => { + record_warning( + env, + &purl, + &VendorWarning::new( + "vendor_wiring_unknown", + format!( + "the ledger entry was reconstructed without pre-vendor wiring \ + originals ({detail}); `vendor --revert` cannot restore the \ + project files for this entry" + ), + ), + common, + ); + } + } match check_vendored_artifact(&common.cwd, &entry, &record).await { ArtifactHealth::Healthy => { - // The re-synthesized entry records no sha256, so the health - // check above verified only the patched members — whole-file - // drift (an altered UNPATCHED member) is invisible to it. - // The rewired lockfile integrity is the trust anchor for - // these exact bytes: a "surviving" artifact that no longer - // matches it leaves the package manager broken, so it must - // be rebuilt, never blessed into the reconstructed ledger. + // The re-synthesized entry records no sha256/fileInventory, + // so the health check above verified only the patched + // members — whole-file drift (an altered UNPATCHED member) + // is invisible to it. The live bytes must therefore NEVER be + // fingerprinted into the restored ledger: that would be + // trust-on-first-use, canonizing a tampered tree that later + // repairs enforce and VEX attests. Only an INDEPENDENT + // anchor can vouch for the exact bytes — the rewired + // npm-family lockfile integrity, when one records this + // artifact. A "surviving" artifact that no longer matches it + // leaves the package manager broken, so it must be rebuilt, + // never blessed into the reconstructed ledger. + let mut anchored = false; if let Some(wired) = lock_inventory::wired_vendor_integrity(&common.cwd, &entry.artifact.path).await { @@ -355,43 +580,73 @@ pub(crate) async fn repair_vendored_artifacts( detached, reconstructed: true, reason: "vendor_artifact_corrupt", + soft: false, }); continue; } + anchored = true; } - // The artifact survived; only the ledger was lost. Restore - // the entry (sha/size recomputed) so GC/sweep/revert know - // the artifact again — without it the next `scan --prune` - // would sweep the uuid dir as an orphan. if common.dry_run { + let mut details = serde_json::json!({ + "vendorArtifact": true, + "wouldRestoreLedgerEntry": true, + "path": relpath, + }); + if !anchored { + // The fingerprint would come from a rebuild, never + // the live tree. + details["wouldRebuild"] = serde_json::Value::Bool(true); + } + env.record( + PatchEvent::new(PatchAction::Verified, purl.clone()).with_details(details), + ); + continue; + } + if anchored { + // The artifact bytes are exactly what the rewired + // lockfile's integrity records; only the ledger was + // lost. Restore the entry (sha/size recomputed from the + // VERIFIED bytes) so GC/sweep/revert know the artifact + // again — without it the next `scan --prune` would sweep + // the uuid dir as an orphan. + fill_artifact_fingerprint(&common.cwd, &mut entry).await; + let save_failed = persist_vendor_entry( + common, env, &mut state, &purl, entry, detached, &record, + ) + .await; + if save_failed { + continue; + } env.record( - PatchEvent::new(PatchAction::Verified, purl.clone()).with_details( + PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( serde_json::json!({ - "vendorArtifact": true, - "wouldRestoreLedgerEntry": true, "path": relpath, + "ledgerRestored": true, + "artifactRebuilt": false, }), ), ); + rebuilt += 1; continue; } - fill_artifact_fingerprint(&common.cwd, &mut entry).await; - let save_failed = - persist_vendor_entry(common, env, &mut state, &purl, entry, detached, &record) - .await; - if save_failed { - continue; - } - env.record( - PatchEvent::new(PatchAction::Rebuilt, purl.clone()).with_details( - serde_json::json!({ - "path": relpath, - "ledgerRestored": true, - "artifactRebuilt": false, - }), - ), - ); - rebuilt += 1; + // No anchor (dir-shaped trees — gem, cargo —, file + // artifacts absent from every npm-family lock): queue a + // SOFT rebuild. The canonical fingerprint is derived from a + // member-verified local rebuild (pristine source + the + // recorded patch, the same dispatch as every other rebuild + // here); when no trustworthy pristine source exists the + // entry is restored WITHOUT a fingerprint — the legacy + // member-only state pass 1 keeps warning about — instead of + // canonizing the live tree. + candidates.push(Candidate { + purl, + entry, + record, + detached, + reconstructed: true, + reason: "vendor_inventory_unverified", + soft: true, + }); } _ => { candidates.push(Candidate { @@ -401,6 +656,7 @@ pub(crate) async fn repair_vendored_artifacts( detached, reconstructed: true, reason: "vendor_artifact_missing", + soft: false, }); } } @@ -434,6 +690,32 @@ pub(crate) async fn repair_vendored_artifacts( ); } + // ── Soft reconstructions: restore the ledger entry FIRST ───────────── + // Fingerprint-less: the restore must survive even when no trustworthy + // rebuild source turns up below, and the fingerprint slot is only ever + // refilled from a member-verified rebuild — never the live tree. The + // early persist also lets the rebuild's own persist carry the + // reconstructed wiring originals forward by identity. + let mut unrebuildable: HashSet = HashSet::new(); + for c in &candidates { + if c.soft + && persist_vendor_entry( + common, + env, + &mut state, + &c.purl, + c.entry.clone(), + c.detached, + &c.record, + ) + .await + { + // The state write failed (Failed event already recorded): + // nothing below could persist either. + unrebuildable.insert(c.purl.clone()); + } + } + // ── Corrupt artifacts are deleted first ────────────────────────────── // The backends' wired hot paths rebuild on MISSING; turning corrupt // into missing gives every ecosystem one uniform rebuild trigger (and @@ -458,6 +740,20 @@ pub(crate) async fn repair_vendored_artifacts( MemStageOutcome::Ready(s) => s, MemStageOutcome::Unavailable => { for c in &candidates { + if unrebuildable.contains(&c.purl) { + continue; + } + if c.soft { + soft_restore_without_fingerprint( + env, + common, + &c.purl, + &c.entry.artifact.path, + "its patch content has no local source to rebuild from", + ); + rebuilt += 1; + continue; + } fail( env, quiet, @@ -492,12 +788,14 @@ pub(crate) async fn repair_vendored_artifacts( let inventory = lock_inventory::inventory_project(&common.cwd).await; let client = registry_fetch::build_registry_client(); let mut holders: Vec = Vec::new(); - let mut unrebuildable: HashSet = HashSet::new(); // Reconstructed npm candidates fetched UNVERIFIED from the conventional // registry: their rebuilt tarball MUST match the integrity the rewired // lockfile records (the trust anchor) before anything is persisted. let mut must_verify: HashMap = HashMap::new(); for c in &candidates { + if unrebuildable.contains(&c.purl) { + continue; + } if all_packages.contains_key(&c.purl) { // Installed copy: works offline too. But for a RECONSTRUCTED // entry the copy is an unverified source — the ledger that @@ -518,17 +816,29 @@ pub(crate) async fn repair_vendored_artifacts( continue; } if common.offline { - fail( - env, - quiet, - &c.purl, - c.reason, - format!( - "the vendored artifact at {} is broken, the package is not installed, \ - and --offline prevents fetching a pristine copy", - c.entry.artifact.path - ), - ); + if c.soft { + soft_restore_without_fingerprint( + env, + common, + &c.purl, + &c.entry.artifact.path, + "the package is not installed and --offline prevents fetching a \ + pristine copy to rebuild from", + ); + rebuilt += 1; + } else { + fail( + env, + quiet, + &c.purl, + c.reason, + format!( + "the vendored artifact at {} is broken, the package is not installed, \ + and --offline prevents fetching a pristine copy", + c.entry.artifact.path + ), + ); + } unrebuildable.insert(c.purl.clone()); continue; } @@ -579,6 +889,21 @@ pub(crate) async fn repair_vendored_artifacts( } } } + if c.soft { + soft_restore_without_fingerprint( + env, + common, + &c.purl, + &c.entry.artifact.path, + "no verifiable pristine source exists to rebuild from (the package \ + is not installed, the lockfile is rewired to the vendored artifact, \ + and the reconstructed entry records no recoverable registry \ + fragment)", + ); + rebuilt += 1; + unrebuildable.insert(c.purl.clone()); + continue; + } let detail = if c.entry.artifact.platform_locked == Some(true) { "the vendored wheel is platform-locked (compiled); reinstall the \ package on this platform and re-run repair, or run `socket-patch \ @@ -596,7 +921,18 @@ pub(crate) async fn repair_vendored_artifacts( unrebuildable.insert(c.purl.clone()); } PristineFetch::Failed(detail) => { - fail(env, quiet, &c.purl, "vendor_fetch_failed", detail); + if c.soft { + soft_restore_without_fingerprint( + env, + common, + &c.purl, + &c.entry.artifact.path, + &format!("the pristine fetch failed ({detail})"), + ); + rebuilt += 1; + } else { + fail(env, quiet, &c.purl, "vendor_fetch_failed", detail); + } unrebuildable.insert(c.purl.clone()); } } @@ -611,6 +947,15 @@ pub(crate) async fn repair_vendored_artifacts( let Some(pkg_path) = all_packages.get(&c.purl).cloned() else { continue; // failed above }; + if c.soft { + // The healthy-by-members live tree is exactly what cannot be + // trusted; with a pristine source secured, clear it so the + // backend's wired hot path materialises a fresh copy — the + // fingerprint below then derives from the member-verified + // rebuild, never the live bytes. (Deleted only now, after the + // patch sources and the pristine source are both in hand.) + remove_vendor_dir(&common.cwd, &c.entry.ecosystem, &c.entry.uuid).await; + } // For an unverified-source rebuild the rewired lockfile is the trust // anchor: snapshot the wiring files so a failed post-verify can put // them back byte-for-byte. The backend's re-wire may refresh the @@ -744,7 +1089,62 @@ pub(crate) async fn repair_vendored_artifacts( continue; } // ── Fail-closed post-verify ────────────────────────────── - match check_vendored_artifact(&common.cwd, &check_entry, &c.record).await { + let mut health = + check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; + // A dir-shaped rebuild whose PATCHED members all verify but + // whose tree differs from the recorded inventory: the entry + // recorded the OTHER build source's tree (the patch + // service's prebuilt artifact carries a converter-generated + // stub gemspec; repair always rebuilds locally). Failing + // here would delete the rebuild, strand the wired pair on a + // dead dir, and deterministically re-fail every later + // repair — so refresh the inventory from the verified + // rebuild instead, loudly. + if !from_backend + && !c.reconstructed + && matches!(&health, ArtifactHealth::Corrupt { reason } + if reason == "vendor_inventory_mismatch") + { + let abs = common + .cwd + .join(check_entry.artifact.path.replace('\\', "/")); + if let Ok(inv) = compute_dir_inventory(&abs).await { + check_entry.artifact.file_inventory = Some(inv); + health = + check_vendored_artifact(&common.cwd, &check_entry, &c.record).await; + if health == ArtifactHealth::Healthy { + record_warning( + env, + &c.purl, + &VendorWarning::new( + "vendor_inventory_refreshed", + "the rebuilt artifact's patched files verify but its \ + tree differs from the recorded file inventory (the \ + entry was likely vendored from the patch service's \ + prebuilt artifact; repair rebuilds locally); the \ + inventory was refreshed from the verified rebuild — \ + run `socket-patch vendor` to restore the \ + service-built tree", + ), + common, + ); + if persist_vendor_entry( + common, + env, + &mut state, + &c.purl, + check_entry.clone(), + c.detached, + &c.record, + ) + .await + { + continue; + } + } + } + } + match health { ArtifactHealth::Healthy => { if !quiet { println!( @@ -758,6 +1158,7 @@ pub(crate) async fn repair_vendored_artifacts( serde_json::json!({ "path": check_entry.artifact.path, "reason": c.reason, + "ledgerRestored": c.reconstructed, }), ), ); @@ -789,14 +1190,17 @@ pub(crate) async fn repair_vendored_artifacts( rebuilt } -/// Compute and record the artifact fingerprint (sha256 + size for -/// file-shaped artifacts) on a re-synthesized ledger entry. +/// Compute and record the artifact fingerprint on a re-synthesized ledger +/// entry: sha256 + size for file-shaped artifacts, the whole-tree file +/// inventory for dir-shaped ones. An uninventoriable dir stays `None` — +/// the entry then behaves as pre-inventory (member-only verification). async fn fill_artifact_fingerprint(project_root: &Path, entry: &mut VendorEntry) { let norm = entry.artifact.path.replace('\\', "/"); - if !(norm.ends_with(".tgz") || norm.ends_with(".tar.gz") || norm.ends_with(".whl")) { - return; // dir-shaped: integrity is per-file afterHashes - } let abs = project_root.join(&norm); + if !artifact_is_file_shaped(&norm) { + entry.artifact.file_inventory = compute_dir_inventory(&abs).await.ok(); + return; + } if let Some(hex) = file_sha256_hex(&abs).await { entry.artifact.sha256 = hex; } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 21c51589..b259605c 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1759,6 +1759,7 @@ mod gc_tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index fc130a15..5548053e 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -128,6 +128,7 @@ fn write_vendor_state(cwd: &Path, purl: &str, rel_path: &str) { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, @@ -574,6 +575,7 @@ fn write_detached_vendor_state(cwd: &Path, purl: &str, rel_path: &str, record: P sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, @@ -842,6 +844,7 @@ fn detached_matrix_entry( sha256, size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index 3b8286ea..226bbaee 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -1207,6 +1207,7 @@ async fn vendored_golang_purl_skipped_by_apply() { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index 6db59886..dee42141 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -3,6 +3,12 @@ //! disk are rebuilt fail-closed (and the ledger itself is reconstructed from //! lockfile references when it was deleted wholesale). Mock API + real npm //! lockfile fixtures, driven through the built binary. +//! +//! The gem rows exercise the dir-shaped counterparts: whole-tree +//! fileInventory tamper detection, full wiring reconstruction from the live +//! Gemfile/lock pair (revert then byte-restores), and the loud empty-wiring +//! revert refusal. Their fixture pair is hand-written, modeled byte-for-byte +//! on real `bundle lock` output (bundler 4.0.15). use std::path::{Path, PathBuf}; use std::process::Command; @@ -900,6 +906,819 @@ async fn repair_dry_run_previews_rebuild() { assert!(!tgz.exists(), "dry run writes nothing"); } +// ────────────────────────────── gem rows ────────────────────────────── + +const GEM_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const GEM_NAME: &str = "padlock"; +const GEM_VERSION: &str = "1.2.0"; +const GEM_PURL: &str = "pkg:gem/padlock@1.2.0"; +const GEM_ENCODED: &str = "pkg%3Agem%2Fpadlock%401.2.0"; +const GEMSPEC_STUB: &[u8] = b"Gem::Specification.new do |s|\n s.name = \"padlock\"\n s.version = \"1.2.0\"\n s.require_paths = [\"lib\"]\nend\n"; + +fn gem_copy_rel() -> String { + format!(".socket/vendor/gem/{GEM_UUID}/{GEM_NAME}-{GEM_VERSION}") +} + +/// Hermetic bundler project: exact-pin Gemfile, a lock modeled on real +/// bundler 4.0.15 output (`with_checksums` adds the ≥ 2.6 CHECKSUMS +/// section), and the installed gem + stub gemspec under the project-local +/// `vendor/bundle` layout the ruby crawler discovers. +fn write_gem_fixture(root: &Path, with_checksums: bool) { + std::fs::write( + root.join("Gemfile"), + format!("source \"https://rubygems.org\"\n\ngem \"{GEM_NAME}\", \"{GEM_VERSION}\"\n"), + ) + .unwrap(); + let checksums = if with_checksums { + format!( + "CHECKSUMS\n {GEM_NAME} ({GEM_VERSION}) sha256={}\n\n", + "e".repeat(64) + ) + } else { + String::new() + }; + std::fs::write( + root.join("Gemfile.lock"), + format!( + "GEM\n remote: https://rubygems.org/\n specs:\n {GEM_NAME} ({GEM_VERSION})\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n {GEM_NAME} (= {GEM_VERSION})\n\n\ + {checksums}BUNDLED WITH\n 4.0.15\n" + ), + ) + .unwrap(); + + let home = root.join("vendor/bundle/ruby/3.4.0"); + let gem_dir = home.join(format!("gems/{GEM_NAME}-{GEM_VERSION}")); + std::fs::create_dir_all(gem_dir.join("lib")).unwrap(); + std::fs::write(gem_dir.join("lib/padlock.rb"), BEFORE).unwrap(); + std::fs::create_dir_all(home.join("specifications")).unwrap(); + std::fs::write( + home.join(format!("specifications/{GEM_NAME}-{GEM_VERSION}.gemspec")), + GEMSPEC_STUB, + ) + .unwrap(); +} + +/// Mount discovery + view for `GEM_UUID` (the gem twin of +/// [`mount_patch_api`]; file key is package-relative, no `package/`). +async fn mount_gem_patch_api(mock: &MockServer) { + let before_hash = git_sha256(BEFORE); + let after_hash = git_sha256(AFTER); + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": GEM_PURL, + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "tier": "free", + "cveIds": ["CVE-2026-0002"], + "ghsaIds": [], + "severity": "high", + "title": "gem vendor target" + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG_SLUG}/patches/by-package/{GEM_ENCODED}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "description": "Gem vendor patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {} + }], + "canAccessPaidPatches": false, + }))) + .mount(mock) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{GEM_UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": GEM_UUID, + "purl": GEM_PURL, + "publishedAt": "2026-01-01T00:00:00Z", + "files": { + "lib/padlock.rb": { + "beforeHash": before_hash, + "afterHash": after_hash, + "blobContent": AFTER_B64, + } + }, + "vulnerabilities": { + "GHSA-dddd-eeee-ffff": { + "cves": ["CVE-2026-0002"], + "summary": "gem test vuln", + "severity": "high", + "description": "details" + } + }, + "description": "Gem vendor patch", + "license": "MIT", + "tier": "free", + }))) + .mount(mock) + .await; +} + +const CARGO_UUID: &str = "33333333-3333-4333-8333-333333333333"; +const CARGO_PURL: &str = "pkg:cargo/padcrate@1.0.0"; + +/// Synthesize a healthy, detached, DIR-shaped cargo ledger entry with no +/// fileInventory into an existing project: artifact dir + embedded record +/// whose afterHash matches the tree. The cargo backend records no +/// inventories (yet), so this is exactly the population the +/// vendor_inventory_missing warning must NOT nag about. +fn add_healthy_cargo_dir_entry(root: &Path) -> PathBuf { + let rel = format!(".socket/vendor/cargo/{CARGO_UUID}/padcrate-1.0.0"); + let dir = root.join(&rel); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write(dir.join("src/lib.rs"), AFTER).unwrap(); + let state_path = root.join(".socket/vendor/state.json"); + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][CARGO_PURL] = serde_json::json!({ + "ecosystem": "cargo", + "basePurl": CARGO_PURL, + "uuid": CARGO_UUID, + "artifact": { "path": rel }, + "wiring": [], + "detached": true, + "record": { + "uuid": CARGO_UUID, + "exportedAt": "2026-01-01T00:00:00Z", + "files": { + "src/lib.rs": { + "beforeHash": git_sha256(BEFORE), + "afterHash": git_sha256(AFTER), + } + }, + "vulnerabilities": {}, + "description": "cargo dir fixture", + "license": "MIT", + "tier": "free", + } + }); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + dir +} + +/// `scan --vendor --yes` the gem fixture; returns the vendored copy dir. +fn vendor_gem_project(root: &Path, mock_uri: &str) -> PathBuf { + let (code, stdout, stderr) = run_cli(root, mock_uri, &["scan", "--vendor", "--yes"]); + assert_eq!(code, 0, "gem vendor setup failed: {stdout} {stderr}"); + let copy = root.join(gem_copy_rel()); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).expect("vendored lib"), + AFTER, + "setup must vendor the patched copy" + ); + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).expect("stub gemspec"), + GEMSPEC_STUB + ); + copy +} + +/// G1. Ledger deleted, wired pair + artifact survive: repair reconstructs +/// the ENTRY — wiring included — byte-identically to the original +/// ledger, and a subsequent `vendor --revert` byte-restores Gemfile and +/// Gemfile.lock and removes the artifact. RED without wiring +/// reconstruction: the revert "succeeds" silently while both files keep +/// pointing at the deleted dir. +#[tokio::test] +async fn repair_reconstructs_gem_wiring_and_revert_byte_restores() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let gemfile_before = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + let lock_before = std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state_before = std::fs::read(&state_path).unwrap(); + // Anti-vacuity: the pair is actually wired before the ledger loss. + let wired_gemfile = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + assert_ne!(wired_gemfile, gemfile_before, "Gemfile must be wired"); + + std::fs::remove_file(&state_path).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "rebuilt" && e["details"]["ledgerRestored"] == true), + "envelope={v}" + ); + assert!( + !events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_wiring_unknown"), + "gem wiring IS reconstructable — no unknown-wiring warning: {v}" + ); + // THE oracle: the reconstructed ledger equals the original, wiring, + // fileInventory and all (deterministic sorted serialization). + assert_eq!( + std::fs::read(&state_path).unwrap(), + state_before, + "reconstructed state.json must be byte-identical to the original" + ); + + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "revert after reconstruction: {stdout}"); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile")).unwrap(), + gemfile_before, + "Gemfile byte-restored" + ); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(), + lock_before, + "Gemfile.lock byte-restored" + ); + assert!(!copy.exists(), "artifact dir removed"); + assert!( + !tmp.path().join(".socket/vendor").exists(), + "fully-reverted project carries no vendor residue" + ); +} + +/// G1b. Same reconstruction on a bundler ≥ 2.6 CHECKSUMS lock: the +/// pre-vendor `sha256=` token is not offline-recoverable, so repair +/// surfaces `vendor_checksum_unrecoverable` and the revert restores +/// everything EXCEPT that one line, which stays in bundler's bare form +/// (a plain `bundle install` refills it — verified on 4.0.15). +#[tokio::test] +async fn repair_reconstruction_flags_unrecoverable_gem_checksum() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), true); + let lock_before = std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(); + let gemfile_before = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + vendor_gem_project(tmp.path(), &mock.uri()); + + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "vendor_checksum_unrecoverable"), + "the unrecoverable sha256 must be surfaced: {v}" + ); + + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "revert: {stdout}"); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile")).unwrap(), + gemfile_before + ); + let lock_after = std::fs::read_to_string(tmp.path().join("Gemfile.lock")).unwrap(); + let expected = lock_before.replace( + &format!(" {GEM_NAME} ({GEM_VERSION}) sha256={}\n", "e".repeat(64)), + &format!(" {GEM_NAME} ({GEM_VERSION})\n"), + ); + assert_ne!(expected, lock_before, "fixture must carry the sha256 line"); + assert_eq!( + lock_after, expected, + "everything byte-restored except the bare CHECKSUMS entry" + ); +} + +/// G1c. No-ledger restore must NEVER canonize a tampered tree +/// (trust-on-first-use): an UNPATCHED file in the vendored gem dir is +/// tampered and the ledger deleted. The re-synthesized entry has no +/// fileInventory, so the health check sees only the patched members +/// (Healthy) — and no npm-family lock records an integrity for a gem +/// dir. Repair must derive the canonical fingerprint from a +/// member-verified LOCAL REBUILD (installed copy + recorded patch), +/// healing the tamper; the restored ledger is byte-identical to the +/// pre-tamper original. RED without the fix: the LIVE dir was +/// fingerprinted into the restored ledger — the tampered gemspec +/// survives as the canonical tree later repairs enforce and VEX +/// attests. +#[tokio::test] +async fn repair_no_ledger_restore_never_canonizes_tampered_gem_tree() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state_before = std::fs::read(&state_path).unwrap(); + + // Tamper an UNPATCHED member (the stub gemspec); the patched member + // keeps its AFTER bytes so member-only verification still passes. + let tampered = b"Gem::Specification.new do |s|\n s.name = \"padlock\"\n s.version = \"1.2.0\"\n s.require_paths = [\"lib\", \"exfil\"]\nend\n"; + std::fs::write(copy.join("padlock.gemspec"), tampered).unwrap(); + std::fs::remove_file(&state_path).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + // The tamper is healed: the rebuild reproduced the pristine stub... + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + GEMSPEC_STUB, + "the tampered unpatched file must be rebuilt, not kept: {v}" + ); + assert_eq!(std::fs::read(copy.join("lib/padlock.rb")).unwrap(), AFTER); + // ...and the restored ledger equals the pre-tamper original — the + // tampered tree was never fingerprinted in. + assert_eq!( + std::fs::read(&state_path).unwrap(), + state_before, + "reconstructed state.json must equal the pre-tamper original" + ); + // A later repair finds the canonical tree healthy — nothing to rebuild. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v2 = parse_env(&stdout); + assert!( + !events_of(&v2).iter().any(|e| e["action"] == "rebuilt"), + "second repair must find nothing to rebuild: {v2}" + ); +} + +/// G1d. Same no-ledger tamper, but NO trustworthy rebuild source exists +/// (the installed copy is gone, and a reconstructed gem entry records +/// no pre-vendor registry checksum to fetch by): repair must restore +/// the ledger entry WITHOUT a fileInventory — surfacing +/// `vendor_inventory_unverified` — so later runs stay in the legacy +/// member-only-warn state (`vendor_inventory_missing`) instead of +/// enforcing the tampered live tree as canonical. RED without the +/// fix: the entry carries an inventory hashing the tampered bytes, +/// no warning fires, and later repairs report fully Healthy. +#[tokio::test] +async fn repair_no_ledger_restore_without_pristine_source_stays_unverified() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + let tampered = b"tampered unpatched member\n"; + std::fs::write(copy.join("padlock.gemspec"), tampered).unwrap(); + std::fs::remove_file(tmp.path().join(".socket/vendor/state.json")).unwrap(); + // No pristine source: the installed copy is gone, and the reconstructed + // wiring carries no CHECKSUMS sha256 (offline-unrecoverable, see G1b). + std::fs::remove_dir_all(tmp.path().join("vendor/bundle")).unwrap(); + + mount_blob(&mock).await; + let (code, stdout, stderr) = run_cli( + tmp.path(), + &mock.uri(), + &["repair", "--download-mode", "file"], + ); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["errorCode"] == "vendor_inventory_unverified"), + "the unverifiable fingerprint must be surfaced: {v}" + ); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "rebuilt" && e["details"]["ledgerRestored"] == true), + "the ledger entry is still restored: {v}" + ); + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + assert!( + state["entries"][GEM_PURL]["artifact"]["fileInventory"].is_null(), + "the tampered live tree must NOT be fingerprinted into the ledger: {state}" + ); + assert!( + !state["entries"][GEM_PURL]["wiring"] + .as_array() + .unwrap_or(&Vec::new()) + .is_empty(), + "the reconstructed wiring is still persisted: {state}" + ); + // No pristine source: repair must not have invented bytes either. + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + tampered.as_slice(), + "the artifact is left as-is in the legacy member-only state" + ); + // Later repairs keep naming the gap instead of enforcing the tampered + // tree as canonical. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v2 = parse_env(&stdout); + assert!( + events_of(&v2) + .iter() + .any(|e| e["errorCode"] == "vendor_inventory_missing"), + "later repairs stay in the legacy-warn state: {v2}" + ); +} + +/// G2. Empty-wiring gem entry (a reconstructed ledger without recoverable +/// originals, synthesized here): `vendor --revert` must FAIL loudly — +/// naming vendor_wiring_unknown — and keep the artifact and both files +/// untouched. RED without the guard: exit 0, artifact deleted, pair +/// stranded on a dead dir. +#[tokio::test] +async fn revert_of_empty_wiring_gem_entry_fails_loudly() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + let state_path = tmp.path().join(".socket/vendor/state.json"); + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][GEM_PURL]["wiring"] = serde_json::json!([]); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + + let gemfile_wired = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + let lock_wired = std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(); + + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 1, "empty-wiring revert must fail: {stdout}"); + let v = parse_env(&stdout); + let failed = events_of(&v) + .into_iter() + .find(|e| e["action"] == "failed" && e["purl"] == GEM_PURL) + .unwrap_or_else(|| panic!("expected a failed event: {v}")); + assert_eq!(failed["errorCode"], "revert_failed", "{failed}"); + assert!( + failed["error"] + .as_str() + .unwrap_or("") + .contains("vendor_wiring_unknown"), + "the machine tag must be named: {failed}" + ); + assert!( + copy.join("lib/padlock.rb").is_file(), + "the artifact must NOT be deleted" + ); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile")).unwrap(), + gemfile_wired, + "Gemfile untouched" + ); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(), + lock_wired, + "Gemfile.lock untouched" + ); +} + +/// G2b. The same empty-wiring population, healed at the repair seam: a +/// LEDGERED gem entry whose wiring is empty (pre-reconstruction repairs +/// persisted exactly these) gets full revert-capable wiring backfilled +/// from the live pair while the artifact is healthy — byte-identical to +/// the original ledger for the exact-pin fixture — and the revert that +/// used to refuse (G2) byte-restores both files. RED without the pass-1 +/// backfill: repair exits 0 leaving `"wiring": []`, no wiringRestored +/// event, and the revert fails. +#[tokio::test] +async fn repair_backfills_wiring_for_empty_wiring_gem_entry() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let gemfile_before = std::fs::read(tmp.path().join("Gemfile")).unwrap(); + let lock_before = std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let state_before = std::fs::read(&state_path).unwrap(); + + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][GEM_PURL]["wiring"] = serde_json::json!([]); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "rebuilt" + && e["purl"] == GEM_PURL + && e["details"]["wiringRestored"] == true + && e["details"]["artifactRebuilt"] == false), + "envelope={v}" + ); + // THE oracle: the backfilled ledger equals the original byte-for-byte + // (the exact-pin fixture reconstructs losslessly). + assert_eq!( + std::fs::read(&state_path).unwrap(), + state_before, + "backfilled state.json must be byte-identical to the original" + ); + + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["vendor", "--revert"]); + assert_eq!(code, 0, "revert after backfill: {stdout}"); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile")).unwrap(), + gemfile_before, + "Gemfile byte-restored" + ); + assert_eq!( + std::fs::read(tmp.path().join("Gemfile.lock")).unwrap(), + lock_before, + "Gemfile.lock byte-restored" + ); + assert!(!copy.exists(), "artifact dir removed"); +} + +/// G3. Dir-shaped tamper matrix: an altered UNPATCHED file (the stub +/// gemspec), a deleted file, and a planted extra file must each flip +/// the health check to Corrupt — repair rebuilds the exact recorded +/// tree — and VEX refuses to attest while tampered. RED without the +/// fileInventory: every arm was blessed Healthy and attested. +#[tokio::test] +async fn repair_gem_dir_tamper_matrix_and_vex_refusal() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + // Anti-vacuity: the ledger records the whole-tree inventory. + let state: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(tmp.path().join(".socket/vendor/state.json")).unwrap(), + ) + .unwrap(); + let inventory = &state["entries"][GEM_PURL]["artifact"]["fileInventory"]; + assert_eq!( + inventory["padlock.gemspec"], + sha256_hex(GEMSPEC_STUB), + "state={state}" + ); + assert_eq!(inventory["lib/padlock.rb"], sha256_hex(AFTER)); + + let tamper: [&dyn Fn(); 3] = [ + &|| std::fs::write(copy.join("padlock.gemspec"), b"tampered stub\n").unwrap(), + &|| std::fs::remove_file(copy.join("padlock.gemspec")).unwrap(), + &|| std::fs::write(copy.join("lib/evil.rb"), b"payload\n").unwrap(), + ]; + for (i, arm) in tamper.iter().enumerate() { + arm(); + + // VEX refuses while tampered (the patched member still verifies — + // only the inventory knows). + let vex_path = tmp.path().join("out.vex.json"); + let (code, stdout, _) = run_cli( + tmp.path(), + &mock.uri(), + &[ + "vex", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:gem/app@1.0.0", + ], + ); + assert_eq!(code, 1, "arm {i}: tampered dir must not attest: {stdout}"); + let venv = parse_env(&stdout); + assert!( + events_of(&venv) + .iter() + .any(|e| e["action"] == "skipped" && e["errorCode"] == "vendor_inventory_mismatch"), + "arm {i}: envelope={venv}" + ); + assert!(!vex_path.exists(), "arm {i}: no VEX doc while tampered"); + + // Repair heals: Corrupt → deterministic rebuild of the recorded tree. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "arm {i}: stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v).iter().any(|e| e["action"] == "rebuilt" + && e["purl"] == GEM_PURL + && e["details"]["reason"] == "vendor_artifact_corrupt"), + "arm {i}: envelope={v}" + ); + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + GEMSPEC_STUB, + "arm {i}: stub byte-restored" + ); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).unwrap(), + AFTER, + "arm {i}: patched member intact" + ); + assert!( + !copy.join("lib/evil.rb").exists(), + "arm {i}: planted file removed" + ); + + // And VEX attests again after the heal. + let (code, _, _) = run_cli( + tmp.path(), + &mock.uri(), + &[ + "vex", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:gem/app@1.0.0", + ], + ); + assert_eq!(code, 0, "arm {i}: healed artifact attests"); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1); + std::fs::remove_file(&vex_path).unwrap(); + } +} + +/// G3c. A service-vendored entry records the SERVICE tree's inventory (its +/// converter-generated stub gemspec differs byte-wise from the local +/// stub), but repair always rebuilds LOCALLY. The member-verified local +/// rebuild must refresh the stale inventory — loudly, with the +/// provenance named — instead of deleting the rebuild and stranding the +/// wired pair on a dead dir. RED without the refresh: exit 1 +/// vendor_artifact_rebuild_failed, artifact gone, and every subsequent +/// repair loops the same failure. +#[tokio::test] +async fn repair_refreshes_stale_inventory_from_service_provenance() { + const SERVICE_STUB: &[u8] = b"# converter-generated stub\nGem::Specification.new do |s|\n s.name = \"padlock\"\n s.version = \"1.2.0\"\n s.require_paths = [\"lib\"]\nend\n"; + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + // Simulate service provenance: the on-disk stub and the recorded + // inventory BOTH carry the converter-generated form (they agree), which + // a LOCAL rebuild cannot reproduce. + std::fs::write(copy.join("padlock.gemspec"), SERVICE_STUB).unwrap(); + let state_path = tmp.path().join(".socket/vendor/state.json"); + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][GEM_PURL]["artifact"]["fileInventory"]["padlock.gemspec"] = + serde_json::json!(sha256_hex(SERVICE_STUB)); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + + // Anti-vacuity: the simulated service state is self-consistent. + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "{stdout}"); + let v = parse_env(&stdout); + assert!( + v["summary"]["rebuilt"].is_null() || v["summary"]["rebuilt"] == 0, + "the simulated service tree must be healthy: {v}" + ); + + std::fs::remove_dir_all(©).unwrap(); + + // THE pin: the local rebuild's stub differs from the recorded + // inventory; repair keeps the member-verified rebuild and refreshes + // the inventory rather than deleting it and failing forever. + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "rebuilt" && e["purl"] == GEM_PURL), + "envelope={v}" + ); + assert!( + events_of(&v).iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_inventory_refreshed" + && e["purl"] == GEM_PURL), + "the provenance switch must be surfaced: {v}" + ); + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + GEMSPEC_STUB, + "the local rebuild's stub is kept" + ); + assert_eq!( + std::fs::read(copy.join("lib/padlock.rb")).unwrap(), + AFTER, + "patched member intact" + ); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["entries"][GEM_PURL]["artifact"]["fileInventory"]["padlock.gemspec"], + serde_json::json!(sha256_hex(GEMSPEC_STUB)), + "inventory refreshed from the verified rebuild: {state}" + ); + + // The loop is dead: the next repair is clean. + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "{stdout}"); + let v = parse_env(&stdout); + assert!( + v["summary"]["rebuilt"].is_null() || v["summary"]["rebuilt"] == 0, + "no repair loop: {v}" + ); + assert!( + !events_of(&v).iter().any(|e| e["action"] == "failed"), + "no repair loop: {v}" + ); +} + +/// G3b. Backward tolerance: a pre-inventory ledger entry (fileInventory +/// stripped) keeps today's member-only verdict on the same tamper — +/// no rebuild, exit 0 — but repair names the gap +/// (vendor_inventory_missing) instead of staying silent. The warning +/// is GEM-only: a healthy inventory-less cargo dir entry (that backend +/// records no inventories, so "re-vendor" could never silence it) must +/// produce no events at all. +#[tokio::test] +async fn repair_warns_on_legacy_gem_entry_without_inventory() { + let mock = MockServer::start().await; + mount_gem_patch_api(&mock).await; + let tmp = tempfile::tempdir().unwrap(); + write_gem_fixture(tmp.path(), false); + let copy = vendor_gem_project(tmp.path(), &mock.uri()); + + let state_path = tmp.path().join(".socket/vendor/state.json"); + let mut state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + state["entries"][GEM_PURL]["artifact"] + .as_object_mut() + .unwrap() + .remove("fileInventory") + .expect("the fixture entry must have recorded an inventory"); + std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap(); + let cargo_dir = add_healthy_cargo_dir_entry(tmp.path()); + + std::fs::write(copy.join("padlock.gemspec"), b"tampered stub\n").unwrap(); + + let (code, stdout, stderr) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let v = parse_env(&stdout); + assert!( + v["summary"]["rebuilt"].is_null() || v["summary"]["rebuilt"] == 0, + "legacy entries keep member-only behavior (no rebuild): {v}" + ); + let missing: Vec<_> = events_of(&v) + .into_iter() + .filter(|e| e["errorCode"] == "vendor_inventory_missing") + .collect(); + assert_eq!( + missing.len(), + 1, + "only the gem entry warns about the inventory gap: {v}" + ); + assert_eq!(missing[0]["purl"], GEM_PURL, "envelope={v}"); + assert!( + !events_of(&v).iter().any(|e| e["purl"] == CARGO_PURL), + "the healthy inventory-less cargo entry stays silent: {v}" + ); + assert_eq!( + std::fs::read(copy.join("padlock.gemspec")).unwrap(), + b"tampered stub\n", + "member-only verification cannot see the tamper (documented legacy gap)" + ); + + // Anti-vacuity for the silence above: the cargo entry IS health-checked + // — break its artifact and the same repair pipeline must surface it. + std::fs::remove_dir_all(&cargo_dir).unwrap(); + let (code, stdout, _) = run_cli(tmp.path(), &mock.uri(), &["repair"]); + assert_eq!(code, 1, "a missing cargo artifact must fail: {stdout}"); + let v = parse_env(&stdout); + assert!( + events_of(&v) + .iter() + .any(|e| e["action"] == "failed" && e["purl"] == CARGO_PURL), + "the cargo entry is live in pass 1: {v}" + ); +} + /// Offline with a broken artifact and NO local sources: a calm, loud, /// per-entry failure naming the purl and the path; exit 1. #[tokio::test] diff --git a/crates/socket-patch-cli/tests/setup_contract_gaps.rs b/crates/socket-patch-cli/tests/setup_contract_gaps.rs index 02c3f579..af650117 100644 --- a/crates/socket-patch-cli/tests/setup_contract_gaps.rs +++ b/crates/socket-patch-cli/tests/setup_contract_gaps.rs @@ -233,6 +233,7 @@ fn setup_vendored_fixture(proj: &Path, home: &Path, installed: &[u8], vendored: sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 294377a4..4434e2c9 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -254,6 +254,7 @@ pub(crate) async fn vendor_bun( sha256: packed.sha256_hex, size: Some(packed.size), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 469a4f37..3be6c232 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -777,6 +777,7 @@ pub async fn vendor_cargo_crate( sha256: String::new(), // dir-shaped: integrity is per-file afterHashes size: None, platform_locked: None, + file_inventory: None, }, wiring, lock: lock_original, diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 969e5444..86b0f41e 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -328,6 +328,7 @@ pub async fn vendor_composer( sha256: String::new(), // dir-shaped: integrity is per-file afterHashes size: None, platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: COMPOSER_LOCK.to_string(), diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 2e5c47a2..26b21d2c 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -554,15 +554,36 @@ pub async fn vendor_gem( } } + // Whole-tree inventory of the committed copy (stub gemspec included): + // no lockfile integrity covers a path source's bytes, so this is the + // only whole-artifact drift/tamper anchor verify/VEX/repair have for a + // dir-shaped artifact. Fail-soft: an uninventoriable copy (symlink, + // non-UTF-8 name) vendors like a pre-inventory entry, with the gap + // surfaced here and again at repair time. + let file_inventory = match super::verify::compute_dir_inventory(©_dir).await { + Ok(inv) => Some(inv), + Err(detail) => { + warnings.push(VendorWarning::new( + "vendor_inventory_unrecorded", + format!( + "could not inventory the vendored copy for {name}@{version} ({detail}); \ + drift in its unpatched files will not be detectable" + ), + )); + None + } + }; + let entry = VendorEntry { ecosystem: "gem".to_string(), base_purl, uuid: record.uuid.clone(), artifact: VendorArtifact { path: copy_rel, - sha256: String::new(), // dir-shaped: integrity is per-file afterHashes + sha256: String::new(), // dir-shaped: whole-tree integrity is the inventory size: None, platform_locked: None, + file_inventory, }, wiring, lock: None, @@ -870,6 +891,28 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) let uuid_dir = project_root.join(&uuid_dir_rel); let mut warnings = Vec::new(); + // Fail-closed guard: an entry with NO wiring records (a ledger repair + // reconstructed without recoverable wiring, or a hand-stripped + // state.json) must not "succeed" by deleting the artifact — the + // Gemfile `path:` and the lock's PATH section would keep pointing at + // the removed dir and the next `bundle install` hard-fails. Refuse + // loudly with the manual cleanup steps instead. (Every entry + // `vendor_gem` records carries at least the Gemfile + lock records.) + if entry.wiring.is_empty() { + let name = parse_gem_purl(&entry.base_purl) + .map(|(n, _)| n) + .unwrap_or(""); + return RevertOutcome::failed(format!( + "vendor_wiring_unknown: the ledger records no wiring for `{name}` (a \ + reconstructed entry without recoverable originals); refusing to delete {} and \ + strand the pair edit — manually remove the `path:` option (or the socket-patch \ + managed block) for `{name}` from the Gemfile, restore its registry entry in \ + Gemfile.lock (or delete the lock and re-run `bundle install`), then delete \ + {uuid_dir_rel} and this state.json entry", + entry.artifact.path + )); + } + // Wiring is restored in reverse application order: lock first, Gemfile // last (the mirror image of vendor's Gemfile-then-lock). for w in entry.wiring.iter().rev() { @@ -928,6 +971,264 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) } } +// ── ledger reconstruction ─────────────────────────────────────────────────── + +/// Re-synthesize the wiring records for a gem entry whose ledger was lost +/// (`repair`'s no-ledger reconstruction), by recognizing this backend's OWN +/// emitted wiring in the live Gemfile + Gemfile.lock pair — the same +/// recognizers the re-vendor-new-uuid path trusts. Everything is +/// grammar-strict and fail-closed: any shape vendor does not write yields +/// `Err` and the caller keeps an empty-wiring entry (whose revert then +/// refuses loudly) instead of guessing. +/// +/// Three documented degradations, all inherent to a lost ledger: +/// +/// * a REWRITTEN declaration's pre-vendor line is reconstructed in the +/// canonical exact-pin form (`gem "", ""` + preserved +/// trailing options) — the user's original version constraint lived only +/// in the lost ledger, and the exact pin restores a consistent, +/// installable pair resolving to the same version; +/// * a trailing `#` comment on the pre-vendor gem line is unrecoverable: +/// vendor's exact-pin rewrite drops it (the verbatim line lived only in +/// the lost ledger), so the reconstructed original restores the line +/// comment-less; +/// * a CHECKSUMS `sha256=` token is NOT recomputable offline, so no +/// checksum record is emitted: revert leaves bundler's bare path-gem +/// entry, which a non-frozen `bundle install` refills byte-identically +/// (bundler 4.0.15 verified; frozen installs fail with a self-explanatory +/// `empty CHECKSUMS entry` message until then) — surfaced as the +/// `vendor_checksum_unrecoverable` warning. The warning is deliberately +/// conservative: a gem whose PRE-vendor CHECKSUMS entry was already bare +/// (real for file-sourced gems — bundler 4.0.15 writes no `sha256=` for +/// them; vendor then records no checksum wiring at all and the bare-line +/// revert is byte-perfect) is indistinguishable from a lost token, so it +/// warns too. +/// +/// The transitive-vs-declared split is recovered from the Gemfile form: +/// vendor appends the managed fence exactly when the gem was undeclared, +/// which is also exactly when the pre-vendor DEPENDENCIES entry was absent. +pub async fn reconstruct_gem_wiring( + project_root: &Path, + entry: &VendorEntry, +) -> Result<(Vec, Vec), String> { + let Some((name, version)) = parse_gem_purl(&entry.base_purl) else { + return Err(format!("not a gem purl: {}", entry.base_purl)); + }; + // SECURITY: the coordinates come from a re-synthesized entry + // (manifest/API purl) and are matched against Gemfile/lock line + // grammar — the same fail-closed token guard as `vendor_gem`. + if !is_safe_single_segment(name) + || !is_safe_single_segment(version) + || !is_plain_gem_token(name) + || !is_plain_gem_token(version) + { + return Err(format!("unsafe gem coordinates `{name}` @ `{version}`")); + } + let rel = entry.artifact.path.replace('\\', "/"); + let leaf = format!("{name}-{version}"); + match parse_vendor_path(&rel) { + Some(p) if p.eco == "gem" && p.uuid == entry.uuid && p.leaf == leaf => {} + _ => { + return Err(format!( + "artifact path `{rel}` is not this entry's canonical vendored dir" + )); + } + } + + // ── Gemfile: exactly one declaration, carrying OUR `path:` ─────────── + let gemfile_text = tokio::fs::read_to_string(project_root.join(GEMFILE)) + .await + .map_err(|e| format!("unreadable Gemfile: {e}"))?; + let lines: Vec<&str> = gemfile_text.split('\n').collect(); + let mut found: Option = None; + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with('#') { + continue; + } + if gem_declaration(trimmed, name).is_some() && found.replace(i).is_some() { + return Err(format!( + "`gem \"{name}\"` is declared more than once in the Gemfile" + )); + } + } + let Some(idx) = found else { + return Err(format!("the Gemfile does not declare `{name}`")); + }; + let line = lines[idx]; + if line.trim_start().len() != line.len() { + return Err(format!( + "the `gem \"{name}\"` declaration is indented — not the wiring vendor writes" + )); + } + let managed = idx > 0 + && lines[idx - 1] == MANAGED_OPEN + && lines.get(idx + 1).is_some_and(|l| *l == MANAGED_CLOSE); + let gemfile_record = if managed { + // The Append plan always emits exactly this double-quoted, + // option-free line; revert deletes the whole fenced block. + if line != format!("gem \"{name}\", \"{version}\", path: \"{rel}\"") { + return Err(format!( + "the managed block line for `{name}` is not the form vendor writes" + )); + } + WiringRecord { + file: GEMFILE.to_string(), + kind: GEMFILE_WIRING_KIND.to_string(), + action: WiringAction::Added, + key: Some(name.to_string()), + original: None, + new: Some(Value::String(format!( + "{MANAGED_OPEN}\n{line}\n{MANAGED_CLOSE}\n" + ))), + } + } else { + let de_vendored = devendored_gem_line(line, name, version, &rel).ok_or_else(|| { + format!( + "the `gem \"{name}\"` line is not the exact-pin + `path:` form vendor \ + writes; its pre-vendor original cannot be reconstructed" + ) + })?; + WiringRecord { + file: GEMFILE.to_string(), + kind: GEMFILE_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(name.to_string()), + original: Some(Value::String(de_vendored)), + new: Some(Value::String(line.to_string())), + } + }; + + // ── Gemfile.lock: our PATH section + the `!` DEPENDENCIES pin ──────── + let lock_text = tokio::fs::read_to_string(project_root.join(GEMFILE_LOCK)) + .await + .map_err(|e| format!("unreadable Gemfile.lock: {e}"))?; + let lock_lines: Vec = lock_text.split('\n').map(str::to_string).collect(); + let (ps, pe) = find_our_path_section(&lock_lines, name, version) + .ok_or_else(|| format!("Gemfile.lock has no PATH section wiring `{name}`"))?; + let remote_line = format!(" remote: {rel}"); + let target = format!(" {name} ({version})"); + let block_start = (ps..pe) + .find(|&i| lock_lines[i] == target) + .ok_or_else(|| format!("the PATH section for `{name}` lost its spec entry"))?; + let mut block_end = block_start + 1; + while block_end < pe && lock_lines[block_end].starts_with(" ") { + block_end += 1; + } + // Grammar-strict, mirroring the re-vendor rewire guard: besides the + // spec block, the section must be exactly what vendor wrote — and its + // remote must be THIS entry's uuid dir, not some other patch's. + let non_block: Vec<&str> = (ps..pe) + .filter(|i| !(block_start..block_end).contains(i)) + .map(|i| lock_lines[i].as_str()) + .filter(|l| !l.is_empty()) + .collect(); + if non_block.len() != 3 + || non_block[0] != "PATH" + || non_block[1] != remote_line + || non_block[2] != " specs:" + { + return Err(format!( + "the Gemfile.lock PATH section for `{name} ({version})` is not the shape \ + vendor writes" + )); + } + let new_dep_line = format!(" {name} (= {version})!"); + let (ds, de) = section_span(&lock_lines, "DEPENDENCIES") + .ok_or_else(|| "Gemfile.lock has no DEPENDENCIES section".to_string())?; + if !(ds..de).any(|i| lock_lines[i] == new_dep_line) { + return Err(format!( + "Gemfile.lock DEPENDENCIES lacks the `{name} (= {version})!` pin vendor writes" + )); + } + + let block = &lock_lines[block_start..block_end]; + let mut new_lines: Vec = vec![ + Value::String("PATH".to_string()), + Value::String(remote_line), + Value::String(" specs:".to_string()), + ]; + new_lines.extend(block.iter().map(|l| Value::String(l.clone()))); + new_lines.push(Value::String(new_dep_line)); + let mut original_lines: Vec = block.iter().map(|l| Value::String(l.clone())).collect(); + if !managed { + // Declared gem ⇒ DEPENDENCIES carried an entry pre-vendor. The + // canonical exact-pin form pairs with the reconstructed Gemfile + // line (see the function docs for the degradation contract). + original_lines.push(Value::String(format!(" {name} (= {version})"))); + } + let lock_record = WiringRecord { + file: GEMFILE_LOCK.to_string(), + kind: LOCK_WIRING_KIND.to_string(), + action: WiringAction::Rewritten, + key: Some(name.to_string()), + original: Some(Value::Array(original_lines)), + new: Some(Value::Array(new_lines)), + }; + + // ── CHECKSUMS: bare path-form entry expected; sha256 unrecoverable ─── + let mut warnings: Vec = Vec::new(); + if let Some((cs, ce)) = section_span(&lock_lines, "CHECKSUMS") { + let bare = format!(" {name} ({version})"); + for line in &lock_lines[cs + 1..ce] { + match checksum_entry(line) { + Some((n, v)) if n == name && v == version => { + if line.as_str() != bare { + return Err(format!( + "Gemfile.lock CHECKSUMS still carries a registry `sha256=` \ + entry for `{name} ({version})` while the lock is path-wired \ + — not a state vendor writes; re-resolve the lock before \ + repairing" + )); + } + warnings.push(VendorWarning::new( + "vendor_checksum_unrecoverable", + format!( + "the pre-vendor CHECKSUMS `sha256=` line for {name} ({version}) \ + is not recoverable from a reconstructed ledger; after `vendor \ + --revert`, run a non-frozen `bundle install` once to refill it \ + (frozen installs fail on the empty entry until then)" + ), + )); + } + Some(_) => {} + None if checksum_line_names_gem(line, name) => { + return Err(format!( + "Gemfile.lock CHECKSUMS entry for `{name}` is not parseable: {line:?}" + )); + } + None => {} + } + } + } + + Ok((vec![gemfile_record, lock_record], warnings)) +} + +/// Strip our `path:` option back out of a line the exact-pin rewrite +/// emitted: `gem {q}{name}{q}, {q}{version}{q}, path: {q}{rel}{q}[, opts]` → +/// `gem {q}{name}{q}, {q}{version}{q}[, opts]`. `None` for any other shape +/// (fail-closed), including trailing options that would re-select a source. +fn devendored_gem_line(line: &str, name: &str, version: &str, rel: &str) -> Option { + for q in ['"', '\''] { + let head = format!("gem {q}{name}{q}, {q}{version}{q}"); + let with_path = format!("{head}, path: {q}{rel}{q}"); + if line == with_path { + return Some(head); + } + if let Some(opts) = line + .strip_prefix(with_path.as_str()) + .and_then(|t| t.strip_prefix(", ")) + { + if opts.is_empty() || rest_blocks_edit(&format!(", {opts}")).is_some() { + return None; + } + return Some(format!("{head}, {opts}")); + } + } + None +} + // ── Gemfile editing ────────────────────────────────────────────────────────── /// The planned Gemfile edit. @@ -4006,4 +4307,296 @@ mod tests { let (code, _) = unwrap_refused(outcome); assert_eq!(code, "vendor_service_offline_conflict"); } + + // ── ledger reconstruction ──────────────────────────────────────────── + + const GEMFILE_PINNED: &str = + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"3.2.6\"\n"; + const LOCK_PINNED: &str = "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n rack (= 3.2.6)\n\nBUNDLED WITH\n 2.5.22\n"; + + /// STRONG oracle for an exact-pin declaration: reconstruction from the + /// live wired pair must reproduce vendor's own recorded wiring + /// byte-for-byte — and reverting with the reconstructed records must + /// byte-restore both files, exactly like the real ledger would. + #[tokio::test] + async fn reconstruction_reproduces_vendor_wiring_for_pinned_declaration() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + let (wiring, warnings) = reconstruct_gem_wiring(&root, &entry).await.unwrap(); + assert_eq!( + wiring, entry.wiring, + "reconstructed wiring must equal what vendor recorded" + ); + assert!( + warnings.is_empty(), + "no CHECKSUMS section, no degradation notes: {warnings:?}" + ); + + // Revert with ONLY the reconstructed records: byte-restored pair, + // artifact gone. + let mut synth = entry.clone(); + synth.wiring = wiring; + let outcome = revert_gem(&synth, &root, false).await; + assert!(outcome.success, "revert failed: {:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_PINNED + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + LOCK_PINNED + ); + assert!(!root.join(copy_rel()).exists(), "artifact removed"); + } + + /// Transitive gem (managed fence): the reconstructed Added record must + /// equal vendor's, and the lock original must carry NO dependencies + /// entry (revert deletes the added pin). + #[tokio::test] + async fn reconstruction_reproduces_vendor_wiring_for_managed_block() { + let (_tmp, root, installed, blobs, record) = + fixture(GEMFILE_TRANSITIVE, LOCK_TRANSITIVE).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + let (wiring, _) = reconstruct_gem_wiring(&root, &entry).await.unwrap(); + assert_eq!(wiring, entry.wiring); + + let mut synth = entry.clone(); + synth.wiring = wiring; + let outcome = revert_gem(&synth, &root, false).await; + assert!(outcome.success, "revert failed: {:?}", outcome.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_TRANSITIVE + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), + LOCK_TRANSITIVE + ); + } + + /// The documented degradation: a RANGE constraint (`~> 3.1`) lived only + /// in the lost ledger, so the reconstructed originals pin the exact + /// locked version — a consistent, installable pair, hand-pinned here. + #[tokio::test] + async fn reconstruction_degrades_range_constraint_to_exact_pin() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + let (wiring, _) = reconstruct_gem_wiring(&root, &entry).await.unwrap(); + assert_eq!( + wiring[0].original, + Some(Value::String("gem \"rack\", \"3.2.6\"".to_string())), + "canonical exact pin, NOT the unrecoverable `~> 3.1`" + ); + let lock_original = wiring[1].original.as_ref().unwrap().as_array().unwrap(); + assert_eq!( + lock_original.last().unwrap(), + &Value::String(" rack (= 3.2.6)".to_string()), + "the DEPENDENCIES restore pairs with the pinned Gemfile line" + ); + + // The reverted pair is CONSISTENT (both halves pin 3.2.6). + let mut synth = entry.clone(); + synth.wiring = wiring; + let outcome = revert_gem(&synth, &root, false).await; + assert!(outcome.success, "revert failed: {:?}", outcome.error); + let gemfile = tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(); + assert!(gemfile.contains("gem \"rack\", \"3.2.6\"\n"), "{gemfile}"); + assert!(!gemfile.contains("path:"), "{gemfile}"); + let lock = tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(); + assert!(lock.contains("\n rack (= 3.2.6)\n"), "{lock}"); + assert!(!lock.contains("PATH"), "{lock}"); + } + + /// Trailing options ride the reconstruction (`require: false` dropped + /// on restore would auto-require the gem at boot). + #[tokio::test] + async fn reconstruction_preserves_trailing_options() { + let gemfile = + "source \"https://rubygems.org\"\n\ngem \"puma\"\ngem \"rack\", \"3.2.6\", require: false\n"; + let (_tmp, root, installed, blobs, record) = fixture(gemfile, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + let (wiring, _) = reconstruct_gem_wiring(&root, &entry).await.unwrap(); + assert_eq!( + wiring[0].original, + Some(Value::String( + "gem \"rack\", \"3.2.6\", require: false".to_string() + )) + ); + } + + /// A bare CHECKSUMS entry (bundler ≥ 2.6 lock): the `sha256=` token is + /// not offline-recoverable, so reconstruction emits NO checksum record + /// (revert leaves the bare line for a plain `bundle install` to refill + /// — bundler 4.0.15 verified) and surfaces the gap as a warning. + #[tokio::test] + async fn reconstruction_flags_unrecoverable_checksum() { + let lock = format!( + "GEM\n remote: https://rubygems.org/\n specs:\n puma (6.4.2)\n nio4r (~> 2.0)\n rack (3.2.6)\n base64 (>= 0.1.0)\n\nPLATFORMS\n arm64-darwin-23\n ruby\n\nDEPENDENCIES\n puma\n rack (= 3.2.6)\n\nCHECKSUMS\n puma (6.4.2) sha256={}\n rack (3.2.6) sha256={}\n\nBUNDLED WITH\n 2.5.22\n", + "a".repeat(64), + "b".repeat(64), + ); + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, &lock).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + assert_eq!(entry.wiring.len(), 3, "vendor recorded a checksum record"); + + let (wiring, warnings) = reconstruct_gem_wiring(&root, &entry).await.unwrap(); + assert_eq!( + wiring.len(), + 2, + "no checksum record — the sha256 is unrecoverable: {wiring:?}" + ); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert_eq!(warnings[0].code, "vendor_checksum_unrecoverable"); + } + + /// Fail-closed refusals: anything that is not vendor's own emitted + /// wiring yields `Err`, never guessed-at records. + #[tokio::test] + async fn reconstruction_refuses_foreign_or_mismatched_wiring() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + // A user fork's path: (not our vendored dir) in place of ours. + let gemfile_path = root.join(GEMFILE); + let wired = tokio::fs::read_to_string(&gemfile_path).await.unwrap(); + tokio::fs::write( + &gemfile_path, + wired.replace(©_rel(), "vendor/forks/rack"), + ) + .await + .unwrap(); + let err = reconstruct_gem_wiring(&root, &entry).await.unwrap_err(); + assert!(err.contains("exact-pin"), "{err}"); + tokio::fs::write(&gemfile_path, &wired).await.unwrap(); + + // A DIFFERENT patch uuid's dir wired in the pair: not this entry's. + let mut other = entry.clone(); + other.uuid = "11111111-2222-4333-8444-555555555555".to_string(); + other.artifact.path = + ".socket/vendor/gem/11111111-2222-4333-8444-555555555555/rack-3.2.6".to_string(); + let err = reconstruct_gem_wiring(&root, &other).await.unwrap_err(); + assert!( + err.contains("exact-pin") || err.contains("PATH section"), + "{err}" + ); + + // The lock lost the `!` pin. + let lock_path = root.join(GEMFILE_LOCK); + let wired_lock = tokio::fs::read_to_string(&lock_path).await.unwrap(); + tokio::fs::write( + &lock_path, + wired_lock.replace(" rack (= 3.2.6)!", " rack (= 3.2.6)"), + ) + .await + .unwrap(); + let err = reconstruct_gem_wiring(&root, &entry).await.unwrap_err(); + assert!(err.contains("(= 3.2.6)!"), "{err}"); + tokio::fs::write(&lock_path, &wired_lock).await.unwrap(); + + // A registry `sha256=` CHECKSUMS entry while path-wired (the stale + // pre-CHECKSUMS-aware state): never silently blessed. + let stale = format!( + "{wired_lock}\nCHECKSUMS\n rack (3.2.6) sha256={}\n", + "c".repeat(64) + ); + tokio::fs::write(&lock_path, stale).await.unwrap(); + let err = reconstruct_gem_wiring(&root, &entry).await.unwrap_err(); + assert!(err.contains("sha256"), "{err}"); + } + + /// The empty-wiring revert guard: a reconstructed entry without + /// recoverable wiring must FAIL loudly — deleting the artifact would + /// strand the Gemfile `path:` + lock PATH section on a dead dir. The + /// files and the artifact stay untouched. + #[tokio::test] + async fn revert_refuses_empty_wiring_entry() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let mut entry = entry.expect("wired entry"); + entry.wiring = Vec::new(); + + let gemfile_before = tokio::fs::read(root.join(GEMFILE)).await.unwrap(); + let lock_before = tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(); + for dry_run in [true, false] { + let outcome = revert_gem(&entry, &root, dry_run).await; + assert!(!outcome.success, "dry_run={dry_run}: must fail loudly"); + let err = outcome.error.expect("error detail"); + assert!(err.contains("vendor_wiring_unknown"), "{err}"); + assert!(err.contains("Gemfile"), "names the files to clean: {err}"); + } + assert!( + root.join(copy_rel()).join("lib/rack.rb").is_file(), + "the artifact must NOT be deleted" + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE)).await.unwrap(), + gemfile_before + ); + assert_eq!( + tokio::fs::read(root.join(GEMFILE_LOCK)).await.unwrap(), + lock_before + ); + } + + /// vendor records the whole-tree file inventory (patched lib + stub + /// gemspec) with hand-pinned plain-sha256 values. + #[tokio::test] + async fn vendor_records_dir_file_inventory() { + use sha2::{Digest, Sha256}; + + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_PINNED, LOCK_PINNED).await; + let (result, entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + let entry = entry.expect("wired entry"); + + let inventory = entry + .artifact + .file_inventory + .as_ref() + .expect("dir-shaped entries record an inventory"); + assert_eq!( + inventory.keys().collect::>(), + ["lib/rack.rb", "rack.gemspec"], + "sorted keys, gemspec included" + ); + assert_eq!( + inventory["lib/rack.rb"], + hex::encode(Sha256::digest(PATCHED)) + ); + assert_eq!( + inventory["rack.gemspec"], + hex::encode(Sha256::digest(GEMSPEC.as_bytes())) + ); + } } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index bb090387..b3aee680 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -292,6 +292,7 @@ pub async fn vendor_go_module( sha256: String::new(), // dir-shaped: integrity is per-file afterHashes size: None, platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: "go.mod".to_string(), diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index 8843ff13..91dc9da0 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -718,13 +718,23 @@ async fn inventory_composer_lock(project_root: &Path) -> Option Option> { let text = tokio::fs::read_to_string(project_root.join("Gemfile.lock")) .await .ok()?; - let mut remote: Option = None; + let mut section_remotes: Vec> = Vec::new(); let mut checksums: HashMap<(String, String), String> = HashMap::new(); - let mut specs: Vec<(String, String)> = Vec::new(); + let mut specs: Vec<(String, String, usize)> = Vec::new(); let mut section = ""; let mut in_specs = false; @@ -732,6 +742,9 @@ async fn inventory_gemfile_lock(project_root: &Path) -> Option Option Option Option http_url(&format!("{base}/downloads/{name}-{version}.gem")), + // No remote (a missing `remote:` line defaults to rubygems.org + // ONLY when the whole lock has one remote-less GEM section — + // the pre-multisource shape) or several remotes: fail closed. + Some([]) if section_remotes.len() == 1 => http_url(&format!( + "https://rubygems.org/downloads/{name}-{version}.gem" + )), + _ => None, + }; out.push(LockfileEntry { ecosystem: "gem", purl: format!("pkg:gem/{name}@{version}"), - resolved: http_url(&format!("{base}/downloads/{name}-{version}.gem")), + resolved, name, version, integrity, @@ -1090,16 +1114,35 @@ pub async fn recover_lock_entry( "the pre-vendor checksum line has no sha256; refusing an unverifiable fetch" .to_string() })?; - let base = gem_remote_base(project_root) - .await - .unwrap_or_else(|| "https://rubygems.org".to_string()); + let base = match gem_remotes(project_root).await.as_slice() { + [] => "https://rubygems.org".to_string(), + [one] => http_url(one).ok_or_else(|| { + // A lone non-http remote (file:// gem repo): the registry + // conventions cannot reproduce its bytes, and defaulting + // to rubygems.org would leak the gem name off-site. + format!( + "the Gemfile.lock's GEM remote ({one}) is not an http(s) registry; \ + refusing to fetch from a guessed remote" + ) + })?, + several => { + // The vendored spec's own GEM section is gone (it moved + // into the PATH section), so with several sources its + // origin is genuinely ambiguous — a guessed remote + // would 404 at best and leak a private gem name to the + // public registry at worst. + return Err(format!( + "Gemfile.lock lists multiple GEM sources ({}); the vendored gem's \ + pre-vendor source is ambiguous — refusing to fetch from a guessed \ + remote", + several.join(", ") + )); + } + }; Ok(LockfileEntry { ecosystem: "gem", purl: format!("pkg:gem/{name}@{version}"), - resolved: http_url(&format!( - "{}/downloads/{name}-{version}.gem", - base.trim_end_matches('/') - )), + resolved: http_url(&format!("{base}/downloads/{name}-{version}.gem")), name, version, integrity: LockIntegrity::Sha256Hex(sha.to_ascii_lowercase()), @@ -1400,27 +1443,41 @@ fn inline_yaml_field(line: &str, field: &str) -> Option { (!v.is_empty()).then_some(v) } -/// The `GEM remote:` base of the (unrewired) Gemfile.lock. -async fn gem_remote_base(project_root: &Path) -> Option { - let text = tokio::fs::read_to_string(project_root.join("Gemfile.lock")) - .await - .ok()?; +/// The DISTINCT `GEM remote:` bases across ALL GEM sections of the +/// Gemfile.lock (trailing `/` trimmed), in first-appearance order. A +/// vendored gem's spec block moved into its PATH section, so which GEM +/// section it came from is unrecoverable — ledger recovery may only build +/// a download URL when the lock's GEM sources agree on a single remote. +/// Collected scheme-AGNOSTICALLY: a non-http remote (a `file://` gem repo — +/// bundler 4.0.15 locks one GEM section per `source "file://…" do` block) +/// still counts toward the ambiguity decision; filtering it out first would +/// collapse a mixed http+file lock to one "agreed" remote and send the +/// file-sourced gem's name to the http one. The caller requires the single +/// survivor to be http(s). +async fn gem_remotes(project_root: &Path) -> Vec { + let Ok(text) = tokio::fs::read_to_string(project_root.join("Gemfile.lock")).await else { + return Vec::new(); + }; + let mut out: Vec = Vec::new(); let mut in_gem = false; for line in text.lines() { - if line.trim_end() == "GEM" { - in_gem = true; + if line.trim().is_empty() { + continue; + } + if !line.starts_with(' ') { + in_gem = line.trim_end() == "GEM"; continue; } if in_gem { if let Some(rest) = line.trim().strip_prefix("remote:") { - return http_url(rest.trim()); - } - if !line.starts_with(' ') && !line.trim().is_empty() { - in_gem = false; + let url = rest.trim().trim_end_matches('/').to_string(); + if !url.is_empty() && !out.contains(&url) { + out.push(url); + } } } } - None + out } /// First `{ url = "…", hash = "sha256:…" }` wheel in a uv.lock `[[package]]` @@ -2059,6 +2116,77 @@ checksum = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" assert!(!entries.iter().any(|e| e.name == "actionpack")); } + /// Multi-source lock (two GEM sections, the exact shape bundler 4.0.15 + /// writes for a Gemfile `source … do` block — fixture mirrors a real + /// `bundle lock --add-checksums` run): each spec must resolve against + /// its OWN section's remote, never the first remote in the file. + #[tokio::test] + async fn gemfile_lock_multi_source_resolves_each_spec_against_its_own_remote() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + &format!( + "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n rack (3.2.6)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n private-gem (= 1.0.0)!\n rack (= 3.2.6)\n\n\ + CHECKSUMS\n private-gem (1.0.0) sha256={}\n rack (3.2.6) sha256={}\n\n\ + BUNDLED WITH\n 4.0.15\n", + "a".repeat(64), + "b".repeat(64), + ), + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + assert_eq!( + entry(&entries, "private-gem").resolved.as_deref(), + Some("https://gems.corp.example/downloads/private-gem-1.0.0.gem"), + "first section's spec resolves against its own remote" + ); + assert_eq!( + entry(&entries, "rack").resolved.as_deref(), + Some("https://rubygems.org/downloads/rack-3.2.6.gem"), + "second section's spec must NOT inherit the first section's remote" + ); + // Both keep their CHECKSUMS integrity. + assert_eq!( + entry(&entries, "rack").integrity, + LockIntegrity::Sha256Hex("b".repeat(64)) + ); + } + + /// A GEM section with SEVERAL `remote:` lines is a legacy bundler 1.x + /// multisource lock (bundler ≥ 2 hard-errors on multiple global + /// sources — verified against 4.0.15): per-spec origin is ambiguous, + /// so its specs stay discovery-only — no guessed download URL, which + /// would leak private gem names to the public registry. + #[tokio::test] + async fn gemfile_lock_legacy_multi_remote_section_is_discovery_only() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "Gemfile.lock", + &format!( + "GEM\n remote: https://rubygems.org/\n remote: https://gems.corp.example/\n \ + specs:\n rack (3.0.8)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rack\n\n\ + CHECKSUMS\n rack (3.0.8) sha256={}\n", + "c".repeat(64), + ), + ) + .await; + + let entries = inventory_gemfile_lock(tmp.path()).await.unwrap(); + let rack = entry(&entries, "rack"); + assert_eq!( + rack.resolved, None, + "ambiguous origin must never guess a remote: {rack:?}" + ); + // Discovery + integrity survive; only the URL is withheld. + assert_eq!(rack.purl, "pkg:gem/rack@3.0.8"); + assert_eq!(rack.integrity, LockIntegrity::Sha256Hex("c".repeat(64))); + } + #[tokio::test] async fn uv_lock_inventories_pure_wheels() { let tmp = tempfile::tempdir().unwrap(); @@ -2200,6 +2328,7 @@ mod recover_tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring, lock: None, @@ -2499,6 +2628,103 @@ mod recover_tests { assert!(recover_lock_entry(tmp.path(), &bare).await.is_err()); } + /// Ledger recovery cannot know which GEM section a vendored gem came + /// from (its spec moved into the PATH section), so a multi-source lock + /// makes the download origin ambiguous: refuse rather than guess (a + /// wrong remote 404s at best and leaks a private gem name at worst). + /// Sections that AGREE on one remote stay recoverable. + #[tokio::test] + async fn gem_recovery_refuses_ambiguous_multi_source_lock() { + let tmp = tempfile::tempdir().unwrap(); + let sha256 = "d".repeat(64); + let gem = entry( + "gem", + "pkg:gem/rack@3.0.0", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(format!(" rack (3.0.0) sha256={sha256}")), + )], + ); + + // Two GEM sections, two different remotes → ambiguous, fail closed. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://gems.corp.example/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("multiple GEM sources"), + "ambiguity must be named: {err}" + ); + + // Two GEM sections agreeing on ONE remote (dedup) → recoverable. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: https://gems.corp.example/\n specs:\n other (1.0.0)\n\n\ + GEM\n remote: https://gems.corp.example/\n specs:\n", + ) + .await + .unwrap(); + let got = recover_lock_entry(tmp.path(), &gem).await.unwrap(); + assert_eq!( + got.resolved.as_deref(), + Some("https://gems.corp.example/downloads/rack-3.0.0.gem"), + "the agreed remote is used, not a rubygems.org guess" + ); + assert_eq!(got.integrity, LockIntegrity::Sha256Hex(sha256)); + } + + /// The ambiguity count must see NON-http remotes too (a `source + /// "file://…" do` block locks its own GEM section with a `file:///` + /// remote — real bundler 4.0.15 output). Filtering to http(s) first + /// would collapse a mixed http+file lock to one "agreed" remote and + /// send a possibly-file-sourced gem's name to the http registry — the + /// same leak class the multi-http refusal closes. A lock whose ONLY + /// remote is non-http must refuse too, never default to rubygems.org. + #[tokio::test] + async fn gem_recovery_counts_non_http_remotes_as_ambiguity() { + let tmp = tempfile::tempdir().unwrap(); + let gem = entry( + "gem", + "pkg:gem/rack@3.0.0", + vec![rec( + "gemfile_lock_checksum", + serde_json::json!(format!(" rack (3.0.0) sha256={}", "d".repeat(64))), + )], + ); + + // Mixed schemes: one file:// section + one https section → ambiguous. + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n rake (13.3.1)\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("multiple GEM sources"), + "a file:// section must count toward the ambiguity refusal: {err}" + ); + + // A single file:// remote: not fetchable, and never a rubygems.org + // fallback (that would leak the private repo's gem name off-site). + tokio::fs::write( + tmp.path().join("Gemfile.lock"), + "GEM\n remote: file:///srv/gems/\n specs:\n private-gem (1.0.0)\n", + ) + .await + .unwrap(); + let err = recover_lock_entry(tmp.path(), &gem).await.unwrap_err(); + assert!( + err.contains("file:///srv/gems") && err.contains("not an http(s) registry"), + "a lone non-http remote must refuse, not guess: {err}" + ); + } + #[tokio::test] async fn recover_decodes_percent_encoded_base_purl() { // The ledger stores base_purl verbatim as the manifest spelled it — diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index f36c0603..e118e8bb 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -387,6 +387,7 @@ pub async fn vendor_maven( sha256: hex::encode(Sha256::digest(&jar_bytes)), size: Some(jar_bytes.len() as u64), platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: PROJECT_POM.to_string(), diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index 2f4b5ba0..1b13747c 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -85,7 +85,10 @@ pub use state::{ carry_forward_wiring, load_state, lookup_entry, save_state, VendorEntry, VendorState, VENDOR_STATE_REL, }; -pub use verify::{check_vendored_artifact, file_sha256_hex, ArtifactHealth}; +pub use verify::{ + artifact_is_file_shaped, check_vendored_artifact, compute_dir_inventory, file_sha256_hex, + ArtifactHealth, +}; use std::collections::{HashMap, HashSet}; use std::path::Path; diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index a8cd0c86..b91d97bb 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -912,6 +912,7 @@ mod tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index 24c676d6..25a60d31 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -351,6 +351,7 @@ pub async fn vendor_npm( sha256: packed.sha256_hex, size: Some(packed.size), platform_locked: None, + file_inventory: None, }, wiring, lock: None, @@ -2032,6 +2033,7 @@ mod tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 83e87ef1..0965667b 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -519,6 +519,7 @@ pub async fn vendor_nuget( sha256: hex::encode(sha2::Sha256::digest(&nupkg_bytes)), size: Some(nupkg_bytes.len() as u64), platform_locked: None, + file_inventory: None, }, wiring, lock: None, @@ -2427,6 +2428,7 @@ mod tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: "../outside.txt".to_string(), diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index bb1f2f96..db09dfb6 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -340,6 +340,7 @@ pub async fn vendor_pnpm( sha256: packed.sha256_hex, size: Some(packed.size), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 9c6576f1..cd738ac6 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -559,6 +559,7 @@ pub async fn vendor_pypi( sha256: artifact.sha256_hex, size: Some(artifact.size), platform_locked: platform_locked.then_some(true), + file_inventory: None, }, wiring, lock: None, @@ -1589,6 +1590,7 @@ wheels = [ sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: vec![], lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_pdm.rs b/crates/socket-patch-core/src/vendor/pypi_pdm.rs index f1543cf9..36e97978 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pdm.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pdm.rs @@ -711,6 +711,7 @@ distribution = false sha256: WHEEL_SHA.into(), size: Some(11053), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs index 10a7509f..8ec52495 100644 --- a/crates/socket-patch-core/src/vendor/pypi_pipenv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_pipenv.rs @@ -658,6 +658,7 @@ mod tests { sha256: WHEEL_SHA.into(), size: Some(11053), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_poetry.rs b/crates/socket-patch-core/src/vendor/pypi_poetry.rs index 2306793a..266fde72 100644 --- a/crates/socket-patch-core/src/vendor/pypi_poetry.rs +++ b/crates/socket-patch-core/src/vendor/pypi_poetry.rs @@ -714,6 +714,7 @@ content-hash = "09f98227642bff952b3df8f8fcc74f1538c091a3ac3ed0031500188347ecb3ca sha256: WHEEL_SHA.into(), size: Some(11053), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_requirements.rs b/crates/socket-patch-core/src/vendor/pypi_requirements.rs index 51aba8c0..32739968 100644 --- a/crates/socket-patch-core/src/vendor/pypi_requirements.rs +++ b/crates/socket-patch-core/src/vendor/pypi_requirements.rs @@ -789,6 +789,7 @@ mod tests { sha256: SHA.into(), size: Some(11053), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/pypi_uv.rs b/crates/socket-patch-core/src/vendor/pypi_uv.rs index 6ce66985..936a45e0 100644 --- a/crates/socket-patch-core/src/vendor/pypi_uv.rs +++ b/crates/socket-patch-core/src/vendor/pypi_uv.rs @@ -1475,6 +1475,7 @@ wheels = [ sha256: WHEEL_SHA.into(), size: Some(11053), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index f47f17b9..a277de08 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -23,7 +23,7 @@ //! flavor strings they have no backend for. Both keep an old binary safe //! against a newer project checkout. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -59,6 +59,17 @@ pub struct VendorArtifact { /// replacing multi-platform registry wheels). #[serde(default, skip_serializing_if = "Option::is_none")] pub platform_locked: Option, + /// Full-file inventory of a DIR-shaped artifact: relative forward-slashed + /// path inside the artifact dir → plain sha256 hex, sorted (the dir + /// counterpart of `sha256` — no lockfile integrity covers a path-source + /// dir's bytes, so without this only the patched members are verifiable + /// and drifted/tampered UNPATCHED files pass every audit). Recorded at + /// vendor time; verification compares the whole tree against it + /// (missing, extra and modified files all fail). Absent on file-shaped + /// artifacts and on pre-inventory ledger entries — those keep member-only + /// verification, and `repair` warns about the gap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_inventory: Option>, } /// How a wiring edit changed a file. @@ -495,6 +506,7 @@ mod tests { sha256: "ab".repeat(32), size: Some(3668), platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: "package-lock.json".into(), @@ -556,6 +568,7 @@ mod tests { "\"pipenv\"", "\"detached\"", "\"record\"", + "\"fileInventory\"", ] { assert!( !text.contains(absent), @@ -565,6 +578,57 @@ mod tests { assert!(text.contains("\"basePurl\""), "camelCase keys: {text}"); } + /// The dir-shaped full-file inventory: camelCase wire key, sorted map + /// order on the wire, lossless round trip, and absent-key tolerance + /// (a pre-inventory ledger deserializes to `None` — the additive-fields + /// forward-compat contract). + #[tokio::test] + async fn file_inventory_round_trips_sorted_camel_case() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut entry = sample_entry(); + entry.ecosystem = "gem".into(); + entry.artifact.path = format!(".socket/vendor/gem/{UUID}/rack-3.2.6"); + entry.artifact.sha256 = String::new(); + entry.artifact.size = None; + entry.artifact.file_inventory = Some(BTreeMap::from([ + ("rack.gemspec".to_string(), "cd".repeat(32)), + ("lib/rack.rb".to_string(), "ab".repeat(32)), + ])); + let mut state = VendorState::new(); + state + .entries + .insert("pkg:gem/rack@3.2.6".into(), entry.clone()); + + save_state(root, &state).await.unwrap(); + let loaded = load_state(root).await.unwrap(); + assert_eq!(loaded, state, "inventory survives the round trip"); + + let text = tokio::fs::read_to_string(root.join(VENDOR_STATE_REL)) + .await + .unwrap(); + assert!(text.contains("\"fileInventory\""), "camelCase key: {text}"); + let lib_at = text.find("lib/rack.rb").unwrap(); + let spec_at = text.find("rack.gemspec").unwrap(); + assert!( + lib_at < spec_at, + "inventory keys serialize sorted (BTreeMap): {text}" + ); + + // A pre-inventory ledger (no `fileInventory` key) deserializes to + // `None`, keeping member-only verification. + let mut legacy = serde_json::to_value(&state).unwrap(); + legacy["entries"]["pkg:gem/rack@3.2.6"]["artifact"] + .as_object_mut() + .unwrap() + .remove("fileInventory"); + let back: VendorState = serde_json::from_value(legacy).unwrap(); + assert!(back.entries["pkg:gem/rack@3.2.6"] + .artifact + .file_inventory + .is_none()); + } + #[tokio::test] async fn detached_entry_round_trips_with_embedded_record() { use crate::manifest::schema::{PatchFileInfo, PatchRecord, VulnerabilityInfo}; diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index 44bcda9b..5c1237b2 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -12,9 +12,14 @@ //! Fail-closed order (each failure is a stable snake_case routing tag): //! `no_files` → `vendor_path_unsafe` → `vendor_uuid_mismatch` → //! `vendor_artifact_missing` → `vendor_artifact_unreadable` / -//! `file_not_found` / `vendor_hash_mismatch`. +//! `file_not_found` / `vendor_hash_mismatch` / `vendor_inventory_mismatch` +//! (dir-shaped artifacts with a recorded [`file_inventory`] additionally +//! verify their FULL file tree — missing, extra and modified unpatched +//! files all fail; entries without one keep member-only verification). +//! +//! [`file_inventory`]: super::state::VendorArtifact::file_inventory -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -92,7 +97,17 @@ pub async fn verify_vendored_patch_record( let is_zip = path_str.ends_with(".whl") || path_str.ends_with(".nupkg") || path_str.ends_with(".jar"); if !is_tarball && !is_zip { - return verify_dir_members(&artifact, record).await; + verify_dir_members(&artifact, record).await?; + // Whole-tree cross-check: a dir-shaped artifact's bytes are covered + // by NO lockfile integrity (bundler path sources, cargo path deps, + // …), so the members above are the only thing the record can vouch + // for — a recorded inventory extends the verdict to every file + // (missing / extra / modified unpatched files, the stub gemspec). + // Pre-inventory entries carry `None` and keep member-only behavior. + if let Some(inventory) = &entry.artifact.file_inventory { + verify_dir_inventory(&artifact, inventory).await?; + } + return Ok(()); } let map = tokio::task::spawn_blocking(move || { if is_tarball { @@ -190,6 +205,110 @@ fn read_wheel_to_map(whl: &Path) -> Result>, String> { /// must not stall `repair`. const MAX_HEALTH_HASH_BYTES: u64 = 512 * 1024 * 1024; +/// Hard cap on inventoried files, mirroring the zip reader's entry cap — a +/// committed artifact dir is one package; a tampered dir must not stall an +/// audit with a million planted files. +const MAX_INVENTORY_ENTRIES: usize = 10_000; + +/// Is this artifact path a single committed FILE (tarball/wheel/nupkg/jar) +/// — whose whole-file drift check is the ledger `sha256` — as opposed to a +/// dir-shaped copy whose counterpart is the `fileInventory`? One suffix +/// rule shared by the health check, repair's fingerprint fill, and the +/// inventory-gap warning. +pub fn artifact_is_file_shaped(path: &str) -> bool { + let norm = path.replace('\\', "/"); + norm.ends_with(".tgz") + || norm.ends_with(".tar.gz") + || norm.ends_with(".whl") + || norm.ends_with(".nupkg") + || norm.ends_with(".jar") +} + +/// Full-file inventory of a dir-shaped artifact: every regular file under +/// `dir`, as `relative forward-slashed path → plain sha256 hex` (sorted — +/// the exact shape [`super::state::VendorArtifact::file_inventory`] +/// records). Fail-closed `Err` on anything that cannot be faithfully +/// inventoried: a non-regular entry (symlink/FIFO — hashing through one +/// could escape the artifact dir or wedge the audit), a non-UTF-8 name, an +/// unreadable file, or a tree past the entry cap. +pub async fn compute_dir_inventory(dir: &Path) -> Result, String> { + let root = dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + use sha2::{Digest, Sha256}; + + let mut out = BTreeMap::new(); + let mut stack: Vec<(PathBuf, String)> = vec![(root, String::new())]; + while let Some((abs, rel)) = stack.pop() { + let entries = std::fs::read_dir(&abs) + .map_err(|e| format!("unreadable artifact dir `{rel}`: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("unreadable artifact dir `{rel}`: {e}"))?; + let name = entry + .file_name() + .to_str() + .ok_or_else(|| format!("non-UTF-8 file name under `{rel}`"))? + .to_string(); + let child_rel = if rel.is_empty() { + name + } else { + format!("{rel}/{name}") + }; + // symlink_metadata: never follow links — a planted symlink + // must fail the inventory, not hash bytes outside the dir. + let meta = std::fs::symlink_metadata(entry.path()) + .map_err(|e| format!("unreadable `{child_rel}`: {e}"))?; + if meta.is_dir() { + stack.push((entry.path(), child_rel)); + continue; + } + if !meta.is_file() { + return Err(format!("`{child_rel}` is not a regular file")); + } + if meta.len() > MAX_HEALTH_HASH_BYTES { + return Err(format!("`{child_rel}` exceeds the inventory size cap")); + } + if out.len() >= MAX_INVENTORY_ENTRIES { + return Err(format!( + "artifact dir exceeds {MAX_INVENTORY_ENTRIES} files" + )); + } + let mut file = std::fs::File::open(entry.path()) + .map_err(|e| format!("unreadable `{child_rel}`: {e}"))?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher) + .map_err(|e| format!("unreadable `{child_rel}`: {e}"))?; + out.insert(child_rel, hex::encode(hasher.finalize())); + } + } + Ok(out) + }) + .await + .map_err(|_| "artifact inventory task failed".to_string())? +} + +/// Compare the live tree under `dir` against the recorded inventory: +/// missing, extra and modified files all fail with the +/// `vendor_inventory_mismatch` routing tag; a tree that cannot be walked +/// (planted symlink/FIFO, unreadable file) is `vendor_artifact_unreadable`. +async fn verify_dir_inventory( + dir: &Path, + inventory: &BTreeMap, +) -> Result<(), String> { + let actual = compute_dir_inventory(dir) + .await + .map_err(|_| "vendor_artifact_unreadable".to_string())?; + if actual.len() != inventory.len() { + return Err("vendor_inventory_mismatch".to_string()); + } + for (rel, recorded) in inventory { + match actual.get(rel) { + Some(live) if live.eq_ignore_ascii_case(recorded) => {} + _ => return Err("vendor_inventory_mismatch".to_string()), + } + } + Ok(()) +} + /// Classified health of one ledger entry's committed artifact, for /// `repair`-style callers that need a DECISION (rebuild or not), not just a /// routing tag. @@ -202,7 +321,8 @@ pub enum ArtifactHealth { Missing, /// Present but failing verification: rebuildable. `reason` is the /// stable routing tag (`vendor_hash_mismatch`, `file_not_found`, - /// `vendor_artifact_unreadable`, `vendor_sha256_mismatch`). + /// `vendor_artifact_unreadable`, `vendor_sha256_mismatch`, + /// `vendor_inventory_mismatch`). Corrupt { reason: String }, /// The ledger/artifact uuid doesn't match the record: a re-vendor is /// pending — not repair's job. @@ -214,10 +334,12 @@ pub enum ArtifactHealth { /// Health-check one vendored artifact against its patch record: the /// per-file afterHash verification of [`verify_vendored_patch_record`] -/// plus, for file-shaped artifacts (`.tgz`/`.tar.gz`/`.whl`) with a -/// recorded ledger sha256, a whole-file hash cross-check — the rewired -/// lockfile integrity references those exact bytes, so silent drift breaks -/// the package manager even when the patched members still verify. +/// (which for dir-shaped artifacts includes the whole-tree fileInventory +/// cross-check) plus, for file-shaped artifacts (`.tgz`/`.tar.gz`/`.whl`) +/// with a recorded ledger sha256, a whole-file hash cross-check — the +/// rewired lockfile integrity references those exact bytes, so silent +/// drift breaks the package manager even when the patched members still +/// verify. pub async fn check_vendored_artifact( project_root: &Path, entry: &VendorEntry, @@ -227,9 +349,10 @@ pub async fn check_vendored_artifact( Err(tag) => match tag.as_str() { "vendor_artifact_missing" => ArtifactHealth::Missing, "vendor_uuid_mismatch" => ArtifactHealth::StaleUuid, - "vendor_hash_mismatch" | "file_not_found" | "vendor_artifact_unreadable" => { - ArtifactHealth::Corrupt { reason: tag } - } + "vendor_hash_mismatch" + | "file_not_found" + | "vendor_artifact_unreadable" + | "vendor_inventory_mismatch" => ArtifactHealth::Corrupt { reason: tag }, _ => ArtifactHealth::Unverifiable { reason: tag }, }, Ok(()) => { @@ -237,13 +360,10 @@ pub async fn check_vendored_artifact( // `.nupkg` (NuGet) and `.jar` (Maven) are single committed files // whose recorded ledger sha256 the rewired lockfile / `.sha1` // sidecar references, so they get the same whole-file drift - // cross-check as tarballs/wheels. - let file_shaped = norm.ends_with(".tgz") - || norm.ends_with(".tar.gz") - || norm.ends_with(".whl") - || norm.ends_with(".nupkg") - || norm.ends_with(".jar"); - if !file_shaped || entry.artifact.sha256.is_empty() { + // cross-check as tarballs/wheels. (Dir-shaped artifacts got the + // fileInventory whole-tree cross-check inside the verification + // above.) + if !artifact_is_file_shaped(&norm) || entry.artifact.sha256.is_empty() { return ArtifactHealth::Healthy; } // The path already passed checked_artifact_path inside the @@ -346,6 +466,7 @@ mod tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None, @@ -382,6 +503,169 @@ mod tests { zip.finish().unwrap(); } + /// The whole-tree inventory closes the dir-shaped blindspot: with only + /// afterHashes, a tampered UNPATCHED file (or stub gemspec), a deleted + /// file, or a planted extra file were all blessed Healthy. Each arm of + /// the tamper matrix is hand-pinned; the legacy no-inventory entry keeps + /// member-only behavior (backward tolerance). + #[tokio::test] + async fn dir_inventory_detects_unpatched_tamper_missing_and_extra_files() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/gem/{UUID}/rack-3.2.6"); + let dir = root.join(&rel); + tokio::fs::create_dir_all(dir.join("lib")).await.unwrap(); + tokio::fs::write(dir.join("lib/rack.rb"), PATCHED) + .await + .unwrap(); + tokio::fs::write(dir.join("rack.gemspec"), b"stub gemspec\n") + .await + .unwrap(); + + let rec = record(UUID, "lib/rack.rb"); + let mut ent = entry("gem", UUID, &rel); + ent.artifact.file_inventory = Some(compute_dir_inventory(&dir).await.unwrap()); + // Anti-vacuity: the recorded inventory names both files with real + // plain-sha256 values, and the pristine tree verifies end to end. + { + let inv = ent.artifact.file_inventory.as_ref().unwrap(); + assert_eq!( + inv.keys().collect::>(), + ["lib/rack.rb", "rack.gemspec"] + ); + use sha2::{Digest, Sha256}; + assert_eq!(inv["lib/rack.rb"], hex::encode(Sha256::digest(PATCHED))); + } + assert!(verify_vendored_patch_record(root, &ent, &rec).await.is_ok()); + assert_eq!( + check_vendored_artifact(root, &ent, &rec).await, + ArtifactHealth::Healthy + ); + + // 1. Tampered UNPATCHED file: afterHashes still verify, the + // inventory flips the verdict. + tokio::fs::write(dir.join("rack.gemspec"), b"tampered gemspec\n") + .await + .unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), + "vendor_inventory_mismatch", + "modified unpatched file" + ); + assert_eq!( + check_vendored_artifact(root, &ent, &rec).await, + ArtifactHealth::Corrupt { + reason: "vendor_inventory_mismatch".to_string() + }, + "Corrupt (rebuildable), never Unverifiable" + ); + + // 2. Missing unpatched file. + tokio::fs::remove_file(dir.join("rack.gemspec")) + .await + .unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), + "vendor_inventory_mismatch", + "deleted unpatched file" + ); + + // 3. Extra planted file (count parity restored: gemspec back, plus + // a file the inventory never recorded). + tokio::fs::write(dir.join("rack.gemspec"), b"stub gemspec\n") + .await + .unwrap(); + tokio::fs::write(dir.join("lib/evil.rb"), b"payload\n") + .await + .unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), + "vendor_inventory_mismatch", + "extra file" + ); + tokio::fs::remove_file(dir.join("lib/evil.rb")) + .await + .unwrap(); + + // 4. Same count, swapped identity: one recorded file replaced by a + // differently-named one (len-only comparison would miss it). + tokio::fs::remove_file(dir.join("rack.gemspec")) + .await + .unwrap(); + tokio::fs::write(dir.join("rack.gemspec2"), b"stub gemspec\n") + .await + .unwrap(); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), + "vendor_inventory_mismatch", + "renamed file at equal count" + ); + tokio::fs::remove_file(dir.join("rack.gemspec2")) + .await + .unwrap(); + tokio::fs::write(dir.join("rack.gemspec"), b"stub gemspec\n") + .await + .unwrap(); + + // 5. LEGACY entry (no inventory recorded): the same unpatched-file + // tamper keeps today's member-only Healthy verdict. + tokio::fs::write(dir.join("rack.gemspec"), b"tampered gemspec\n") + .await + .unwrap(); + let legacy = entry("gem", UUID, &rel); + assert!(legacy.artifact.file_inventory.is_none()); + assert!( + verify_vendored_patch_record(root, &legacy, &rec) + .await + .is_ok(), + "pre-inventory entries keep member-only verification" + ); + assert_eq!( + check_vendored_artifact(root, &legacy, &rec).await, + ArtifactHealth::Healthy + ); + } + + /// SECURITY: a symlink planted inside a vendored dir must fail the + /// inventory walk (never hash through it — the target may live outside + /// the artifact dir), surfacing as unreadable/Corrupt. + #[cfg(unix)] + #[tokio::test] + async fn dir_inventory_refuses_planted_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = format!(".socket/vendor/gem/{UUID}/rack-3.2.6"); + let dir = root.join(&rel); + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("lib.rb"), PATCHED).await.unwrap(); + + let rec = record(UUID, "lib.rb"); + let mut ent = entry("gem", UUID, &rel); + ent.artifact.file_inventory = Some(compute_dir_inventory(&dir).await.unwrap()); + + let outside = root.join("outside.txt"); + tokio::fs::write(&outside, b"outside\n").await.unwrap(); + std::os::unix::fs::symlink(&outside, dir.join("link.rb")).unwrap(); + assert!( + compute_dir_inventory(&dir).await.is_err(), + "symlinks are not inventoriable" + ); + assert_eq!( + verify_vendored_patch_record(root, &ent, &rec) + .await + .unwrap_err(), + "vendor_artifact_unreadable" + ); + } + #[tokio::test] async fn dir_artifact_verifies_and_detects_tamper() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index d0170327..0e999d4b 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -479,6 +479,7 @@ pub async fn vendor_yarn_berry( sha256: packed.sha256_hex, size: Some(packed.size), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index 5052ef7a..8a631361 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -256,6 +256,7 @@ pub async fn vendor_yarn_classic( sha256: packed.sha256_hex, size: Some(packed.size), platform_locked: None, + file_inventory: None, }, wiring, lock: None, diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index cf63f2e6..57be60ba 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -916,6 +916,7 @@ mod tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring: Vec::new(), lock: None,