Skip to content

Commit b787454

Browse files
fix: vendor hosted patches directly from lockfiles (#1648)
* fix: vendor hosted lockfile patches without installed packages * Update cargo mode-migration tests to hosted-pins-first vendor A plain `vendor` with a hosted pin in scope now ejects exactly the lockfile's hosted patch, even when `.socket/manifest.json` names another patch for the same purl; vendoring manifest patches takes a full `scan --mode vendored`. The hosted→vendored and A→B→A tests expected the old per-purl takeover onto the manifest's patch. Point the eject at the mock API, serve the granted crate (with the sha512 the artifact download checks), and assert the new contract: the hosted UUID is vendored and attested, the manifest-only UUID is neither vendored, reported nor attested, and the manifest is left byte-identical. The fail-closed test now covers both refusals: `--offline` is refused up front (`offline_eject_unavailable`), and an index that cannot re-resolve the crates.io entry still yields `redirect_revert_failed` with the checkout remedy, nothing touched. Co-Authored-By: Claude <noreply@anthropic.com> * Update bun mode-migration tests to hosted-pins-first vendor With a hosted pin in scope, plain `vendor` ejects the lockfile's hosted patch and ignores `.socket/manifest.json`; the bun hosted→vendored and dry-run tests still expected the manifest's patch (uuid_v) to take over the hosted pin, with the per-purl takeover advisories. Point `vendor_cmd` at the mock API so the eject can fetch the record, expect the hosted uuid to be vendored (detached ledger entry, VEX on the view's CVE), and assert that the manifest-only patch is not previewed, reported or vendored and that the manifest is left byte-identical. The dry run now previews the eject (`eject_planned`) instead of the old `vendor_would_revert_redirect` advisory. Co-Authored-By: Claude <noreply@anthropic.com> * Update yarn mode-migration tests to hosted-pins-first vendor The classic and berry hosted→vendored legs expected plain `vendor` to take over the hosted pin with the staged manifest's patch (UUID_V) and announce `vendor_takeover_reverted_redirect`. With a hosted pin in scope `vendor` now ejects the lockfile's hosted patch and ignores the manifest. Point the vendor runs at the mock API (the eject fetches the record from `patches/view`), expect UUID_H and its CVE in the manifest-less VEX matrices with UUID_V as the never-attested patch, and replace the takeover-advisory check with the eject contract: the manifest-only patch is neither reported nor vendored, and the manifest is not rewritten. Co-Authored-By: Claude <noreply@anthropic.com> * Update golang hosted→vendored test to hosted-pins-first vendor The test staged a manifest naming UUID_V over a hosted go.mod pin and expected plain `vendor` to take the pin over with the manifest's patch. With a hosted pin in scope `vendor` now ejects the lockfile's hosted patch (UUID_H) and ignores the manifest; the run reached the public proxy for the record and failed. Point the run at the mock API, serve the hosted patch's prebuilt module from the artifact-service mock, and expect the go.mod replace to name UUID_H. Assert the manifest-only patch is neither reported nor vendored and the manifest is left byte-identical; the existing upstream-restore checks (no hosted replace, go.sum back to its pre-redirect bytes, no ledger) are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * Describe plain vendor over hosted pins as an eject, not a takeover The "Takeover reconciliation" contract and the `run_eject` doc comment still described plain `vendor` as a per-purl takeover onto the manifest's patches. With a hosted pin in scope it ejects exactly the lockfile's hosted pins as one planned transition and ignores the manifest; say so, point manifest vendoring at `scan --mode vendored`, and keep the per-purl takeover wording for `scan` / `get --mode vendored` and the no-hosted-pin manifest path. Co-Authored-By: Claude <noreply@anthropic.com> * Update hosted-pin vendor tests for eject and heal vlt stores on eject Plain `vendor` with hosted pins in scope now ejects only the lockfile pins and fetches each pinned record from the patch API, so tests that staged a manifest and expected the per-purl takeover failed with `patch_fetch_failed` (coverage job). Point those runs at mock API records (sbt, pypi requirements/pipenv, bun, vlt, pnpm/npm conversion, berry) and assert the eject contract: `eject_planned` on dry runs, `eject_refused` / `offline_eject_unavailable` for refusals, the hosted pin's UUID vendored, and the manifest left untouched. The bun prune test now builds its manifest-tracked entry from a registry lock, since only manifest vendoring without hosted pins produces one. The help hygiene test expects the new one-line vendor summary. The vlt dry/wet test exposed a product gap: the eject skipped the warm vlt store heal the per-purl takeover runs, leaving the hosted store copy and hidden lock behind, so a later `vendor --revert` could keep patched bytes under a registry pin. The eject now heals those store entries once it stands and reports `redirect_vlt_reinstall_required`. Co-Authored-By: Claude <noreply@anthropic.com> * Skip the backend preview for pins the eject restore refused A pin whose upstream restore was refused already has its failure event. Previewing the backend over a lock where that pin is still hosted added a second `failed` event for the same purl with a backend code, which double counted summary.failed and blamed the wrong step. Also stop listing plain `vendor` as a source of vendor_would_revert_redirect in CLI_CONTRACT.md: with a hosted pin in scope it ejects and reports eject_planned / eject_refused instead. Co-Authored-By: Claude <noreply@anthropic.com> * Let manifest vendoring run past contested wiring with no pins in scope The hosted inventory is not ecosystem-scoped, so a contested hosted ref anywhere in the tree (e.g. an npm shrinkwrap under `vendor --ecosystems pypi`) refused the whole run as `hosted_wiring_contested`, even when an agent manifest exists and no hosted pin is in scope. That made the legacy manifest-only fallback unreachable and regressed plain manifest vendoring versus main. Refuse on contested wiring only when there is no manifest (the lockfiles are the only patch set) or when in-scope hosted pins would be ejected. Co-Authored-By: Claude <noreply@anthropic.com> * Correct the contract on what feeds standalone vendor The vendor-command contract still said plain `vendor` is fed by `.socket/manifest.json` and falls back to the lockfiles' hosted pins only when no manifest exists. This PR flipped that precedence: any hosted pin in scope sends the run to the eject, which ignores the manifest and runs no reconcile. State the new order so the contract matches the code and the takeover-reconciliation section above it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Align #1105 test with the hosted-pin eject Merging main brought in #1713's regression test, which expects plain `vendor` over a hosted pin at patch A to vendor the manifest's newer patch B. This branch makes plain `vendor` eject the hosted pins from the lockfiles instead (manifest unread), so the merged tree failed the test deterministically and the PR was evicted from the merge queue twice (coverage (1), runs 40572480618 and 40575886984). Keep the #1105 setup (venv synced from the hosted lock, manifest at B) and assert the eject outcome: exit 0, no package_not_installed, the project vendored at A with the hosted URL gone and B unread. The superseded-install rule behind `scan --mode vendored` keeps its unit test, hosted_pin_marks_a_pypi_install_superseded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011f9aeQeQBzNvTuMrf6bWCp --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8244b94 commit b787454

24 files changed

Lines changed: 1084 additions & 395 deletions

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 21 additions & 12 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/vendor.rs‎

Lines changed: 157 additions & 74 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/vendored_backend/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ pub(crate) struct ApplyRequest<'a> {
7272
/// rollback undoes exactly these), and the wired flavors. With it, the
7373
/// run's human summary and "Next steps:" are left to the eject, which
7474
/// prints them only once it knows the eject stands.
75+
/// The records are exact lockfile-selected patches with their hosted
76+
/// wiring already restored upstream; skip installed-package discovery
77+
/// and per-package hosted takeover.
7578
pub(crate) eject: Option<&'a mut EjectCapture>,
7679
}
7780

‎crates/socket-patch-cli/src/lib.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,12 @@ pub enum Commands {
8787
/// Generate OpenVEX for vulnerabilities addressed by verified patches
8888
Vex(commands::vex::VexArgs),
8989

90-
/// Store patched dependencies in .socket/vendor/ for offline installs
90+
/// Download hosted patches from lockfiles into .socket/vendor/
9191
///
92-
/// Rewire dependency files to use the committed artifacts. Unpatched
93-
/// dependencies still need their normal registry or cache. Use --revert
94-
/// to undo vendoring.
92+
/// Works before installing dependencies and rewires dependency files to
93+
/// use the committed artifacts. Falls back to the agent manifest when no
94+
/// hosted patches are in scope. Unpatched dependencies still need their
95+
/// normal registry or cache. Use --revert to undo vendoring.
9596
Vendor(commands::vendor::VendorArgs),
9697

9798
/// Reapply agent-mode patches after installing dependencies

‎crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs‎

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::path::Path;
1818
#[path = "common/mod.rs"]
1919
mod common;
2020

21+
use base64::Engine as _;
2122
use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes;
2223
use wiremock::matchers::{method, path};
2324
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -475,7 +476,8 @@ async fn hosted_rollback_restores_go_sum_byte_for_byte() {
475476
/// to its upstream entry (drop the hosted replace and the socket go.sum
476477
/// lines, re-derive the pruned upstream pair from the checksum database),
477478
/// so the project is fully vendored — never a vendored go.mod beside a
478-
/// hosted pin. No ledger is involved at any point.
479+
/// hosted pin. No ledger is involved at any point. The vendored patch is
480+
/// the hosted pin's own: a manifest naming another patch is ignored.
479481
#[tokio::test(flavor = "multi_thread")]
480482
async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() {
481483
let tmp = tempfile::tempdir().unwrap();
@@ -523,11 +525,50 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() {
523525
.unwrap();
524526
std::fs::write(socket.join("blobs").join(&after), PATCHED_LIB).unwrap();
525527

526-
// Online: the takeover's upstream restore consults the (mocked)
527-
// checksum database; the patch itself comes from the local manifest.
528-
let (code, stdout, stderr) = run_with_prebuilt(
528+
let manifest = std::fs::read(socket.join("manifest.json")).unwrap();
529+
530+
// The manifest above names a DIFFERENT patch (UUID_V), but a hosted pin
531+
// is in scope: plain `vendor` ejects exactly the go.mod's hosted patch
532+
// (UUID_H) and ignores the manifest (vendoring manifest patches takes a
533+
// full `scan --mode vendored`). The eject fetches the record from the
534+
// API and downloads the prebuilt module from the artifact service; the
535+
// upstream restore consults the (mocked) checksum database.
536+
let view = serde_json::json!({
537+
"uuid": UUID_H,
538+
"purl": UPURL,
539+
"publishedAt": "2026-01-01T00:00:00Z",
540+
"files": { "lib.go": {
541+
"beforeHash": compute_git_sha256_from_bytes(PRISTINE_LIB.as_bytes()),
542+
"afterHash": &after,
543+
"blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED_LIB),
544+
}},
545+
"vulnerabilities": {},
546+
"description": "golang hosted state fixture",
547+
"license": "MIT",
548+
"tier": "free",
549+
});
550+
prebuilt_common::mount_view_from_source(
551+
&server,
552+
&view,
553+
None,
554+
Some(&modcache.join(format!("{UMOD}@{UVER}"))),
555+
)
556+
.await;
557+
let uri = server.uri();
558+
let (code, stdout, stderr) = common::run_with_env(
529559
&consumer,
530-
&["vendor", "--json", "--cwd", consumer.to_str().unwrap()],
560+
&[
561+
"vendor",
562+
"--json",
563+
"--cwd",
564+
consumer.to_str().unwrap(),
565+
"--api-url",
566+
&uri,
567+
"--org",
568+
ORG,
569+
"--api-token",
570+
"fake",
571+
],
531572
&[
532573
("GOMODCACHE", modcache.to_str().unwrap()),
533574
("SOCKET_GOSUMDB_URL", sumdb.as_str()),
@@ -538,13 +579,24 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() {
538579
"vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}"
539580
);
540581
assert!(
541-
stdout.contains("vendor_takeover_reverted_redirect"),
542-
"the takeover is reported: {stdout}"
582+
!stdout.contains(UUID_V),
583+
"the manifest-only patch is not part of the eject: {stdout}"
584+
);
585+
assert!(
586+
!consumer
587+
.join(format!(".socket/vendor/golang/{UUID_V}"))
588+
.exists(),
589+
"the manifest-only patch must not be vendored"
590+
);
591+
assert_eq!(
592+
std::fs::read(socket.join("manifest.json")).unwrap(),
593+
manifest,
594+
"the eject never rewrites the manifest"
543595
);
544596
let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap();
545597
assert!(
546598
go_mod.contains(&format!(
547-
"replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID_V}/{UMOD}@{UVER}"
599+
"replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID_H}/{UMOD}@{UVER}"
548600
)),
549601
"{go_mod}"
550602
);

‎crates/socket-patch-cli/tests/e2e_sbt_vendor.rs‎

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -786,17 +786,29 @@ fn drop_evidence(proj: &Path) {
786786
}
787787
}
788788

789-
/// A takeover (vendoring over a hosted pin) the vendored gate would skip
790-
/// (no evidence: a fresh clone) is refused BEFORE the hosted pin is
791-
/// restored: the project stays hosted, never neither hosted nor vendored.
789+
/// The `vendor` flags naming the mock patch API of [`mock_gson_api`].
790+
fn api_flags(uri: &str) -> [&str; 6] {
791+
["--api-url", uri, "--org", "test-org", "--api-token", "fake"]
792+
}
793+
794+
/// Plain `vendor` over a hosted pin ejects it even beside an agent
795+
/// manifest. An eject the vendored gate would skip (no evidence: a fresh
796+
/// clone) is refused BEFORE the hosted pin is restored: the project stays
797+
/// hosted, never neither hosted nor vendored, and the manifest is untouched.
792798
#[test]
793799
fn vendor_takeover_the_gate_would_skip_keeps_the_hosted_pin() {
794800
let tmp = tempfile::tempdir().unwrap();
795801
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
796802
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
803+
let manifest = std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap();
797804
drop_evidence(&fx.proj());
798-
let (exit, env) = fx.run(&["vendor"]);
805+
let (_rt, server) = mock_gson_api();
806+
let uri = server.uri();
807+
let mut args = vec!["vendor"];
808+
args.extend(api_flags(&uri));
809+
let (exit, env) = fx.run(&args);
799810
assert_eq!(exit, Some(1), "{env}");
811+
assert_eq!(env["error"]["code"], "eject_refused", "{env}");
800812
assert!(
801813
event_triples(&env).contains(&(
802814
GSON.purl(),
@@ -807,47 +819,47 @@ fn vendor_takeover_the_gate_would_skip_keeps_the_hosted_pin() {
807819
);
808820
assert_eq!(std::fs::read(fx.proj().join(HOSTED_FILE)).unwrap(), hosted);
809821
assert!(!fx.proj().join(VENDORED_FILE).exists());
822+
assert_eq!(
823+
std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap(),
824+
manifest
825+
);
810826
}
811827

812-
/// A dry-run takeover previews (the restore is dry, so the hosted file
813-
/// still pins the GA on disk; the planner's hosted-conflict refusal the wet
814-
/// run never sees is not raised), and the wet run takes over.
828+
/// A dry-run eject of a hosted sbt pin (beside an agent manifest) previews
829+
/// the plan and writes nothing; the wet run vendors the pinned patch and
830+
/// drops the hosted file, leaving the manifest as it was.
815831
#[test]
816832
fn vendor_takeover_of_a_hosted_sbt_pin_dry_and_wet() {
817833
let tmp = tempfile::tempdir().unwrap();
818834
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
819835
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
820-
let env = fx.ok(&["vendor", "--dry-run"]);
821-
assert!(
822-
env.to_string().contains("vendor_would_revert_redirect"),
823-
"{env}"
824-
);
825-
assert!(
826-
!env.to_string().contains("vendor_sbt_hosted_conflict"),
827-
"{env}"
828-
);
836+
let manifest = std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap();
837+
let (_rt, server) = mock_gson_api();
838+
let uri = server.uri();
839+
let mut args = vec!["vendor"];
840+
args.extend(api_flags(&uri));
841+
let mut dry = args.clone();
842+
dry.push("--dry-run");
843+
let env = fx.ok(&dry);
844+
assert!(env.to_string().contains("eject_planned"), "{env}");
829845
assert_eq!(std::fs::read(fx.proj().join(HOSTED_FILE)).unwrap(), hosted);
830-
let env = fx.ok(&["vendor"]);
831-
assert!(
832-
env.to_string()
833-
.contains("vendor_takeover_reverted_redirect"),
834-
"{env}"
835-
);
846+
assert!(!fx.proj().join(VENDORED_FILE).exists());
847+
let env = fx.ok(&args);
836848
assert!(!fx.proj().join(HOSTED_FILE).exists(), "{env}");
837849
assert!(fx.generated().contains(GSON_UUID));
850+
assert_eq!(
851+
std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap(),
852+
manifest
853+
);
838854
}
839855

840-
/// An eject (plain `vendor` in a hosted project) the vendored gate would
841-
/// skip is refused whole before anything is restored.
842-
#[test]
843-
fn vendor_eject_the_gate_would_skip_is_refused() {
856+
/// A mock patch API serving gson's patch record by `GSON_UUID`: plain
857+
/// `vendor` over a hosted pin ejects it, fetching the pinned record from
858+
/// the API (the agent manifest is not its input). Returns the runtime that
859+
/// keeps the server alive and the server.
860+
fn mock_gson_api() -> (tokio::runtime::Runtime, wiremock::MockServer) {
844861
use wiremock::matchers::{method, path};
845862
use wiremock::{Mock, MockServer, ResponseTemplate};
846-
let tmp = tempfile::tempdir().unwrap();
847-
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
848-
std::fs::remove_file(fx.proj().join(".socket/manifest.json")).unwrap();
849-
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
850-
drop_evidence(&fx.proj());
851863
let rt = tokio::runtime::Builder::new_multi_thread()
852864
.worker_threads(1)
853865
.enable_all()
@@ -868,6 +880,19 @@ fn vendor_eject_the_gate_would_skip_is_refused() {
868880
.respond_with(ResponseTemplate::new(200).set_body_json(view))
869881
.mount(&server),
870882
);
883+
(rt, server)
884+
}
885+
886+
/// An eject (plain `vendor` in a hosted project) the vendored gate would
887+
/// skip is refused whole before anything is restored.
888+
#[test]
889+
fn vendor_eject_the_gate_would_skip_is_refused() {
890+
let tmp = tempfile::tempdir().unwrap();
891+
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
892+
std::fs::remove_file(fx.proj().join(".socket/manifest.json")).unwrap();
893+
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
894+
drop_evidence(&fx.proj());
895+
let (_rt, server) = mock_gson_api();
871896
let uri = server.uri();
872897
let (exit, env) = fx.run(&[
873898
"vendor",

‎crates/socket-patch-cli/tests/help_text_hygiene.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ fn vendor_and_repair_summaries_read_as_one_line() {
227227
let text = long_help(&[]);
228228
assert!(
229229
text.lines().any(|l| l
230-
== " vendor Store patched dependencies in .socket/vendor/ for offline installs"),
230+
== " vendor Download hosted patches from lockfiles into .socket/vendor/"),
231231
"{text}"
232232
);
233233
assert!(

0 commit comments

Comments
 (0)