From 3c5ae016e1015e82975d8a3b9a4bd28b63868f99 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 17:41:48 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(vendor):=20close=20three=20gem=20vendor?= =?UTF-8?q?ed-integrity=20holes=20=E2=80=94=20wiring=20reconstruction,=20d?= =?UTF-8?q?ir=20file=20inventory,=20per-section=20lock=20remotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three verified holes in the vendored-gem integrity story, each fixed at its seam and pinned by tests that fail without the fix. 1. repair's no-ledger reconstruction synthesized entries with EMPTY wiring; a later `vendor --revert` of such a gem entry deleted the artifact dir, reported success with zero warnings, and left the Gemfile `path:` + lock PATH section pointing at the deleted dir (next `bundle install` hard-fails). Now: - gem::reconstruct_gem_wiring rebuilds full revert-capable wiring from the backend's own recognizable pair edit (exact-pin line / managed fence, PATH section, `!` dependency pin), grammar-strict and fail-closed; for an exact-pin fixture the reconstructed ledger is byte-identical to the original. Two documented degradations: a pre-vendor RANGE constraint restores as the canonical exact pin, and a CHECKSUMS sha256= token is not offline-recoverable — the bare entry stays for a plain `bundle install` to refill (bundler 4.0.15 verified: plain install heals it byte-identically, frozen fails with a self-explanatory message until then), surfaced as vendor_checksum_unrecoverable. - repair routes reconstruction per ecosystem (repair_vendor.rs seam); non-gem ecosystems keep empty wiring — their pre-vendor originals are registry integrity material no offline source can reproduce — and the gap now surfaces loudly as vendor_wiring_unknown instead of silently. - revert_gem refuses an empty-wiring entry outright (vendor_wiring_unknown + manual cleanup steps) instead of stranding the pair edit. The npm-family revert has the same hole and still 'succeeds' silently — the shared guard belongs in dispatch_revert_one (outside this PR's scope, see PR body). 2. check_vendored_artifact's whole-file drift cross-check only covered file-shaped artifacts; a vendored gem DIRECTORY was verified purely by record.files afterHashes, so tampered/drifted UNPATCHED files (or the stub gemspec) were blessed Healthy by repair and attested by VEX. VendorArtifact gains `fileInventory` (relative path → plain sha256, sorted, camelCase) recorded by vendor_gem at vendor time and verified whole-tree inside verify_vendored_patch_record (missing/extra/modified files and planted symlinks all fail; vendor_inventory_mismatch → Corrupt → repair rebuilds; VEX refuses while tampered). Backward-tolerant: entries without the field keep member-only verification and repair names the gap (vendor_inventory_missing). 3. inventory_gemfile_lock captured the FIRST `remote:` across ALL GEM sections and resolved every spec against it, so specs under a second GEM source (private gem server) got a wrong (e.g. rubygems.org) URL — a 404 at best, a private-gem name leak at worst. Specs now resolve against their OWN section's remote (bundler ≥ 2 emits one section per source and hard-errors on multiple global sources — verified on 4.0.15); a legacy bundler-1.x section with several remotes is genuinely ambiguous and stays discovery-only (fail-closed, no guessed URL). gem_remote_base had the same first-remote assumption in ledger recovery: recovery now requires the lock's GEM sources to agree on a single remote and refuses ambiguity. Verified: core 2474/0, cli lib 350/0, repair_vendor_e2e 20/20 (5 new gem rows), adjacent vendor/vex/scan suites green, e2e_vendor_gem_build capstone green under real bundler 4.0.15, clippy+fmt clean. Co-Authored-By: Claude Fable 5 --- .../src/commands/repair_vendor.rs | 115 +++- .../socket-patch-cli/src/commands/vendor.rs | 1 + .../socket-patch-cli/tests/e2e_vex_vendor.rs | 3 + .../tests/in_process_vendor.rs | 1 + .../tests/repair_vendor_e2e.rs | 467 ++++++++++++++ .../tests/setup_contract_gaps.rs | 1 + .../socket-patch-core/src/vendor/bun_lock.rs | 1 + crates/socket-patch-core/src/vendor/cargo.rs | 1 + .../src/vendor/composer_lock.rs | 1 + crates/socket-patch-core/src/vendor/gem.rs | 586 +++++++++++++++++- crates/socket-patch-core/src/vendor/golang.rs | 1 + .../src/vendor/lock_inventory.rs | 220 ++++++- .../src/vendor/maven_repo.rs | 1 + crates/socket-patch-core/src/vendor/mod.rs | 5 +- .../src/vendor/npm_flavor.rs | 1 + .../socket-patch-core/src/vendor/npm_lock.rs | 2 + .../src/vendor/nuget_feed.rs | 2 + .../socket-patch-core/src/vendor/pnpm_lock.rs | 1 + crates/socket-patch-core/src/vendor/pypi.rs | 2 + .../socket-patch-core/src/vendor/pypi_pdm.rs | 1 + .../src/vendor/pypi_pipenv.rs | 1 + .../src/vendor/pypi_poetry.rs | 1 + .../src/vendor/pypi_requirements.rs | 1 + .../socket-patch-core/src/vendor/pypi_uv.rs | 1 + crates/socket-patch-core/src/vendor/state.rs | 66 +- crates/socket-patch-core/src/vendor/verify.rs | 320 +++++++++- .../src/vendor/yarn_berry_lock.rs | 1 + .../src/vendor/yarn_classic_lock.rs | 1 + crates/socket-patch-core/src/vex/verify.rs | 1 + 29 files changed, 1746 insertions(+), 60 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index b435b786..faba4f0c 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -15,9 +15,13 @@ //! 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. use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -29,10 +33,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; @@ -131,6 +136,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 +152,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!( @@ -248,7 +285,31 @@ 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. + if !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, + ); + } + } ArtifactHealth::StaleUuid => { env.record( PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason( @@ -324,6 +385,33 @@ 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 @@ -789,14 +877,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 43a68d6f..421ff34d 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -1781,6 +1781,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..29bebe3f 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,467 @@ 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; +} + +/// `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" + ); +} + +/// 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" + ); +} + +/// 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(); + } +} + +/// 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. +#[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(); + + 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}" + ); + assert!( + events_of(&v).iter().any(|e| e["action"] == "skipped" + && e["errorCode"] == "vendor_inventory_missing" + && e["purl"] == GEM_PURL), + "the verification gap must be named: {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)" + ); +} + /// 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..4d9ffdb5 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,255 @@ 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. +/// +/// Two documented degradations, both 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 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 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 +4298,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..75109b7f 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,27 @@ 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] => one.clone(), + 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 +1435,37 @@ 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 http(s) `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. +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; + if let Some(url) = http_url(rest.trim()) { + let url = url.trim_end_matches('/').to_string(); + if !out.contains(&url) { + out.push(url); + } + } } } } - None + out } /// First `{ url = "…", hash = "sha256:…" }` wheel in a uv.lock `[[package]]` @@ -2059,6 +2104,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 +2316,7 @@ mod recover_tests { sha256: String::new(), size: None, platform_locked: None, + file_inventory: None, }, wiring, lock: None, @@ -2499,6 +2616,55 @@ 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)); + } + #[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 f6076c1e..3e794e48 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -82,7 +82,10 @@ mod yarn_layering_tests; pub use path::{ecosystem_dir_for_purl, parse_vendor_path}; pub use state::{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 fe2863d9..31effea4 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -297,6 +297,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 2333079e..0e7b8789 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. @@ -405,6 +416,7 @@ mod tests { sha256: "ab".repeat(32), size: Some(3668), platform_locked: None, + file_inventory: None, }, wiring: vec![WiringRecord { file: "package-lock.json".into(), @@ -466,6 +478,7 @@ mod tests { "\"pipenv\"", "\"detached\"", "\"record\"", + "\"fileInventory\"", ] { assert!( !text.contains(absent), @@ -475,6 +488,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, From 1bac50a312472c10de190a984d217f353e38b7a4 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 18:33:42 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(vendor):=20review=20polish=20=E2=80=94?= =?UTF-8?q?=20refresh=20service-provenance=20inventories,=20backfill=20emp?= =?UTF-8?q?ty=20gem=20wiring,=20scheme-agnostic=20remote=20ambiguity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the gem vendored-integrity batch: - repair's post-verify no longer deterministic-fails a service-vendored gem entry: a member-verified local rebuild whose tree differs from the recorded fileInventory (the service's converter stub vs the local one) refreshes the inventory from the verified rebuild and persists it, with the provenance named (vendor_inventory_refreshed) — instead of deleting the rebuild, stranding the wired pair on a dead dir, and re-failing every later repair. - pass 1 backfills full revert-capable wiring for EXISTING empty-wiring gem ledger entries (the pre-reconstruction repair population) from the live pair while healthy, so `vendor --revert` stops refusing. - the vendor_inventory_missing nag is gated to gem — the one backend that records inventories; cargo/golang/composer re-vendors record nothing, so the advice was permanent per-run noise there. - gem_remotes collects lock remotes scheme-agnostically before the ambiguity refusal: a mixed http+file:// lock (real bundler 4.0.15 shape, one GEM section per `source "file://…" do` block) no longer collapses to one "agreed" remote and leaks the gem name to the http registry; a lone non-http remote refuses instead of defaulting to rubygems.org. - reconstruct_gem_wiring docs: the trailing-comment loss degradation and the deliberately-conservative bare-CHECKSUMS warning are documented. Co-Authored-By: Claude Fable 5 --- .../src/commands/repair_vendor.rs | 138 ++++++++++- .../tests/repair_vendor_e2e.rs | 224 +++++++++++++++++- crates/socket-patch-core/src/vendor/gem.rs | 13 +- .../src/vendor/lock_inventory.rs | 74 +++++- 4 files changed, 431 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index faba4f0c..655ab9d6 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -21,7 +21,17 @@ //! 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. +//! 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. use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -289,8 +299,13 @@ pub(crate) async fn repair_vendored_artifacts( // 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. - if !artifact_is_file_shaped(&entry.artifact.path) + // 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( @@ -309,6 +324,66 @@ pub(crate) async fn repair_vendored_artifacts( 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( @@ -832,7 +907,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!( diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index 29bebe3f..babb1715 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -1030,6 +1030,48 @@ async fn mount_gem_patch_api(mock: &MockServer) { .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"]); @@ -1220,6 +1262,64 @@ async fn revert_of_empty_wiring_gem_entry_fails_loudly() { ); } +/// 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 @@ -1323,10 +1423,102 @@ async fn repair_gem_dir_tamper_matrix_and_vex_refusal() { } } +/// 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. +/// (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; @@ -1344,6 +1536,7 @@ async fn repair_warns_on_legacy_gem_entry_without_inventory() { .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(); @@ -1354,17 +1547,38 @@ async fn repair_warns_on_legacy_gem_entry_without_inventory() { 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["action"] == "skipped" - && e["errorCode"] == "vendor_inventory_missing" - && e["purl"] == GEM_PURL), - "the verification gap must be named: {v}" + !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, diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 4d9ffdb5..26b21d2c 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -981,19 +981,28 @@ pub async fn revert_gem(entry: &VendorEntry, project_root: &Path, dry_run: bool) /// `Err` and the caller keeps an empty-wiring entry (whose revert then /// refuses loudly) instead of guessing. /// -/// Two documented degradations, both inherent to a lost ledger: +/// 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. +/// `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, diff --git a/crates/socket-patch-core/src/vendor/lock_inventory.rs b/crates/socket-patch-core/src/vendor/lock_inventory.rs index 75109b7f..91dc9da0 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory.rs @@ -1116,7 +1116,15 @@ pub async fn recover_lock_entry( })?; let base = match gem_remotes(project_root).await.as_slice() { [] => "https://rubygems.org".to_string(), - [one] => one.clone(), + [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 @@ -1435,11 +1443,17 @@ fn inline_yaml_field(line: &str, field: &str) -> Option { (!v.is_empty()).then_some(v) } -/// The DISTINCT http(s) `GEM remote:` bases across ALL GEM sections of the +/// 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(); @@ -1456,11 +1470,9 @@ async fn gem_remotes(project_root: &Path) -> Vec { } if in_gem { if let Some(rest) = line.trim().strip_prefix("remote:") { - if let Some(url) = http_url(rest.trim()) { - let url = url.trim_end_matches('/').to_string(); - if !out.contains(&url) { - out.push(url); - } + let url = rest.trim().trim_end_matches('/').to_string(); + if !url.is_empty() && !out.contains(&url) { + out.push(url); } } } @@ -2665,6 +2677,54 @@ mod recover_tests { 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 — From bf55fa1d6f96730d52c8d3f674dd62437da3d5a1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 13 Aug 2026 18:56:17 -0700 Subject: [PATCH 3/3] fix(repair): never fingerprint the live tree into a reconstructed ledger entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-ledger restore (pass 2) ran check_vendored_artifact on a synthesized entry with no fileInventory/sha256 — verifying only the patched members — and then filled the fingerprint from the LIVE artifact and persisted it. For anything without an npm-family wired lockfile integrity (gem dirs foremost), a tampered UNPATCHED file was thereby canonized: later repairs enforce the tampered tree and VEX attests it (trust-on-first-use). Healthy-but-unanchored reconstructions are now queued as SOFT rebuild candidates: the ledger entry is restored fingerprint-less first (so the restore survives any rebuild failure and wiring originals carry forward), the untrusted live tree is cleared only once the patch sources and a pristine source are both secured, and the canonical fingerprint derives from the member-verified local rebuild — exactly the dispatch every other rebuild here uses. When no trustworthy pristine source exists (not installed, no recoverable registry fragment, offline, fetch failure) the entry stays in the legacy member-only state with a vendor_inventory_unverified warning — later repairs keep naming the gap (vendor_inventory_missing) instead of enforcing a tampered tree. Anchored file-shaped artifacts (rewired npm-family lock integrity, verified intact) restore as before. Pinned by two gem e2e tests (both verified RED on the pre-fix code): tampered-unpatched-file + lost ledger is healed by the rebuild with the restored ledger byte-identical to the pre-tamper original, and the no-pristine-source variant restores without a fingerprint, warns, and stays in the legacy-warn state. Co-Authored-By: Claude Fable 5 --- .../src/commands/repair_vendor.rs | 271 +++++++++++++++--- .../tests/repair_vendor_e2e.rs | 138 +++++++++ 2 files changed, 365 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/repair_vendor.rs b/crates/socket-patch-cli/src/commands/repair_vendor.rs index 655ab9d6..c4bdcd73 100644 --- a/crates/socket-patch-cli/src/commands/repair_vendor.rs +++ b/crates/socket-patch-cli/src/commands/repair_vendor.rs @@ -32,6 +32,16 @@ //! 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; @@ -70,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 @@ -204,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). @@ -416,6 +471,7 @@ pub(crate) async fn repair_vendored_artifacts( detached, reconstructed: false, reason, + soft: false, }); } } @@ -489,13 +545,19 @@ pub(crate) async fn repair_vendored_artifacts( } 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 { @@ -518,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( + 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::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 { @@ -564,6 +656,7 @@ pub(crate) async fn repair_vendored_artifacts( detached, reconstructed: true, reason: "vendor_artifact_missing", + soft: false, }); } } @@ -597,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 @@ -621,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, @@ -655,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 @@ -681,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; } @@ -742,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 \ @@ -759,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()); } } @@ -774,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 @@ -976,6 +1158,7 @@ pub(crate) async fn repair_vendored_artifacts( serde_json::json!({ "path": check_entry.artifact.path, "reason": c.reason, + "ledgerRestored": c.reconstructed, }), ), ); diff --git a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs index babb1715..dee42141 100644 --- a/crates/socket-patch-cli/tests/repair_vendor_e2e.rs +++ b/crates/socket-patch-cli/tests/repair_vendor_e2e.rs @@ -1209,6 +1209,144 @@ async fn repair_reconstruction_flags_unrecoverable_gem_checksum() { ); } +/// 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