Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1ce0099
Let a group commit hold vendored artifact deletions until it lands
mikolalysenko Oct 7, 2026
2593960
Share one takeover-reach predicate between the hosted engines
mikolalysenko Oct 7, 2026
bfb893d
Make the vendored-to-hosted takeover staged and atomic
mikolalysenko Oct 7, 2026
5b96496
Keep a staged Gradle takeover from deleting the vendored tree
mikolalysenko Oct 7, 2026
d4edf4d
Put back direct hosted writes when the hosted commit fails
mikolalysenko Oct 7, 2026
2ed2870
Check that dry-run takeovers leave the whole tree byte-identical
mikolalysenko Oct 7, 2026
6da1ec4
Make the yarn hosted preflights private to the redirect module
mikolalysenko Oct 7, 2026
8601853
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 7, 2026
bde572b
Skip the yarn berry risk warning when an offline mirror refused the pins
mikolalysenko Oct 7, 2026
6803a10
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 7, 2026
830726e
Keep a yarn berry takeover vendored when the project gates refuse it
mikolalysenko Oct 7, 2026
172bc83
Merge remote-tracking branch 'origin/main' into arch-fix/takeover-atomic
claude Oct 7, 2026
406e111
Keep landed-pin advisories and scope suffixes out of takeover skip re…
claude Oct 7, 2026
c166766
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
34b12c1
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
ed035c1
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
e3ef75a
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
d0c4f19
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
a9adbd3
Label the setup-php pin in ci.yml with its real tag
mikolalysenko Oct 8, 2026
172a25f
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
6fe7bd2
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
47deaea
Merge origin/main into arch-fix/takeover-atomic
mikolalysenko Oct 8, 2026
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
24 changes: 15 additions & 9 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

933 changes: 197 additions & 736 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs

Large diffs are not rendered by default.

581 changes: 581 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted/takeover.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,26 @@ fn vendored_project(root: &Path) {
);
}

/// Every file under `root`, `.socket/` included (relative path → bytes):
/// a dry-run takeover stages its revert in memory and must leave all of
/// it byte-identical, the vendored artifacts included.
fn tree_snapshot(root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
fn walk(root: &Path, dir: &Path, out: &mut std::collections::BTreeMap<String, Vec<u8>>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if std::fs::symlink_metadata(&p).unwrap().is_dir() {
walk(root, &p, out);
} else {
let rel = p.strip_prefix(root).unwrap().to_string_lossy().into_owned();
out.insert(rel, std::fs::read(&p).unwrap());
}
}
}
let mut out = std::collections::BTreeMap::new();
walk(root, root, &mut out);
out
}

fn warning_detail<'a>(doc: &'a Value, code: &str) -> Option<&'a str> {
doc["redirect"]["warnings"]
.as_array()?
Expand Down Expand Up @@ -332,9 +352,15 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() {
vendored_project(root);
let vendored_lock = std::fs::read(root.join("pnpm-lock.yaml")).unwrap();
let vendored_state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap();
let vendored_tree = tree_snapshot(root);

let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true);
assert_eq!(code, 0, "dry-run scan --mode hosted must succeed: {doc:#}");
assert_eq!(
tree_snapshot(root),
vendored_tree,
"dry-run must leave every file, the vendored tarball included, byte-identical"
);
assert_eq!(doc["redirect"]["dryRun"], true, "envelope: {doc:#}");

