diff --git a/crates/socket-patch-core/src/patch/vendor/cargo.rs b/crates/socket-patch-core/src/patch/vendor/cargo.rs index 15d0ea0..b0a2d48 100644 --- a/crates/socket-patch-core/src/patch/vendor/cargo.rs +++ b/crates/socket-patch-core/src/patch/vendor/cargo.rs @@ -142,6 +142,24 @@ async fn cargo_service_copy( ); } let _ = tokio::fs::remove_file(copy_dir.join(".cargo-checksum.json")).await; + // Verify the EXTRACTED TREE, not just the archive bytes: the SRI + // proves the download is intact, but an unexpected internal + // layout (the single `{name}-{version}/` strip leaving an extra + // wrapper, or an over-strip) lands the patched files at the wrong + // paths and the caller would synthesize success from + // `record.files` while the copy is wrong. Fail closed → `auto` + // falls back to the local build. (Mirrors composer_lock.rs.) + if !copy_matches_after_hashes(copy_dir, &record.files).await { + let _ = remove_tree(copy_dir).await; + return miss( + warnings, + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt crate for {name} extracted to an unexpected \ + layout (patched files absent at their recorded paths)" + ), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( diff --git a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs index b03ad79..bb5d7fd 100644 --- a/crates/socket-patch-core/src/patch/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/patch/vendor/composer_lock.rs @@ -538,6 +538,30 @@ async fn composer_service_copy( format!("cannot extract the prebuilt dist zip: {e}"), ); } + // Verify the EXTRACTED TREE, not just the archive bytes. The + // archive-bytes SRI (checked in fetch_verified_archive) proves + // the download is intact, but says nothing about whether the + // internal layout lands the patched files at the paths the + // record names: a zip with an unexpected wrapper dir (the + // single-level `strip_first` leaves an extra `pkg-/` + // segment) or a root-level `src/…` (over-stripped) extracts + // "successfully" with every file at the WRONG path. Without + // this check the caller synthesized success purely from + // `record.files` and shipped a copy missing its patched files + // (exit 0, empty copy_dir on disk). Fail closed here and let + // the `auto` source fall back to the local build. + if !copy_matches_after_hashes(copy_dir, &record.files).await { + let _ = remove_tree(copy_dir).await; + return miss( + warnings, + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt dist zip for {pkg} extracted to an \ + unexpected layout (patched files absent at their \ + recorded paths)" + ), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( @@ -1501,6 +1525,106 @@ mod tests { .any(|w| w.code == "vendor_prebuilt_downloaded")); } + /// Wrong internal layout (double wrapper → the single-level strip + /// misplaces the patched file) must NOT be reported as success from + /// `record.files` alone. Under `service` mode it hard-fails + /// `vendor_prebuilt_layout_mismatch`; the file is not at the expected + /// path. Regression for the exit-0-empty-copy incident (run 29040958337). + #[tokio::test] + async fn service_wrong_layout_service_mode_hard_fails() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + // A double wrapper: single strip_first leaves `extra/src/...`, so the + // patched file never lands at `copy_dir/src/LoggerInterface.php`. + let zip = make_dist_zip( + "outer-wrapper", + &[("extra/src/LoggerInterface.php", PATCHED)], + ); + let sri = sri_sha512(&zip); + let server = wiremock::MockServer::start().await; + mount_composer_granted(&server, &sri, &zip).await; + + let outcome = vendor_with_service( + root, + &blobs, + &installed, + &record, + &composer_service_cfg(&server.uri(), VendorSource::Service, false), + ) + .await; + // Service mode has no fallback, so `miss()` surfaces the uniform + // `vendor_prebuilt_required` code (same as an integrity mismatch); + // the layout diagnosis rides in the detail. The point of the + // regression is that it REFUSES rather than synthesizing success — + // and the copy dir does not hold the file at its recorded path. + match outcome { + VendorOutcome::Refused { code, detail } => { + assert_eq!(code, "vendor_prebuilt_required"); + assert!( + detail.contains("unexpected layout"), + "the refusal must diagnose the layout mismatch: {detail}" + ); + } + other => panic!("expected a refusal, got {other:?}"), + } + assert!( + tokio::fs::metadata(root.join(copy_rel()).join("src/LoggerInterface.php")) + .await + .is_err(), + "the misplaced service copy must not be left at the recorded path" + ); + } + + /// Same wrong-layout archive under `auto`: the guard trips and the local + /// build takes over, producing a correct copy — success WITHOUT the bad + /// service bytes. + #[tokio::test] + async fn service_wrong_layout_auto_falls_back_to_build() { + let lock = lock_value("psr/log", "3.0.2", false); + let (dir, blobs, installed, record) = fixture(&lock).await; + let root = dir.path(); + let zip = make_dist_zip( + "outer-wrapper", + &[("extra/src/LoggerInterface.php", PATCHED)], + ); + let sri = sri_sha512(&zip); + let server = wiremock::MockServer::start().await; + mount_composer_granted(&server, &sri, &zip).await; + + let (result, entry, warnings) = unwrap_done( + vendor_with_service( + root, + &blobs, + &installed, + &record, + &composer_service_cfg(&server.uri(), VendorSource::Auto, false), + ) + .await, + ); + assert!( + result.success, + "auto must fall back to the local build when the service layout \ + is wrong: {:?}", + result.error + ); + assert!(entry.is_some()); + // The copy holds the patched bytes at the RIGHT path (from the local + // build, not the misplaced service extract). + assert_eq!( + tokio::fs::read(root.join(copy_rel()).join("src/LoggerInterface.php")) + .await + .unwrap(), + PATCHED + ); + assert!( + warnings + .iter() + .any(|w| w.code == "vendor_prebuilt_layout_mismatch"), + "the fallback must record why the service copy was rejected: {warnings:?}" + ); + } + /// `service` mode + integrity mismatch hard-fails, nothing extracted. #[tokio::test] async fn service_integrity_mismatch_service_mode_hard_fails() { diff --git a/crates/socket-patch-core/src/patch/vendor/gem.rs b/crates/socket-patch-core/src/patch/vendor/gem.rs index f19acf6..43f6dfb 100644 --- a/crates/socket-patch-core/src/patch/vendor/gem.rs +++ b/crates/socket-patch-core/src/patch/vendor/gem.rs @@ -685,6 +685,24 @@ async fn gem_service_copy( format!("cannot write the stub gemspec into the vendored dir: {e}"), ); } + // Verify the EXTRACTED data.tar.gz tree, not just the .gem bytes: the + // SRI proves the download is intact, but an unexpected internal layout + // lands the patched files at the wrong paths and the caller would + // synthesize success from `record.files` while the copy is wrong. (The + // stub gemspec we just wrote is not in record.files, so it is not part + // of this check.) Fail closed → `auto` falls back to the local build. + // (Mirrors composer_lock.rs.) + if !copy_matches_after_hashes(copy_dir, &record.files).await { + let _ = remove_tree(uuid_dir).await; + return miss( + warnings, + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt .gem for {name} extracted to an unexpected layout \ + (patched files absent at their recorded paths)" + ), + ); + } warnings.push(VendorWarning::new( "vendor_prebuilt_downloaded", format!( diff --git a/crates/socket-patch-core/src/patch/vendor/golang.rs b/crates/socket-patch-core/src/patch/vendor/golang.rs index 6eaf2c2..547a9af 100644 --- a/crates/socket-patch-core/src/patch/vendor/golang.rs +++ b/crates/socket-patch-core/src/patch/vendor/golang.rs @@ -418,6 +418,26 @@ async fn go_service_redirect( format!("cannot synthesize go.mod for the copy: {e}"), ); } + // Verify the EXTRACTED TREE before wiring the consumer's go.mod: + // the SRI proves the zip bytes are intact, but an unexpected + // internal layout (the `{module}@{version}/` prefix strip + // mismatching) lands the patched files at the wrong paths, and + // the caller would synthesize success from `record.files` while + // the copy is wrong. Fail closed → `auto` falls back to the + // local build; do it BEFORE editing go.mod so nothing points at + // a bad copy. (Mirrors composer_lock.rs.) + if !copy_matches_after_hashes(copy_dir, &record.files).await { + teardown_failed_service_copy(project_root, base_rel, module, wired).await; + return miss( + warnings, + "vendor_prebuilt_layout_mismatch", + format!( + "prebuilt module zip for {module} extracted to an \ + unexpected layout (patched files absent at their \ + recorded paths)" + ), + ); + } if let Err(e) = go_mod_edit::ensure_replace_entry(project_root, module, version, base_rel, false) .await