Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

231 changes: 157 additions & 74 deletions crates/socket-patch-cli/src/commands/vendor.rs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions crates/socket-patch-cli/src/commands/vendored_backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub(crate) struct ApplyRequest<'a> {
/// rollback undoes exactly these), and the wired flavors. With it, the
/// run's human summary and "Next steps:" are left to the eject, which
/// prints them only once it knows the eject stands.
/// The records are exact lockfile-selected patches with their hosted
/// wiring already restored upstream; skip installed-package discovery
/// and per-package hosted takeover.
pub(crate) eject: Option<&'a mut EjectCapture>,
}

Expand Down
9 changes: 5 additions & 4 deletions crates/socket-patch-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ pub enum Commands {
/// Generate OpenVEX for vulnerabilities addressed by verified patches
Vex(commands::vex::VexArgs),

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

/// Reapply agent-mode patches after installing dependencies
Expand Down
68 changes: 60 additions & 8 deletions crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::path::Path;
#[path = "common/mod.rs"]
mod common;

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

// Online: the takeover's upstream restore consults the (mocked)
// checksum database; the patch itself comes from the local manifest.
let (code, stdout, stderr) = run_with_prebuilt(
let manifest = std::fs::read(socket.join("manifest.json")).unwrap();

// The manifest above names a DIFFERENT patch (UUID_V), but a hosted pin
// is in scope: plain `vendor` ejects exactly the go.mod's hosted patch
// (UUID_H) and ignores the manifest (vendoring manifest patches takes a
// full `scan --mode vendored`). The eject fetches the record from the
// API and downloads the prebuilt module from the artifact service; the
// upstream restore consults the (mocked) checksum database.
let view = serde_json::json!({
"uuid": UUID_H,
"purl": UPURL,
"publishedAt": "2026-01-01T00:00:00Z",
"files": { "lib.go": {
"beforeHash": compute_git_sha256_from_bytes(PRISTINE_LIB.as_bytes()),
"afterHash": &after,
"blobContent": base64::engine::general_purpose::STANDARD.encode(PATCHED_LIB),
}},
"vulnerabilities": {},
"description": "golang hosted state fixture",
"license": "MIT",
"tier": "free",
});
prebuilt_common::mount_view_from_source(
&server,
&view,
None,
Some(&modcache.join(format!("{UMOD}@{UVER}"))),
)
.await;
let uri = server.uri();
let (code, stdout, stderr) = common::run_with_env(
&consumer,
&["vendor", "--json", "--cwd", consumer.to_str().unwrap()],
&[
"vendor",
"--json",
"--cwd",
consumer.to_str().unwrap(),
"--api-url",
&uri,
"--org",
ORG,
"--api-token",
"fake",
],
&[
("GOMODCACHE", modcache.to_str().unwrap()),
("SOCKET_GOSUMDB_URL", sumdb.as_str()),
Expand All @@ -538,13 +579,24 @@ async fn vendored_takeover_of_hosted_module_unwinds_the_redirect() {
"vendor failed\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("vendor_takeover_reverted_redirect"),
"the takeover is reported: {stdout}"
!stdout.contains(UUID_V),
"the manifest-only patch is not part of the eject: {stdout}"
);
assert!(
!consumer
.join(format!(".socket/vendor/golang/{UUID_V}"))
.exists(),
"the manifest-only patch must not be vendored"
);
assert_eq!(
std::fs::read(socket.join("manifest.json")).unwrap(),
manifest,
"the eject never rewrites the manifest"
);
let go_mod = std::fs::read_to_string(consumer.join("go.mod")).unwrap();
assert!(
go_mod.contains(&format!(
"replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID_V}/{UMOD}@{UVER}"
"replace {UMOD} {UVER} => ./.socket/vendor/golang/{UUID_H}/{UMOD}@{UVER}"
)),
"{go_mod}"
);
Expand Down
87 changes: 56 additions & 31 deletions crates/socket-patch-cli/tests/e2e_sbt_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,17 +786,29 @@ fn drop_evidence(proj: &Path) {
}
}

/// A takeover (vendoring over a hosted pin) the vendored gate would skip
/// (no evidence: a fresh clone) is refused BEFORE the hosted pin is
/// restored: the project stays hosted, never neither hosted nor vendored.
/// The `vendor` flags naming the mock patch API of [`mock_gson_api`].
fn api_flags(uri: &str) -> [&str; 6] {
["--api-url", uri, "--org", "test-org", "--api-token", "fake"]
}

/// Plain `vendor` over a hosted pin ejects it even beside an agent
/// manifest. An eject the vendored gate would skip (no evidence: a fresh
/// clone) is refused BEFORE the hosted pin is restored: the project stays
/// hosted, never neither hosted nor vendored, and the manifest is untouched.
#[test]
fn vendor_takeover_the_gate_would_skip_keeps_the_hosted_pin() {
let tmp = tempfile::tempdir().unwrap();
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
let manifest = std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap();
drop_evidence(&fx.proj());
let (exit, env) = fx.run(&["vendor"]);
let (_rt, server) = mock_gson_api();
let uri = server.uri();
let mut args = vec!["vendor"];
args.extend(api_flags(&uri));
let (exit, env) = fx.run(&args);
assert_eq!(exit, Some(1), "{env}");
assert_eq!(env["error"]["code"], "eject_refused", "{env}");
assert!(
event_triples(&env).contains(&(
GSON.purl(),
Expand All @@ -807,47 +819,47 @@ fn vendor_takeover_the_gate_would_skip_keeps_the_hosted_pin() {
);
assert_eq!(std::fs::read(fx.proj().join(HOSTED_FILE)).unwrap(), hosted);
assert!(!fx.proj().join(VENDORED_FILE).exists());
assert_eq!(
std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap(),
manifest
);
}

/// A dry-run takeover previews (the restore is dry, so the hosted file
/// still pins the GA on disk; the planner's hosted-conflict refusal the wet
/// run never sees is not raised), and the wet run takes over.
/// A dry-run eject of a hosted sbt pin (beside an agent manifest) previews
/// the plan and writes nothing; the wet run vendors the pinned patch and
/// drops the hosted file, leaving the manifest as it was.
#[test]
fn vendor_takeover_of_a_hosted_sbt_pin_dry_and_wet() {
let tmp = tempfile::tempdir().unwrap();
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
let env = fx.ok(&["vendor", "--dry-run"]);
assert!(
env.to_string().contains("vendor_would_revert_redirect"),
"{env}"
);
assert!(
!env.to_string().contains("vendor_sbt_hosted_conflict"),
"{env}"
);
let manifest = std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap();
let (_rt, server) = mock_gson_api();
let uri = server.uri();
let mut args = vec!["vendor"];
args.extend(api_flags(&uri));
let mut dry = args.clone();
dry.push("--dry-run");
let env = fx.ok(&dry);
assert!(env.to_string().contains("eject_planned"), "{env}");
assert_eq!(std::fs::read(fx.proj().join(HOSTED_FILE)).unwrap(), hosted);
let env = fx.ok(&["vendor"]);
assert!(
env.to_string()
.contains("vendor_takeover_reverted_redirect"),
"{env}"
);
assert!(!fx.proj().join(VENDORED_FILE).exists());
let env = fx.ok(&args);
assert!(!fx.proj().join(HOSTED_FILE).exists(), "{env}");
assert!(fx.generated().contains(GSON_UUID));
assert_eq!(
std::fs::read(fx.proj().join(".socket/manifest.json")).unwrap(),
manifest
);
}

/// An eject (plain `vendor` in a hosted project) the vendored gate would
/// skip is refused whole before anything is restored.
#[test]
fn vendor_eject_the_gate_would_skip_is_refused() {
/// A mock patch API serving gson's patch record by `GSON_UUID`: plain
/// `vendor` over a hosted pin ejects it, fetching the pinned record from
/// the API (the agent manifest is not its input). Returns the runtime that
/// keeps the server alive and the server.
fn mock_gson_api() -> (tokio::runtime::Runtime, wiremock::MockServer) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let tmp = tempfile::tempdir().unwrap();
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
std::fs::remove_file(fx.proj().join(".socket/manifest.json")).unwrap();
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
drop_evidence(&fx.proj());
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
Expand All @@ -868,6 +880,19 @@ fn vendor_eject_the_gate_would_skip_is_refused() {
.respond_with(ResponseTemplate::new(200).set_body_json(view))
.mount(&server),
);
(rt, server)
}

/// An eject (plain `vendor` in a hosted project) the vendored gate would
/// skip is refused whole before anything is restored.
#[test]
fn vendor_eject_the_gate_would_skip_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let fx = Fixture::new(tmp.path(), &[(GSON, GSON_UUID)]);
std::fs::remove_file(fx.proj().join(".socket/manifest.json")).unwrap();
let hosted = write_hosted_pin(&fx.proj(), GSON_UUID);
drop_evidence(&fx.proj());
let (_rt, server) = mock_gson_api();
let uri = server.uri();
let (exit, env) = fx.run(&[
"vendor",
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/tests/help_text_hygiene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ fn vendor_and_repair_summaries_read_as_one_line() {
let text = long_help(&[]);
assert!(
text.lines().any(|l| l
== " vendor Store patched dependencies in .socket/vendor/ for offline installs"),
== " vendor Download hosted patches from lockfiles into .socket/vendor/"),
"{text}"
);
assert!(
Expand Down
Loading
Loading