let codes = warning_codes(&doc);
Expand Down Expand Up @@ -364,10 +390,11 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() {
// writes (the takeover splices the root v9 lock) must be previewed too.
let trust = warning_detail(&doc, "redirect_pnpm_trust_lockfile")
.unwrap_or_else(|| panic!("the trust config must be previewed: {doc:#}"));
// (The vendor run already created pnpm-workspace.yaml for its own
// wiring, so the wet run MERGES the key into it.)
// The vendor run created pnpm-workspace.yaml for its own wiring, and
// the takeover's revert removes it again, so the wet run writes a new
// one: the preview plans against the same reverted project.
assert!(
trust.contains("would be merged into the existing pnpm-workspace.yaml"),
trust.contains("would be written to a new pnpm-workspace.yaml"),
"{trust}"
);
assert!(
Expand Down Expand Up @@ -418,6 +445,12 @@ async fn dry_run_over_vendored_project_previews_the_wet_takeover() {
workspace(root).is_some_and(|w| w.contains("trustLockfile: true")),
"the wet run writes what the preview promised: {wet:#}"
);
let wet_trust = warning_detail(&wet, "redirect_pnpm_trust_lockfile")
.unwrap_or_else(|| panic!("the wet run reports the trust config: {wet:#}"));
assert!(
wet_trust.contains("to a new pnpm-workspace.yaml"),
"the preview named the wet run's file: {wet_trust}"
);
}

/// Refusal parity: a vendored purl whose revert the wet run would REFUSE
Expand Down Expand Up @@ -620,9 +653,11 @@ async fn dry_run_package_lock_takeover_previews_the_npmrc_write() {
assert_eq!(code, 0, "fixture vendor run must succeed: {env:#}");
let vendored_lock = std::fs::read_to_string(root.join("package-lock.json")).unwrap();
assert!(vendored_lock.contains(".socket/vendor/"), "{vendored_lock}");
let vendored_tree = tree_snapshot(root);

let (code, doc) = scan_hosted_json(root, &server.uri(), /*dry_run=*/ true);
assert_eq!(code, 0, "{doc:#}");
assert_eq!(tree_snapshot(root), vendored_tree, "dry run writes nothing");
assert!(
warning_codes(&doc).contains(&"redirect_would_revert_vendored"),
"{doc:#}"
Expand Down Expand Up @@ -702,8 +737,10 @@ async fn vlt_dry_run_over_vendored_project_previews_the_wet_takeover() {
let vendored_lock = std::fs::read(root.join("vlt-lock.json")).unwrap();
let vendored_state = std::fs::read(root.join(".socket/vendor/state.json")).unwrap();
let vendored_pkg = std::fs::read(root.join("package.json")).unwrap();
let vendored_tree = tree_snapshot(root);

let (_, doc) = hosted::scan_hosted(root, &server, &["--dry-run"], &[]);
assert_eq!(tree_snapshot(root), vendored_tree, "dry run writes nothing");
let codes = hosted::warning_codes(&doc);
assert!(
codes.contains(&"redirect_would_revert_vendored".to_string()),
Expand Down
71 changes: 28 additions & 43 deletions crates/socket-patch-cli/tests/covgap_commands_scan_hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2146,16 +2146,13 @@ async fn human_rush_run_prints_the_repo_state_stale_warning_line() {

// ───────── ledger save failure after a successful revert ─────────

/// save_state failure AFTER a successful takeover revert: the wiring is gone
/// but the vendored ledger still claims it, so the purl must fail CLOSED —
/// `redirect_vendored_revert_failed` with the could-not-be-updated detail, a
/// `vendored_revert_failed` skip, and no redirect — and, since the package
/// is now unpatched in both modes, `redirect_takeover_unpatched` with
/// `partial_failure` and exit 1. Reached by making
/// `.socket/vendor` itself read-only (0o555): the entry's empty wiring
/// reverts trivially and its artifact dir under the still-writable
/// `.socket/vendor/npm/` is removed, but persisting the now-empty ledger
/// needs a write in `.socket/vendor` and fails.
/// The vendored ledger cannot be updated (`.socket/vendor` itself is
/// read-only, 0o555): the takeover's revert, the hosted pin and the ledger
/// are one commit, which then fails before it replaces anything. The run
/// fails (exit 1) and NOTHING changed — the lock, the ledger and the
/// artifact are byte-identical, so the package stays vendored and patched.
/// Before the staged takeover, the revert was already on disk, leaving the
/// package unpatched in both modes.
#[cfg(unix)]
#[tokio::test]
async fn ledger_save_failure_after_successful_revert_fails_closed() {
Expand Down Expand Up @@ -2185,6 +2182,7 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() {
std::fs::create_dir_all(&artifact_dir).unwrap();
std::fs::write(artifact_dir.join(format!("{NAME}-{VERSION}.tgz")), b"tgz").unwrap();
let lock_before = std::fs::read(root.join("package-lock.json")).unwrap();
let state_before = std::fs::read(root.join(".socket/vendor/state.json")).unwrap();

let vendor_dir = root.join(".socket/vendor");
std::fs::set_permissions(&vendor_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
Expand All @@ -2198,36 +2196,24 @@ async fn ledger_save_failure_after_successful_revert_fails_closed() {

let (code, doc) = scan_hosted_json(root, &server.uri(), &[], &[]);

// The vendored wiring and artifact are already gone, so the package is
// unpatched in both modes: a stranded takeover, never a success.
assert_eq!(code, 1, "a stranded takeover exits 1: {doc:#}");
assert_eq!(doc["status"], "partial_failure", "envelope: {doc:#}");
assert!(
warning_detail(&doc, "redirect_takeover_unpatched").contains(PURL),
"the stranded package is named: {doc:#}"
);
let detail = warning_detail(&doc, "redirect_vendored_revert_failed");
assert!(
detail.contains("could not be updated"),
"the post-revert ledger-save failure must be named: {detail}"
);
assert!(
doc["redirect"]["skipped"].as_array().is_some_and(|s| s
.iter()
.any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")),
"the refusal must be accounted as skipped: {doc:#}"
assert_eq!(code, 1, "the failed commit fails the run: {doc:#}");
assert_eq!(doc["status"], "error", "envelope: {doc:#}");
let text = doc.to_string();
assert!(text.contains("nothing was changed"), "{doc:#}");
assert!(!text.contains("redirect_takeover_unpatched"), "{doc:#}");
assert_eq!(
std::fs::read(root.join("package-lock.json")).unwrap(),
lock_before,
"no redirect lands"
);
assert_eq!(doc["redirect"]["redirected"], 0, "envelope: {doc:#}");
let lock_after = std::fs::read(root.join("package-lock.json")).unwrap();
assert_eq!(
lock_after, lock_before,
"no redirect may land when the ledger cannot record the takeover"
std::fs::read(root.join(".socket/vendor/state.json")).unwrap(),
state_before,
"the ledger still claims the package"
);
// Fail-closed residue this warning exists to explain: the wiring/artifact
// are reverted but the ledger still claims the entry.
assert!(
root.join(".socket/vendor/state.json").exists(),
"the stale ledger survives (the warning tells the user to fix it)"
artifact_dir.join(format!("{NAME}-{VERSION}.tgz")).exists(),
"the artifact is kept"
);
}

Expand Down Expand Up @@ -2493,10 +2479,9 @@ async fn human_pnpm_rerun_prints_only_the_reminder_and_heal_restores_guidance()

// ───────────────────────────── vlt ─────────────────────────────

/// A vendored vlt entry is never reverted for a hosted takeover the vlt
/// rewriter would then refuse: the lock-level refusal (here a BOM) is known
/// first, the purl is skipped with that code, and the vendored ledger and
/// the lock stay byte-identical.
/// A vendored vlt entry over a lock vlt cannot read (here a BOM) is never
/// taken over: the staged revert refuses the unreadable lock itself, so the
/// purl is skipped and the vendored ledger and the lock stay byte-identical.
#[tokio::test]
async fn vlt_takeover_refusal_before_revert() {
let server = MockServer::start().await;
Expand All @@ -2521,11 +2506,11 @@ async fn vlt_takeover_refusal_before_revert() {
assert!(
doc["redirect"]["skipped"].as_array().is_some_and(|s| s
.iter()
.any(|e| e["purl"] == PURL && e["reason"] == "redirect_vlt_lock_unsupported")),
.any(|e| e["purl"] == PURL && e["reason"] == "vendored_revert_failed")),
"{doc:#}"
);
assert!(warning_detail(&doc, "redirect_vlt_lock_unsupported").contains("BOM"));
assert!(!warning_codes(&doc).contains(&"redirect_vendored_revert_failed".to_string()));
assert!(warning_detail(&doc, "redirect_vendored_revert_failed").contains("vlt-lock.json"));
assert!(!warning_codes(&doc).contains(&"redirect_takeover_reverted_vendored".to_string()));
assert_eq!(
std::fs::read(tmp.path().join(".socket/vendor/state.json")).unwrap(),
state_before
Expand Down
78 changes: 62 additions & 16 deletions crates/socket-patch-cli/tests/e2e_golang_hosted_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,24 +101,36 @@ async fn mount_sumdb(server: &MockServer) {
}

fn get_hosted(consumer: &Path, server: &MockServer, modcache: &Path) -> serde_json::Value {
get_hosted_with(consumer, server, modcache, &[])
}

fn get_hosted_with(
consumer: &Path,
server: &MockServer,
modcache: &Path,
extra: &[&str],
) -> serde_json::Value {
let uri = server.uri();
let mut args = vec![
"get",
UUID_H,
"--mode",
"hosted",
"--json",
"--yes",
"--cwd",
consumer.to_str().unwrap(),
"--api-url",
&uri,
"--org",
ORG,
"--api-token",
"fake",
];
args.extend_from_slice(extra);
let (code, stdout, stderr) = run_with_prebuilt(
consumer,
&[
"get",
UUID_H,
"--mode",
"hosted",
"--json",
"--yes",
"--cwd",
consumer.to_str().unwrap(),
"--api-url",
&server.uri(),
"--org",
ORG,
"--api-token",
"fake",
],
&args,
&[("GOMODCACHE", modcache.to_str().unwrap())],
);
assert_eq!(
Expand All @@ -128,6 +140,24 @@ fn get_hosted(consumer: &Path, server: &MockServer, modcache: &Path) -> serde_js
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("not JSON: {e}\n{stdout}"))
}

/// Every file under `root`, `.socket/` included (relative path → bytes).
fn tree_snapshot(root: &Path) -> std::collections::BTreeMap<String, Vec<u8>> {
fn walk(root: &Path, dir: &Path, out: &mut std::collections::BTreeMap<String, Vec<u8>>) {
for e in std::fs::read_dir(dir).unwrap() {
let p = e.unwrap().path();
if std::fs::symlink_metadata(&p).unwrap().is_dir() {
walk(root, &p, out);
} else {
let rel = p.strip_prefix(root).unwrap().to_string_lossy().into_owned();
out.insert(rel, std::fs::read(&p).unwrap());
}
}
}
let mut out = std::collections::BTreeMap::new();
walk(root, root, &mut out);
out
}

fn write_consumer(consumer: &Path, go_mod_tail: &str, go_sum: &str) {
std::fs::create_dir_all(consumer).unwrap();
std::fs::write(
Expand Down Expand Up @@ -296,6 +326,22 @@ async fn hosted_takeover_of_vendored_module_removes_vendored_state() {

let server = MockServer::start().await;
mount_hosted_grant(&server).await;
// The dry run stages the same revert in memory and drops it: every
// byte of the project, `.socket/` included, stays as it was.
let before = tree_snapshot(&consumer);
let preview = get_hosted_with(&consumer, &server, &modcache, &["--dry-run"]);
assert!(
preview
.to_string()
.contains("redirect_would_revert_vendored"),
"the takeover is previewed: {preview}"
);
assert_eq!(
tree_snapshot(&consumer),
before,
"a dry-run takeover changes nothing: {preview}"
);

let env = get_hosted(&consumer, &server, &modcache);
assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}");

Expand Down
6 changes: 4 additions & 2 deletions crates/socket-patch-cli/tests/e2e_redirect_gradle_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2172,8 +2172,10 @@ fn gradle_hosted_vendored_takeover_and_eject() {
}

/// A vendored Gradle build the hosted planner would refuse (a custom
/// `lockFile`): `scan --mode hosted` refuses the takeover BEFORE reverting
/// anything, so the vendored patch keeps working.
/// `lockFile`): `scan --mode hosted` stages the vendored revert in memory,
/// the planner refuses the pin, and the takeover is retracted before
/// anything reaches the disk (the tree's jars included), so the vendored
/// patch keeps working.
#[test]
#[ignore = "real Gradle; run with --ignored"]
fn gradle_hosted_takeover_refusal_keeps_vendored() {
Expand Down
46 changes: 46 additions & 0 deletions crates/socket-patch-cli/tests/hosted_memory_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,52 @@ async fn vendored_takeover_is_refused() {
assert!(output.changed_files.is_empty());
}

/// The in-memory engine refuses exactly the takeovers the disk flow
/// performs (one shared predicate): a vendored PyPI package is one, so it
/// is refused as a takeover rather than handed to the Python rewriters,
/// which would refuse socket-patch's own vendored source as user-authored.
#[tokio::test]
async fn vendored_pypi_takeover_is_refused_like_the_disk_flow() {
const PYPI_FIXTURE: &str = "redirect/pypi/requirements/basic";
let server = MockServer::start().await;
let patches = patches_from_overrides(
&fixtures_root().join(PYPI_FIXTURE).join("overrides.json"),
None,
);
mount_api(&server, &patches).await;
let mut files = fixture_files(&fixtures_root().join(PYPI_FIXTURE).join("input"));
let uuid = "33333333-3333-3333-3333-333333333333";
files.insert(
".socket/vendor/state.json".into(),
serde_json::to_vec(&serde_json::json!({
"version": 1,
"entries": {
"pkg:pypi/requests@2.28.1": {
"ecosystem": "pypi",
"basePurl": "pkg:pypi/requests@2.28.1",
"uuid": uuid,
"flavor": "requirements",
"artifact": {"path": format!(".socket/vendor/pypi/{uuid}/requests-2.28.1-py3-none-any.whl")},
"wiring": []
}
}
}))
.unwrap(),
);
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
let project = &output.projects[0];
assert!(
project
.skipped
.iter()
.any(|s| s.reason == "vendored_takeover_unsupported_in_memory"),
"{:?}",
project.skipped
);
assert!(project.redirected.is_empty());
assert!(output.changed_files.is_empty());
}

/// A pre-v5 redirect ledger (`.socket/vendor/redirect-state.json`) is
/// never read by the v5 engine: a torn one neither fails its project nor
/// changes its plan, and the engine never emits (or rewrites) the file.
Expand Down
Loading
Loading