Support hosted Pipenv patches and safe vendoring - #242
Merged
Mikola Lysenko (mikolalysenko) merged 28 commits intoSep 18, 2026
Merged
Conversation
Preserve Pipenv categories and source identity when applying patches. Handle legacy hosted references and vendored extras, reject unsupported installers, and restore lock entries safely during rollback. Assisted-by: Codex:gpt-6-astra
Explain the version requirement when Pipenv is unavailable, and point local-wheel verification guidance at the existing VEX command. Assisted-by: Codex:gpt-6-astra
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Stale Pipfile.lock blocks sibling redirects
- Modified pipenv::rewrite to only add UUIDs to refused_pipenv_uuids for actual conflicts (version mismatches, existing sources, non-registry packages), not for missing entries or malformed locks.
Or push these changes by commenting:
@cursor push 2ea444ba23
Preview (2ea444ba23)
diff --git a/crates/socket-patch-core/src/crawlers/go_crawler.rs b/crates/socket-patch-core/src/crawlers/go_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/go_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/go_crawler.rs
@@ -1196,8 +1196,7 @@
// FIRST GOPATH entry is skipped in favor of the next non-empty one
// (GOPATH is an OS-separator-delimited list; Go uses the first
// usable entry for the module cache).
- let gopath_list =
- std::env::join_paths(["".as_ref(), gopath_a.path().as_os_str()]).unwrap();
+ let gopath_list = std::env::join_paths(["".as_ref(), gopath_a.path().as_os_str()]).unwrap();
let _gomodcache = EnvGuard::set("GOMODCACHE", "");
let _gopath = EnvGuard::set("GOPATH", gopath_list.to_str().unwrap());
let _home = EnvGuard::set("HOME", "/nonexistent-home-unused");
diff --git a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/nuget_crawler.rs
@@ -748,7 +748,9 @@
async fn test_scan_package_dir_dedups_same_package_across_two_scans() {
let dir = tempfile::tempdir().unwrap();
let pkg_dir = dir.path().join("newtonsoft.json").join("13.0.3");
- tokio::fs::create_dir_all(pkg_dir.join("lib")).await.unwrap();
+ tokio::fs::create_dir_all(pkg_dir.join("lib"))
+ .await
+ .unwrap();
let crawler = NuGetCrawler::new();
let mut seen = HashSet::new();
diff --git a/crates/socket-patch-core/src/package_json/find.rs b/crates/socket-patch-core/src/package_json/find.rs
--- a/crates/socket-patch-core/src/package_json/find.rs
+++ b/crates/socket-patch-core/src/package_json/find.rs
@@ -846,10 +846,7 @@
// below); if the team decides such values should degrade to
// WorkspaceType::None (npm semantics), update detect_workspaces and
// flip these assertions together.
- for spelling in [
- r#"{"workspaces": "packages/*"}"#,
- r#"{"workspaces": null}"#,
- ] {
+ for spelling in [r#"{"workspaces": "packages/*"}"#, r#"{"workspaces": null}"#] {
let dir = tempfile::tempdir().unwrap();
let pkg = dir.path().join("package.json");
fs::write(&pkg, spelling).await.unwrap();
diff --git a/crates/socket-patch-core/src/package_json/update.rs b/crates/socket-patch-core/src/package_json/update.rs
--- a/crates/socket-patch-core/src/package_json/update.rs
+++ b/crates/socket-patch-core/src/package_json/update.rs
@@ -787,7 +787,9 @@
);
assert!(result.new_script.is_none());
assert!(
- result.old_dependencies_script.contains("socket-patch apply"),
+ result
+ .old_dependencies_script
+ .contains("socket-patch apply"),
"old_dependencies_script must reflect the configured script, got {:?}",
result.old_dependencies_script
);
diff --git a/crates/socket-patch-core/src/patch/apply.rs b/crates/socket-patch-core/src/patch/apply.rs
--- a/crates/socket-patch-core/src/patch/apply.rs
+++ b/crates/socket-patch-core/src/patch/apply.rs
@@ -2763,7 +2763,9 @@
assert!(result.success, "expected success: {:?}", result.error);
assert_eq!(result.files_patched, vec!["new.js".to_string()]);
assert_eq!(result.applied_via.get("new.js"), Some(&AppliedVia::Blob));
- let written = tokio::fs::read(pkg_dir.path().join("new.js")).await.unwrap();
+ let written = tokio::fs::read(pkg_dir.path().join("new.js"))
+ .await
+ .unwrap();
assert_eq!(written, fresh, "divergent existing content is overwritten");
}
@@ -2811,7 +2813,9 @@
assert!(result.success, "strict still overwrites at a new-file path");
assert_eq!(result.files_patched, vec!["new.js".to_string()]);
- let written = tokio::fs::read(pkg_dir.path().join("new.js")).await.unwrap();
+ let written = tokio::fs::read(pkg_dir.path().join("new.js"))
+ .await
+ .unwrap();
assert_eq!(written, fresh);
}
@@ -2961,7 +2965,9 @@
let evil = b"pwned";
let evil_hash = compute_git_sha256_from_bytes(evil);
- tokio::fs::write(pkg.join("index.js"), original).await.unwrap();
+ tokio::fs::write(pkg.join("index.js"), original)
+ .await
+ .unwrap();
tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
.await
.unwrap();
@@ -3005,7 +3011,10 @@
assert!(result.files_patched.is_empty());
// The safe file was never written — the whole apply aborted
// before the write phase.
- assert_eq!(tokio::fs::read(pkg.join("index.js")).await.unwrap(), original);
+ assert_eq!(
+ tokio::fs::read(pkg.join("index.js")).await.unwrap(),
+ original
+ );
assert!(!root.path().join("escape.js").exists());
}
@@ -3028,9 +3037,15 @@
after_hash: compute_git_sha256_from_bytes(b"x"),
};
- let applied =
- try_apply_from_diff(Some(&entries), "new.js", dir.path(), "new.js", &info, Some("anything"))
- .await;
+ let applied = try_apply_from_diff(
+ Some(&entries),
+ "new.js",
+ dir.path(),
+ "new.js",
+ &info,
+ Some("anything"),
+ )
+ .await;
assert!(!applied, "new-file entries must never apply via diff");
assert!(!dir.path().join("new.js").exists(), "nothing written");
}
diff --git a/crates/socket-patch-core/src/patch/apply_lock.rs b/crates/socket-patch-core/src/patch/apply_lock.rs
--- a/crates/socket-patch-core/src/patch/apply_lock.rs
+++ b/crates/socket-patch-core/src/patch/apply_lock.rs
@@ -496,9 +496,9 @@
fs2::lock_contended_error().raw_os_error()
);
}
- LockError::Held => panic!(
- "a genuine flock fault must not be mislabelled as contention"
- ),
+ LockError::Held => {
+ panic!("a genuine flock fault must not be mislabelled as contention")
+ }
}
// The fault arm returns without ever entering the retry/backoff
// path: nowhere near the 5 s budget (the old funnel-everything-
@@ -530,9 +530,9 @@
fs2::lock_contended_error().raw_os_error()
);
}
- LockError::Held => panic!(
- "try-once mode must not mislabel a genuine flock fault as Held"
- ),
+ LockError::Held => {
+ panic!("try-once mode must not mislabel a genuine flock fault as Held")
+ }
}
}
diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs
--- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs
+++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs
@@ -2177,7 +2177,10 @@
// Parity: the real run removes exactly what the dry run reported.
let removed_wet = reconcile_go_redirects(root, &HashSet::new(), false).await;
- assert_eq!(removed_wet, removed, "dry-run report must match the real run");
+ assert_eq!(
+ removed_wet, removed,
+ "dry-run report must match the real run"
+ );
assert!(!copy_dir.exists(), "real run prunes the copy");
assert!(read_replace_entries(root).await.is_empty());
}
diff --git a/crates/socket-patch-core/src/patch/redirect/pipenv.rs b/crates/socket-patch-core/src/patch/redirect/pipenv.rs
--- a/crates/socket-patch-core/src/patch/redirect/pipenv.rs
+++ b/crates/socket-patch-core/src/patch/redirect/pipenv.rs
@@ -178,7 +178,12 @@
result.edits.extend(edits);
}
Err(detail) => {
- result.refused_pipenv_uuids.insert(dep.patch_uuid.clone());
+ let is_conflict = detail.contains("already exists")
+ || detail.contains("does not match")
+ || detail.contains("is not a registry package");
+ if is_conflict {
+ result.refused_pipenv_uuids.insert(dep.patch_uuid.clone());
+ }
result.warnings.push(RewriteWarning {
code: "redirect_pipenv_refused".into(),
detail,
diff --git a/crates/socket-patch-core/src/patch/redirect/replay.rs b/crates/socket-patch-core/src/patch/redirect/replay.rs
--- a/crates/socket-patch-core/src/patch/redirect/replay.rs
+++ b/crates/socket-patch-core/src/patch/redirect/replay.rs
@@ -656,9 +656,7 @@
}
}
}
- outcome
- .reverted_files
- .extend(staged.keys().cloned());
+ outcome.reverted_files.extend(staged.keys().cloned());
pending_warnings.extend(group_warnings);
drop_indices.extend(group_drops);
}
@@ -725,9 +723,9 @@
let mut state = RedirectState::new();
state.edits = edits;
for p in record_purls {
- state
- .records
- .insert((*p).to_string(), crate::manifest::schema::PatchRecord {
+ state.records.insert(
+ (*p).to_string(),
+ crate::manifest::schema::PatchRecord {
uuid: "u".into(),
exported_at: "now".into(),
files: Default::default(),
@@ -776,7 +774,10 @@
);
let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
assert!(out.fully_reverted(), "{:?}", out.refusals);
- assert_eq!(read(dir.path(), "requirements.txt").await, "left-pad==1.3.0\n");
+ assert_eq!(
+ read(dir.path(), "requirements.txt").await,
+ "left-pad==1.3.0\n"
+ );
assert!(state.edits.is_empty());
assert!(state.records.is_empty());
assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]);
@@ -804,7 +805,10 @@
);
let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
assert!(out.fully_reverted(), "{:?}", out.refusals);
- assert_eq!(read(dir.path(), "pom.xml").await, "<version>2.17.1</version>\n");
+ assert_eq!(
+ read(dir.path(), "pom.xml").await,
+ "<version>2.17.1</version>\n"
+ );
}
#[tokio::test]
@@ -1248,7 +1252,9 @@
// The npm group refused; the bun group replayed.
assert_eq!(out.refusals.len(), 1);
assert_eq!(out.refusals[0].group, "npm");
- assert!(read(dir.path(), "bun.lock").await.contains("upstream.example"));
+ assert!(read(dir.path(), "bun.lock")
+ .await
+ .contains("upstream.example"));
// npm-family records are held while ANY npm-family group refused.
assert!(state.records.contains_key("pkg:npm/a@1"));
assert_eq!(state.edits.len(), 1, "only the refused npm edit remains");
@@ -1428,7 +1434,9 @@
assert_eq!(out.dropped_records, vec!["pkg:pypi/left-pad@1.3.0"]);
assert!(out.reverted_files.contains("requirements.txt"));
// Disk and ledger untouched.
- assert!(read(dir.path(), "requirements.txt").await.contains("patch.example"));
+ assert!(read(dir.path(), "requirements.txt")
+ .await
+ .contains("patch.example"));
assert_eq!(state.edits.len(), 1);
assert_eq!(state.records.len(), 1);
}
@@ -1645,7 +1653,9 @@
);
let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
assert_eq!(out.refusals.len(), 1, "{out:?}");
- assert!(out.refusals[0].reason.contains("missing its recorded lines"));
+ assert!(out.refusals[0]
+ .reason
+ .contains("missing its recorded lines"));
assert_eq!(state.edits.len(), 1);
assert_eq!(read(dir.path(), "go.sum").await, "x v1 h1:a\n");
}
@@ -1690,10 +1700,16 @@
];
for (path, kind, action, original, new) in cases {
let dir = TempDir::new().unwrap();
- tokio::fs::create_dir_all(dir.path().join(path)).await.unwrap();
+ tokio::fs::create_dir_all(dir.path().join(path))
+ .await
+ .unwrap();
let mut state = state_with(vec![edit(path, kind, action, original, new)], &[]);
let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
- assert_eq!(out.refusals.len(), 1, "{kind}/{action} must refuse: {out:?}");
+ assert_eq!(
+ out.refusals.len(),
+ 1,
+ "{kind}/{action} must refuse: {out:?}"
+ );
assert!(
out.refusals[0].reason.starts_with(&format!("read {path}:")),
"{kind}/{action}: {}",
@@ -1958,8 +1974,11 @@
);
let out = revert_remaining_redirect_edits(dir.path(), &mut state, false).await;
assert_eq!(out.refusals.len(), 1, "{out:?}");
- assert_eq!(out.refusals[0].reason, "composer.lock is not a regular file");
assert_eq!(
+ out.refusals[0].reason,
+ "composer.lock is not a regular file"
+ );
+ assert_eq!(
read(dir.path(), "real.lock").await,
"https://patch.example/a\n",
"the symlink target must stay byte-identical"
diff --git a/crates/socket-patch-core/src/patch/redirect/takeover.rs b/crates/socket-patch-core/src/patch/redirect/takeover.rs
--- a/crates/socket-patch-core/src/patch/redirect/takeover.rs
+++ b/crates/socket-patch-core/src/patch/redirect/takeover.rs
@@ -1082,7 +1082,10 @@
// previews — the whole-ledger replay running after per-purl
// reverts — must see the post-claim state); the caller owns the
// clone and never persists it on a dry run.
- assert!(state.records.len() < records_before, "record claimed in memory");
+ assert!(
+ state.records.len() < records_before,
+ "record claimed in memory"
+ );
assert!(state.edits.len() < edits_before, "edits claimed in memory");
// The preview names exactly the files a wet run then reverts —
@@ -1901,7 +1904,9 @@
.and_then(Value::as_object_mut)
.unwrap();
entry.remove("name").expect("fixture name field present");
- entry.remove("version").expect("fixture version field present");
+ entry
+ .remove("version")
+ .expect("fixture version field present");
tokio::fs::write(
root.join("package-lock.json"),
serde_json::to_string_pretty(&on_disk).unwrap(),
@@ -2038,9 +2043,7 @@
.await;
let root = tmp.path();
assert_eq!(state.edits.len(), 2, "{:?}", state.edits);
- let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0")
- .artifact_url
- .clone();
+ let scoped_url = npm_dep_for("@scope/left-pad", "1.3.0").artifact_url.clone();
// Hand edit / merge artifact: strip the alias entry's name+version.
let mut on_disk: Value = serde_json::from_str(
&tokio::fs::read_to_string(root.join("package-lock.json"))
@@ -2054,7 +2057,9 @@
.and_then(Value::as_object_mut)
.unwrap();
entry.remove("name").expect("fixture name field present");
- entry.remove("version").expect("fixture version field present");
+ entry
+ .remove("version")
+ .expect("fixture version field present");
tokio::fs::write(
root.join("package-lock.json"),
serde_json::to_string_pretty(&on_disk).unwrap(),
@@ -2361,7 +2366,9 @@
let cfg_before = tokio::fs::read_to_string(root.join(".cargo/config.toml"))
.await
.unwrap();
- tokio::fs::remove_file(root.join("Cargo.lock")).await.unwrap();
+ tokio::fs::remove_file(root.join("Cargo.lock"))
+ .await
+ .unwrap();
let records_before = state.records.len();
let edits_before = state.edits.len();
@@ -2515,9 +2522,12 @@
let wired_toml = tokio::fs::read_to_string(root.join("Cargo.toml"))
.await
.unwrap();
- tokio::fs::write(root.join("Cargo.toml"), format!("{wired_toml}{pinned_line}"))
- .await
- .unwrap();
+ tokio::fs::write(
+ root.join("Cargo.toml"),
+ format!("{wired_toml}{pinned_line}"),
+ )
+ .await
+ .unwrap();
let out = revert_cargo_redirect_purl(root, &mut state, PURL, false)
.await
@@ -2627,7 +2637,9 @@
async fn npm_missing_text_lock_refuses_and_keeps_the_ledger() {
let (tmp, mut state) = npm_redirected_fixture("yarn.lock", &classic_pristine()).await;
let root = tmp.path();
- tokio::fs::remove_file(root.join("yarn.lock")).await.unwrap();
+ tokio::fs::remove_file(root.join("yarn.lock"))
+ .await
+ .unwrap();
let records_before = state.records.len();
let edits_before = state.edits.len();
@@ -2821,7 +2833,10 @@
let err = revert_npm_redirect_purl(root, &mut state, NPM_PURL, false)
.await
.expect_err("vanished v2 tree must refuse");
- assert!(err.contains("no longer holds a `dependencies` tree"), "{err}");
+ assert!(
+ err.contains("no longer holds a `dependencies` tree"),
+ "{err}"
+ );
let after = tokio::fs::read_to_string(root.join("package-lock.json"))
.await
.unwrap();
diff --git a/crates/socket-patch-core/src/patch/rollback.rs b/crates/socket-patch-core/src/patch/rollback.rs
--- a/crates/socket-patch-core/src/patch/rollback.rs
+++ b/crates/socket-patch-core/src/patch/rollback.rs
@@ -2087,12 +2087,9 @@
.await
.unwrap();
// Blob whose CONTENT does not match its name — verifies Ready.
- tokio::fs::write(
- blobs_dir.path().join(&before_hash),
- b"corrupted blob bytes",
- )
- .await
- .unwrap();
+ tokio::fs::write(blobs_dir.path().join(&before_hash), b"corrupted blob bytes")
+ .await
+ .unwrap();
let mut files = HashMap::new();
files.insert(
diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs
--- a/crates/socket-patch-core/src/setup/gem/mod.rs
+++ b/crates/socket-patch-core/src/setup/gem/mod.rs
@@ -2063,7 +2063,8 @@
let root = dir.path();
let plugin_root = root.join(".bundle/plugin");
let index = plugin_root.join("index");
- let body = "---\ncommands:\nhooks:\n after-install:\n - \"other\"\n - \"socket-patch\"\n\
+ let body =
+ "---\ncommands:\nhooks:\n after-install:\n - \"other\"\n - \"socket-patch\"\n\
load_paths:\n other:\n - \"/x/other/.\"\n socket-patch:\n - \"/proj/p/.\"\n\
plugin_paths:\n other: \"/x/other\"\n socket-patch: \"/proj/p\"\nsources:\n";
write(&index, body).await;
@@ -2132,8 +2133,12 @@
.await;
let r = add_plugin_files(root, false).await;
- assert_eq!(r.status, GemSetupStatus::Updated, "stale plugins.rb resynced");
assert_eq!(
+ r.status,
+ GemSetupStatus::Updated,
+ "stale plugins.rb resynced"
+ );
+ assert_eq!(
fs::read_to_string(plugins_rb_path(root)).await.unwrap(),
PLUGINS_RB
);
diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs
--- a/crates/socket-patch-core/src/setup/gem/update.rs
+++ b/crates/socket-patch-core/src/setup/gem/update.rs
@@ -1163,11 +1163,8 @@
// runtime waits for on shutdown; connect a writer to release it so
// the test can FAIL instead of hanging the whole suite.
let deadline = std::time::Duration::from_secs(5);
- let Ok(results) = tokio::time::timeout(
- deadline,
- remove_plugin_directive_at(&project, None, false),
- )
- .await
+ let Ok(results) =
+ tokio::time::timeout(deadline, remove_plugin_directive_at(&project, None, false)).await
else {
let _ = std::fs::OpenOptions::new().write(true).open(&index);
panic!("remove must complete promptly with a FIFO index");
diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs
--- a/crates/socket-patch-core/src/update/download.rs
+++ b/crates/socket-patch-core/src/update/download.rs
@@ -741,7 +741,10 @@
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("never-existed");
sweep_stale_stages(&missing);
- assert!(!missing.exists(), "sweep must not create the destination dir");
+ assert!(
+ !missing.exists(),
+ "sweep must not create the destination dir"
+ );
}
/// A write failure AFTER a successful open (EFBIG here, standing in
@@ -757,8 +760,7 @@
#[test]
fn stage_write_failure_cleans_up_stage_file() {
const CHILD_ENV: &str = "SOCKET_PATCH_CORE_TEST_STAGE_FSIZE_CHILD";
- const TEST_NAME: &str =
- "update::download::tests::stage_write_failure_cleans_up_stage_file";
+ const TEST_NAME: &str = "update::download::tests::stage_write_failure_cleans_up_stage_file";
if std::env::var_os(CHILD_ENV).is_none() {
let exe = std::env::current_exe().expect("test binary path must resolve");
let output = std::process::Command::new(exe)
@@ -824,7 +826,10 @@
matches!(err, UpdateError::SwapFailed(_)),
"expected SwapFailed, got: {err}"
);
- assert!(err.to_string().contains("error writing staged binary"), "{err}");
+ assert!(
+ err.to_string().contains("error writing staged binary"),
+ "{err}"
+ );
let leftovers: Vec<String> = std::fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs
--- a/crates/socket-patch-core/src/update/release.rs
+++ b/crates/socket-patch-core/src/update/release.rs
@@ -680,9 +680,11 @@
.mount(&server)
.await;
- let client =
- metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints()))
- .unwrap();
+ let client = metadata_client(
+ &short_timeouts(),
+ follow_redirect_policy(&default_endpoints()),
+ )
+ .unwrap();
let err = client
.get(format!("{}/start", server.uri()))
.send()
@@ -715,9 +717,11 @@
.mount(&server)
.await;
- let client =
- metadata_client(&short_timeouts(), follow_redirect_policy(&default_endpoints()))
- .unwrap();
+ let client = metadata_client(
+ &short_timeouts(),
+ follow_redirect_policy(&default_endpoints()),
+ )
+ .unwrap();
let err = client
.get(format!("{}/start", server.uri()))
.send()
@@ -793,7 +797,10 @@
.unwrap_err();
assert!(matches!(err, UpdateError::CheckFailed(_)), "{err:?}");
let msg = err.to_string();
- assert!(msg.contains("expected a redirect to the latest tag"), "{msg}");
+ assert!(
+ msg.contains("expected a redirect to the latest tag"),
+ "{msg}"
+ );
assert!(msg.contains("API fallback:"), "{msg}");
assert!(msg.contains("returned 500"), "{msg}");
}
@@ -836,7 +843,9 @@
// silently.
let server = MockServer::start().await;
Mock::given(method("GET"))
- .and(path("/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS"))
+ .and(path(
+ "/SocketDev/socket-patch/releases/download/v1.2.3/SHA256SUMS",
+ ))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs
--- a/crates/socket-patch-core/src/utils/python_lock.rs
+++ b/crates/socket-patch-core/src/utils/python_lock.rs
@@ -848,7 +848,9 @@
"{rewritten}"
);
assert!(
- rewritten.contains(&format!("{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}")),
+ rewritten.contains(&format!(
+ "{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}"
+ )),
"{rewritten}"
);
assert!(!rewritten.contains("direct+"), "{rewritten}");
@@ -916,12 +918,17 @@
"{rewritten}"
);
assert!(
- rewritten.contains(&format!("wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]")),
+ rewritten.contains(&format!(
+ "wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]"
+ )),
"{rewritten}"
);
assert!(!rewritten.contains("[[distribution.wheel]]"), "{rewritten}");
assert!(!rewritten.contains("sdist"), "{rewritten}");
- assert!(rewritten.contains("[[distribution.dependencies]]\nname = \"urllib3\""), "{rewritten}");
+ assert!(
+ rewritten.contains("[[distribution.dependencies]]\nname = \"urllib3\""),
+ "{rewritten}"
+ );
assert_eq!(
rewrite_python_lock(
&rewritten,
@@ -1062,7 +1069,11 @@
Some(&*format!("sha256:{SHA256}"))
);
assert!(entry.get("wheels").is_none(), "{rewritten}");
- assert_eq!(rewritten.matches("[[distribution.wheel]]").count(), 2, "{rewritten}");
+ assert_eq!(
+ rewritten.matches("[[distribution.wheel]]").count(),
+ 2,
+ "{rewritten}"
+ );
assert!(!rewritten.contains("wheels = ["), "{rewritten}");
// The sibling's own artifact is untouched.
assert!(rewritten.contains("certifi-2024.2.2-py3-none-any.whl"));
@@ -1109,7 +1120,9 @@
assert_eq!(entry["wheels"][0]["url"].as_str(), Some(URL));
assert!(entry.get("wheel").is_none(), "{rewritten}");
assert!(
- rewritten.contains(&format!("wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]")),
+ rewritten.contains(&format!(
+ "wheels = [{{ url = \"{URL}\", hash = \"sha256:{SHA256}\" }}]"
+ )),
"{rewritten}"
);
assert!(!rewritten.contains("[[distribution.wheel]]"), "{rewritten}");
@@ -1397,15 +1410,10 @@
{ url = "https://pypi.org/urllib3-1.26.18-py2.py3-none-any.whl", hash = "sha256:old", size = 123 },
]
"#;
- let rewritten = rewrite_python_lock(
- text,
- "urllib3",
- "1.26.18",
- ArtifactSource::Url(URL),
- SHA256,
- )
- .unwrap()
- .unwrap();
+ let rewritten =
+ rewrite_python_lock(text, "urllib3", "1.26.18", ArtifactSource::Url(URL), SHA256)
+ .unwrap()
+ .unwrap();
let document: toml_edit::DocumentMut = rewritten.parse().unwrap();
let manifest = &document["manifest"];
let constraint = manifest["constraints"][0].as_inline_table().unwrap();
@@ -1414,7 +1422,10 @@
let build = manifest["build-constraints"].as_array().unwrap();
let foreign = build.get(0).unwrap().as_inline_table().unwrap();
assert_eq!(foreign["specifier"].as_str(), Some("==1.0"));
- assert!(foreign.get("url").is_none(), "a foreign constraint is untouched");
+ assert!(
+ foreign.get("url").is_none(),
+ "a foreign constraint is untouched"
+ );
let ours = build.get(1).unwrap().as_inline_table().unwrap();
assert_eq!(ours["url"].as_str(), Some(URL));
assert!(ours.get("specifier").is_none(), "{rewritten}");
diff --git a/crates/socket-patch-core/src/utils/python_script.rs b/crates/socket-patch-core/src/utils/python_script.rs
--- a/crates/socket-patch-core/src/utils/python_script.rs
+++ b/crates/socket-patch-core/src/utils/python_script.rs
@@ -612,7 +612,12 @@
"{direct}"
);
assert!(uv_line.ends_with('}'), "{direct}");
- assert!(direct.starts_with("[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"), "{direct}");
+ assert!(
+ direct.starts_with(
+ "[project]\nname = \"p\"\ndependencies = [\"alpha==1.0.0\"]\n\n[tool]\n"
+ ),
+ "{direct}"
+ );
assert_settled(&direct);
let transitive = rewrite_project_metadata(
diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs
--- a/crates/socket-patch-core/src/vendor/cargo.rs
+++ b/crates/socket-patch-core/src/vendor/cargo.rs
@@ -3061,9 +3061,9 @@
async fn marker_write_failure_warns_but_vendor_succeeds() {
let (dir, blobs, pristine, record) = fixture().await;
let root = dir.path();
- tokio::fs::create_dir_all(root.join(format!(
- ".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}"
- )))
+ tokio::fs::create_dir_all(
+ root.join(format!(".socket/vendor/cargo/{UUID}/{VENDOR_MARKER_FILE}")),
+ )
.await
.unwrap();
@@ -3103,7 +3103,10 @@
let out = revert_cargo_vendor(&entry, root, false).await;
assert!(!out.success);
assert!(
- out.error.as_deref().unwrap_or("").contains("not a cargo purl"),
+ out.error
+ .as_deref()
+ .unwrap_or("")
+ .contains("not a cargo purl"),
"{:?}",
out.error
);
@@ -3224,7 +3227,7 @@
);
}
- // ── swap_stage_into_place unit edges ──────────────────────────────────
+ // ── swap_stage_into_place unit edges ────────────────────────��─────────
/// A failed stage rename with NO pre-existing copy parked (had_old =
/// false skips the backup restore): the error propagates and no backup
diff --git a/crates/socket-patch-core/src/vendor/common.rs b/crates/socket-patch-core/src/vendor/common.rs
--- a/crates/socket-patch-core/src/vendor/common.rs
+++ b/crates/socket-patch-core/src/vendor/common.rs
@@ -498,8 +498,9 @@
fn in_sync_jar_fixture(
dir: &Path,
) -> (std::path::PathBuf, HashMap<String, PatchFileInfo>, Vec<u8>) {
- let zip_bytes = write_zip_entries(&[("lib/a.js".to_string(), b"patched\n".to_vec(), 0o644)])
- .expect("fixture zip");
+ let zip_bytes =
+ write_zip_entries(&[("lib/a.js".to_string(), b"patched\n".to_vec(), 0o644)])
+ .expect("fixture zip");
let jar = dir.join("pkg.jar");
std::fs::write(&jar, &zip_bytes).unwrap();
let files = HashMap::from([(
diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs
--- a/crates/socket-patch-core/src/vendor/composer_lock.rs
+++ b/crates/socket-patch-core/src/vendor/composer_lock.rs
@@ -1587,7 +1587,9 @@
// Drift the committed copy so the rerun takes the rebuild path…
let drifted = root.join(copy_rel()).join("src/LoggerInterface.php");
- tokio::fs::write(&drifted, b"<?php // drifted\n").await.unwrap();
+ tokio::fs::write(&drifted, b"<?php // drifted\n")
+ .await
+ .unwrap();
// …and make the rebuild fail: the patch bytes cannot be sourced.
let empty = root.join("empty-blobs");
tokio::fs::create_dir_all(&empty).await.unwrap();
@@ -2265,7 +2267,9 @@
assert!(e1.is_some());
let drifted = root.join(copy_rel()).join("src/LoggerInterface.php");
- tokio::fs::write(&drifted, b"<?php // drifted\n").await.unwrap();
+ tokio::fs::write(&drifted, b"<?php // drifted\n")
+ .await
+ .unwrap();
// Integrity-valid garbage: the download verifies, the extract fails.
let garbage = b"not a zip at all".to_vec();
@@ -2453,7 +2457,9 @@
);
assert!(entry.is_some(), "the wiring is live, the entry is recorded");
assert!(
- warnings.iter().any(|w| w.code == "vendor_marker_write_failed"),
+ warnings
+ .iter()
+ .any(|w| w.code == "vendor_marker_write_failed"),
"{warnings:?}"
);
// The surgery really landed despite the marker failure.
@@ -2494,10 +2500,8 @@
impl Drop for ModeGuard {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
- let _ = std::fs::set_permissions(
- &self.path,
- std::fs::Permissions::from_mode(self.mode),
- );
+ let _ =
+ std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(self.mode));
}
}
@@ -2562,7 +2566,9 @@
PATCHED
);
assert!(
- warnings.iter().all(|w| !w.code.starts_with("vendor_prebuilt")),
+ warnings
+ .iter()
+ .all(|w| !w.code.starts_with("vendor_prebuilt")),
"build source must never touch the service: {warnings:?}"
);
}
@@ -2721,7 +2727,9 @@
assert!(result.success, "{:?}", result.error);
assert!(entry.is_some());
assert!(
- warnings.iter().any(|w| w.code == "vendor_prebuilt_unavailable"),
+ warnings
+ .iter()
+ .any(|w| w.code == "vendor_prebuilt_unavailable"),
... diff truncated: showing 800 of 2812 linesYou can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fa6b27f. Configure here.
Pipenv keeps a project's virtualenv OUTSIDE the project by default
(`$WORKON_HOME/<dir>-<hash>[-<python>]`), so after a plain `pipenv install`
the crawler found no VIRTUAL_ENV / .venv / venv and — because a Pipfile is a
Python project marker — fell through to the global interpreter's
site-packages: a bare `scan --mode agent` patched nothing for the project's
dependencies (or a different interpreter that happened to carry the same
release) and reported success, and a bare `rollback` pruned the manifest
while the venv stayed patched. Measured on real Pipenv 11.10.4, 2018.11.26
and 2026.8.0: the PR-head binary scanned 56 global distributions and found
no patch; the fixed binary scans the 2-distribution Pipenv venv and finds
urllib3 1.26.18.
The crawler now reproduces Pipenv's own placement without running Pipenv:
WORKON_HOME (`$VAR`/`${VAR}`/`%VAR%`/`~` expanded like expandvars +
expanduser) or the `$XDG_DATA_HOME`/`~/.local/share/virtualenvs`
(`~/.virtualenvs` on Windows) default, the `.venv` FILE pointer (relative
path or WORKON_HOME name), PIPENV_CUSTOM_VENV_NAME, PIPENV_PIPFILE, and
`Project._get_virtualenv_hash` — the sanitized directory name capped at 42
chars, a dash, the first 6 bytes of sha256(absolute Pipfile path) in URL-safe
base64 — unchanged from Pipenv 7 through 2026 and pinned by known-answer
vectors. Both sanitizer generations (2022+ also replaces `& ( ) [ ]`) are
tried, any `-<PIPENV_PYTHON>` suffix is matched, and Pipenv's
case-insensitive-filesystem fallback (a recased directory hashed over the
recased location) is honoured. A project-local venv still wins.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`scan --mode vendored` on a fresh clone (pyproject + poetry.lock, nothing installed) was skipped with `vendor_fetch_unverifiable` + `package_not_installed` although the lock records the wheel's sha256 — the poetry.lock inventory was discovery-only (`LockIntegrity::None`), so the fetch gate refused before the vendor engine ever ran, while the identical uv.lock scenario vendored fine. That broke the CI story for Poetry: the machine that vendors had to have the package installed. The inventory now carries the pure-Python (`-none-any.whl`) wheel's sha256 from `files` (lock 2.x) or `[metadata.files]` (lock 1.0/1.1), lowercased, and the pypi fetcher resolves a hash-only entry through PyPI's JSON API (`urls[].digests.sha256`, `SOCKET_PYPI_JSON_API` overrides the endpoint) and verifies the download against the same digest, exactly like uv's lock-only path. Poetry 0.12's bare `[metadata.hashes]` names no wheel, and platform-only wheels offer no platform-independent choice, so those stay discovery-only. Measured on the fixed CLI: lock-only vendoring now applies on Poetry 1.0 (populated lock), 1.2, 1.8 and 2.4 fixtures (`vendor_fetched_missing` + `vendor_prebuilt_downloaded`), and the resulting checkout installs the patched wheel with every release. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… in every mode
The lock inventory never read Pipfile.lock ("Pipenv/pdm locks: not yet
read"), so a fresh clone with only Pipfile + Pipfile.lock — the CI /
fresh-checkout shape hosted mode exists for — discovered ZERO packages:
`scan --mode hosted` redirected nothing and `scan --mode vendored` vendored
nothing, both exiting 0 with no warning. Every Pipenv install proof so far
pre-installed the package before scanning, which hid this. Measured on the
PR head with a Pipenv 2026.8.0 lock: scannedPackages 0 / redirected 0 in all
three modes; on the fix: lockfileOnlyPackages 1, hosted redirected 1,
vendored applied 1, rollback byte-identical.
`inventory_pipfile_lock` reads every category other than `_meta`; registry
pins (`==` version) become entries and VCS/path/file/editable sources,
range pins and our own wired file references are skipped. Pipenv records
every release file's sha256 without filenames, so the entry carries the
digest SET as the new `LockIntegrity::Sha256AnyOf`: the pypi fetcher picks
the pure-Python `-none-any.whl` whose PyPI digest is in the set (never an
sdist or platform wheel, whatever the list order) and verifies the download
against the set. Pipfile.lock sits between poetry.lock and requirements.txt
in the inventory precedence, mirroring `detect_pypi_flavor`; a parseable
uv.lock stays exclusive.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…writers Every `plan()` failure landed in `refused_pipenv_uuids` and was stripped from the requirements.txt / uv.lock / pyproject rewriters — including "no entry for the package", an old pipfile-spec, an unparseable (or BOM-prefixed) lock and a digest-less patch. A stale Pipfile.lock left behind in a uv, Poetry or requirements project therefore blocked every hosted redirect in the files the project actually installs from (Cursor Bugbot HIGH on #242). `plan()` now classifies its refusals: a CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) still vetoes the siblings — the project's Pipenv install could not pick the patch up, so a half-redirected checkout would be worse — and is reported as `redirect_pipenv_refused`; everything else is reported as `redirect_pipenv_skipped` and leaves the siblings alone. A UTF-8 BOM in front of the lock (Windows editors) is parsed past and preserved through the rewrite and the rollback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pstream release in place Measured on real Pipenv 11.10.4, 2018.11.26 and 2026.8.0, hosted AND vendored: with the same release already installed, `pipenv install`, `pipenv install --deploy` and `pipenv sync` all exit 0 and keep the upstream bytes (pip: "Requirement already satisfied"). The rewritten lock protects fresh installs only, and nothing said so — the same silent-CVE class the gem redirect guards against (#219). Every Pipenv install proof so far uninstalled the package before installing, which hid it. Hosted: a post-rewrite probe over the Python crawler's site-packages (VIRTUAL_ENV, ./.venv, ./venv, Pipenv's out-of-tree venv; --global honoured) judges each confirmed Pipfile.lock redirect with the shared `verify_patch_record` oracle and warns `redirect_pipenv_stale_install` on POSITIVE evidence only (readable bytes ≠ afterHash); an agent-mode-patched install, a lock-only checkout and missing/unreadable files stay silent, and stale purls are excluded from the same-run `--vex` attestation exactly like gem's. Gated off on --dry-run. `confirmed_pipenv_uuids` (previously written and never read) scopes the probe to the redirects the Pipenv rewriter made. Vendored: the pipenv wiring path pushes `pypi_pipenv_stale_install` under the same rules. Remedy, verified on 2026 and 2018 with the Pipfile byte-untouched: `pipenv run pip uninstall -y <pkg> && pipenv sync` or `pipenv --rm && pipenv sync`. `pipenv uninstall <pkg>` is NOT it — it rewrites the Pipfile and re-locks the patch away (the package vanishes); `PIP_FORCE_REINSTALL=1 pipenv sync` works on 2018 but is ignored by 2026. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ler only when a patch targets the lock Rollback: `pipenv lock` (and `update`, and `install <other>` before 2024) regenerates the redirected entry to registry shape on every Pipenv major (measured). `restore()` then saw live ≠ new and refused with "drifted", holding the whole pypi replay group — and a re-scan added a second edit on the same key, so the ledger could never be unwound. A registry-shaped live entry (no `file`/`path` key) is now the desired end state: the edit retires cleanly and the user's fresh resolution stands. A foreign `file`/`path` reference is still drift and still refuses. Installer probe: `pipenv --version` ran (up to 10 s) on every hosted scan whose root merely had a Pipfile.lock, and `redirect_pipenv_installer_unknown` fired even when nothing was redirected, on every re-run and on --dry-run, telling users to install legacy Pipenv. It now runs only when a pypi patch targets an entry of that lock (`pipenv_lock_targets`) and warns only when the lock was actually rewritten, with the actual consequence (the modern `file` shape was chosen) and the remedy. `SOCKET_PIPENV_MAJOR=<major>` pins the answer for CI images without pipenv or projects installed with a different release than the machine's default. Probe hardening: `pipenv` is resolved on ABSOLUTE PATH entries only — the probe runs with the project as cwd, so `.`/empty PATH components would have executed a `pipenv` planted in the scanned repository — and on Windows the PATHEXT extensions are tried so `pipenv.bat`/`pipenv.cmd` shims (pyenv-win) are found and run through `cmd.exe /C`. `parse_major` takes only the token after `version` (every release 0.2.8–2026.8.0 prints `pipenv, version X`), never a stray dotted number such as a `Python 3.12` banner. `.env` loading is disabled for the probe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a qualifier `scan --mode hosted --vex` exempts the purls the run just confirmed from installed-tree verification and attests them from the redirect ledger (`assume_applied`). The confirmed purls come from the grant reference unqualified (`pkg:pypi/urllib3@1.26.18`) while the ledger records the API's artifact-qualified purl (`…?artifact_id=py2-py3-none-any-whl`), so for pypi redirects nothing matched: a lock-only Pipenv checkout redirected the lock and then exited 1 with `no_applicable_patches`. Match on the qualifier-stripped purl on both sides. Measured on a lock-only Pipenv 2026.8.0 checkout: exit 1 / 0 statements before, exit 0 / 1 `not_affected` statement after. Same change as b7a4254 on fix/poetry-compat-review (PR #241); identical so the two merge cleanly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nventory
Reading Pipfile.lock as an `else if` before requirements.txt hid every
requirements pin behind a Pipfile.lock — including the stale one Bugbot's
scenario leaves in a requirements project — so urllib3 was no longer even
discovered there and the requirements redirect silently stopped happening.
Pipenv projects also routinely ship both files (`pipenv requirements`
exports the same pins). Both are now read and deduplicated; the hosted
rewriter judges each file on its own. Verified: stale Pipfile.lock +
requirements.txt → requirements redirected, `redirect_pipenv_skipped`
("no entry"), rollback restores the pin.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The PR pinned the rewriter with unit tests but nothing drove the CLI wiring: the `path` branch was unreachable in CI (it needs a real Pipenv 7–11 on PATH), the lock-only fresh-checkout shape had no test at all, and the Bugbot veto had no regression. Against a mocked API and the committed Pipenv 2026.8.0 lock: a lock-only project is discovered from Pipfile.lock alone, repointed with `file` + `#sha256=` + `hashes`, attested by the same-run `--vex` (with `--vex-product`, since a Pipfile names no project), re-scanned idempotently (one edit, not two) and rolled back byte for byte; `SOCKET_PIPENV_MAJOR=11` selects `path` references; a stale Pipfile.lock no longer vetoes the requirements.txt redirect; a venv holding the upstream release is kept out of the attestation and never modified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rated `vendor --revert` / `rollback` treated a relocked Pipfile.lock entry as drift when the regenerated registry entry differed from the recorded original — Pipenv 2022.12.19 writes a different hash list; 2026 reproduces the original byte for byte and already converged — so the rollback exited 1 (`partial_failure`), kept the orphaned wheel dir and held the ledger entry forever for a reference nothing pointed at (measured in the matrix). A live entry that is registry-shaped (no `file`/`path`) for a rewritten record with a recorded original now retires the record with `vendor_lock_entry_relocked`: the artifact is removed, the ledger entry dropped, the user's fresh resolution stands. A foreign `file`/`path` reference is still drift and still keeps both, and the destructive `Added` arm keeps its deep-equality gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d agent mode scripts/backtest-pipenv.py drives the real CLI and the last stable release of every published Pipenv major (0.2.8 … 2026.8.0; the ten pre-2018 releases run inside python:3.6.15-slim through a host-side pipenv wrapper so the CLI's installer probe sees them) through hosted, vendored, agent and out-of-tree agent mode over the depscan capture shapes (direct, dev, category, marker, marker-excluded, extras, transitive, crlf) and four CLI invocations (in-dir, --cwd, nested --cwd, symlinked cwd). Beyond the depscan harness it checks the lock-only fresh checkout, --dry-run parity, the warm-venv stale-install warning, whether a warm venv is reinstalled (recorded), the relock → rollback retirement, fresh-clone installs, tamper rejection, `pipenv verify`/ `requirements`, vex and byte-exact rollback, and it requires the bare agent scan to see Pipenv's out-of-tree venv. Python pins are overridable (BACKTEST_PY38 / BACKTEST_PY312) for containers without the exact patch releases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d the measured boundaries
CLI_CONTRACT.md gains Pipfile.lock in the candidate-file list, a "Pipenv
hosted redirect" section (reference key by installer major and the probe
rules, refusal vs skip scope, the era-split hash enforcement, the
stale-install guard and its verified remedy, relock retirement, the
`--vex-product` requirement) and vocabulary rows for
redirect_pipenv_{refused,skipped,installer_unknown,stale_install},
pypi_pipenv_{installer_unsupported,version_mismatch,invalid_wheel,
stale_install} and vendor_lock_entry_relocked. docs/ecosystems.md no longer
says Pipenv locks are not rewritten in hosted mode. CHANGELOG records the
feature and the fixes; the README section states what was measured
(including that `pipenv uninstall` is not the warm-venv remedy); the new
docs/testing/pipenv-compatibility.md holds the installer boundaries and the
matrix recipe, with the results table generated after the final run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…igin, validate ledger originals
- `pipenv lock --keep-outdated` / `install --keep-outdated <pkg>` (2022)
rewrite our entry into a file+version+index hybrid that Pipenv still
installs from; the rewriter refused it as a foreign source ("already
exists") and, being a conflict, vetoed the sibling Python rewriters. An
entry whose reference is ours is now re-planned to the canonical shape
when its `version` still names the patched release, and is a conflict
only when it names another one.
- `owned_url` recognized only `https://patch.socket.dev`, so a
`--patch-server-url` deployment refused its own previous references on
every re-scan. Ownership now follows the grant's own artifact URL origin
(the public service stays recognized).
- `restore()` spliced the committed, tamper-able ledger `original` string
into the lock verbatim; it must parse as a JSON object first.
- Vendored mode said nothing when the installer could not be probed while
hosted warned; it now warns `pypi_pipenv_installer_unknown` with the
`SOCKET_PIPENV_MAJOR` remedy. `vendor_integrity_unverified` no longer opens
with "Pipenv 2018 or later is required" on Pipenv 2026 and names the
release split (2018–2022 verify local-wheel hashes, 2023+ do not) and
`vex --product`.
- Harness: a CRLF lock re-serialized LF by the vendored backend is recorded,
not required (it is what `pipenv lock` itself writes).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dedicated workflow drives scripts/backtest-pipenv.py against the real CLI and real Pipenv releases (every 2018+ major on ubuntu-latest; 2018, 2022, 2023 and 2026 on macos-latest) through hosted, vendored, agent and out-of-tree agent mode, on pull requests that touch the Pipenv code paths, on pushes to main, and on demand. Windows real-Pipenv installs stay a local concern (the harness runs the pre-2018 releases in Docker and is POSIX layout-bound); the in-process hosted Pipenv CLI tests already run on all three platforms in the `test` job. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… directories
Pipenv 2018–2021 append the WHOLE `PIPENV_PYTHON` string to the virtualenv
name (`<name>-<hash>-/opt/tools/bin/python`), so the venv sits at the bottom
of a directory chain under WORKON_HOME; 2022+ append the basename. The
crawler matched the top-level directory but looked for site-packages only
directly beneath it, so a bare agent scan on Pipenv 2018 with an absolute
PIPENV_PYTHON applied nothing (measured on Linux). The matched directory is
now walked down (bounded depth, no name pruning — the chain literally
contains `bin/python`) to the first directories holding a site-packages.
Harness: pipfile-spec < 6 is now reported as `redirect_pipenv_skipped` in
hosted mode (a skip, not a veto); agent-mode shapes Pipenv 0.x cannot
express (inline-table markers/extras install nothing) are skipped with a
note; the case-insensitive fallback test picks a project name whose recased
hash carries no dash, since Pipenv's own `rsplit("-", 1)` cannot see one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pipenv 0.x–6.x install plain string pins only (inline-table markers are ignored, extras fail to install) and 0.x cannot stamp the transitive Pipfile's content hash, so those agent-mode cells are skipped with a note instead of measuring an installer limitation; hosted refusals for pipfile-spec < 6 are expected as `redirect_pipenv_skipped`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
enabled auto-merge (squash)
September 18, 2026 13:48
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Sep 18, 2026
…he stale probes to the project's venvs, recover lock-only re-scans from the ledger Verification round on the fix branch (23 deduplicated findings, three adversarial lenses each) plus the full 18-major matrix surfaced what the first fixes left open; all confirmed empirically against real Pipenv: - Rollback tolerance (hosted `restore()` and vendored `revert_pipenv`): entries are compared as parsed JSON, so a whole-file CRLF/LF conversion (git autocrlf, a cross-OS checkout) is not drift and the original is spliced back in the live file's line ending; an entry Pipenv re-serialized around our very reference — 2023+ relock an entry excluded by its marker keeping our `file` but restoring the registry `hashes` and `version`; `--keep-outdated` re-adds `version`/`index` — restores the original (only `hashes`/`version`/`index` may differ; a changed marker or reference is still drift); an entry a relock DROPPED (`pipenv uninstall`) retires the edit / the vendored record (`vendor_lock_entry_relocked`). The replay stages the lock only when the restore changed it (no `editedFiles` credit for a retired edit). - Live-lock veto: a conflicting pin vetoes the sibling Python rewriters only when a Pipfile sits beside the lock; an abandoned Pipfile.lock refuses its own rewrite but leaves requirements.txt / uv.lock alone. `Pipfile` joins the hosted candidate files (read-only). - Stale probes judge the PROJECT'S venvs only (VIRTUAL_ENV, ./.venv, ./venv, Pipenv's WORKON_HOME venv): the hosted probe no longer falls through to the global interpreters, and the vendored one no longer judges the staging dir a lock-only vendor fetched the pristine wheel into (a false `pypi_pipenv_stale_install` on every lock-only run). It also runs on the already-wired arm so a re-run keeps warning while the venv is stale. - Lock-only re-scans were one-shot: after the first vendored run the inventory skips our own wiring and the re-scan exited 1 `package_not_installed`. `recover_lock_entry` now reads the pre-vendor Pipfile.lock fragment from the ledger (its digest set) so the re-scan fetches by digest and reports `already_vendored`. - Vendored CRLF: Pipenv preserves a CRLF lock; the wire and revert writes now do too (the matrix's 8 CRLF vendored cells restore byte-identically). - Version probe: neutral working directory (the scanned repository's `.env`, Pipfile and `.venv` pointer no longer reach it), executable-bit check on PATH candidates, `SOCKET_PIPENV_MAJOR` accepts a full release string, `PIPENV_IGNORE_VIRTUALENVS`; the `redirect_pipenv_installer_unknown` detail lost its stray whitespace. - Messages: a HOSTED Socket reference met by the vendored guards names the remedy (`socket-patch rollback`) instead of "user-declared". - Docs: CLI_CONTRACT (live-lock veto, JSON-compared rollback, removed entries, `pypi_pipenv_installer_unknown`), hosted-production-e2e.md rows, pipenv-compatibility.md (CLI scope, line endings, relock variants). Harness: CRLF preservation required for vendored again; Pipenv 7's out-of-tree venv creation is an image limitation and is skipped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ver query PyPI for private-index locks, honour USERPROFILE on Windows - The CI re-run shape — `scan --mode hosted --vex` on a checkout whose Pipfile.lock already carries the committed Socket reference — saw zero packages (the inventory skipped every `file`/`path` entry, ours included), so the same-run `--vex` exited 1 `no_applicable_patches` and a vendored re-scan went through the ledger only. A hosted URL (`…/patch/pypi/<name>/<version>/…/<wheel>`) or a vendored `.socket/vendor/pypi/<uuid>/<wheel>` path now yields a discovery-only entry for the package it replaces, so re-scans re-confirm and attest it. - A lock whose `_meta.sources` name no public PyPI index keeps its entries discovery-only: the digest-set lookup must not leak private package names to pypi.org (it would not find their files there either). - Pipenv's default `WORKON_HOME` on Windows comes from `USERPROFILE` (`HOMEDRIVE`+`HOMEPATH`, then `HOME`), like Python's `expanduser`; a Git-Bash `HOME=/c/Users/u` no longer wins. - The probe's `SOCKET_PIPENV_MAJOR` test mutated the process environment under a parallel test runner (a hermeticity race); `parse_override` is unit-tested instead. The ledger-recovery test now expects the Pipenv digest set to be fetchable. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ference With Socket's own Pipfile.lock reference now inventoried (discovery-only), the vendored re-scan of a lock-only checkout fetched by that entry — which carries no registry integrity — and reported `vendor_fetch_unverifiable` + `package_not_installed` (exit 1) instead of `already_vendored`. A discovery-only inventory entry now defers to the ledger's pre-vendor fragment (digest set) when one exists; without a ledger it behaves as before. Measured: three consecutive lock-only vendored scans → applied, already_vendored, already_vendored, lock and wheel intact. The harness checks this re-run shape for both modes (`lockOnlyRescanGreen`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he private-index rule Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…code path in the trigger Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Conflicts resolved: the crawler runs Poetry's then Pipenv's out-of-tree venv discovery (both gated on nothing found above); replay keeps the PipenvEntry inverse next to main's HatchDocument; the hosted confirmed-uuid filter confirms Pipfile.lock rewrites through `confirmed_pipenv_uuids` alongside main's python-lock / hatch / requirements sets and still vetoes `refused_pipenv_uuids`; main's generic Python stale-install guard (`redirect_pypi_stale_install`, `known_stale`) replaces the Pipenv-specific hosted probe — it now judges only the project's own venvs (no global interpreter fallback) and names the Pipenv-specific remedy (`pipenv run pip uninstall -y <pkg> && pipenv sync`, never `pipenv uninstall`) for Pipfile.lock redirects; the pypi fetcher keeps the digest-set resolver (superset of main's single-hash one); the candidate-file list, the ecosystem matrix row, the hosted-e2e note, CHANGELOG and CLI_CONTRACT carry both sides once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…racle The pre-2018 legs run the byte oracle in a fresh container over a bind mount the host CLI just wrote through (stage + rename); Docker Desktop's shared file cache occasionally shows the directory without the renamed entry for a moment (seen twice in ~900 cells, always right after a host write). A genuinely absent file stays absent across the retries. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ex gate `cargo clippy --workspace --all-features -- -D warnings` (CI's invocation) rejected the `.map_or(true, ..)` on the `_meta.sources` lookup; use `Option::is_none_or`, which spells the same rule (no sources block means the public index). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iginal Pipenv 2023+ relock a marker-excluded entry into a hybrid that keeps our `file` reference and restores the upstream hashes and version around it. That entry is still ours, and `rollback` restores the pristine registry entry (the matrix captured byte-identical locks on 2023.12.1 through 2026.8.0, hosted and vendored). The harness demanded the relocked bytes be kept, which is right only for a registry-shaped relock; judge the two outcomes separately and compare the urllib3 entries semantically. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
macOS: 470/470 cells over the last stable release of all 18 Pipenv majors x
8 lock shapes x {hosted, vendored, agent, out-of-tree agent} (103 expected
refusals, 36 installer skips). Linux: 32/32 (release binary in a container,
2018+ majors, 4 modes). Invocation variants (--cwd, nested --cwd, symlinked
project directory): 48/48. Per-case checks and measured flags in
docs/testing/pipenv-compatibility/results.json (log tails stripped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Mikola Lysenko (mikolalysenko)
deleted the
codex/pipenv-patch-compatibility
branch
September 18, 2026 15:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Pipenv projects can now use hosted Socket patches, and vendored patches retain custom categories and extras. The rewriter preserves the Pipfile and its content hash, updates every matching lock category atomically, refuses source/version conflicts, and records entry-level rollback with drift checks. Hosted grant rotation remains reversible.
Native installers require different reference forms: hosted mode uses
pathfor Pipenv 7–11 andfilefrom 2018 onward. Earlier lock specifications are refused. Vendoring explicitly refuses detected pre-2018 installers; local wheels with extras usepathto avoid Pipenv 2022's file-URL parsing bug. Version detection runs once per vendoring or repair command.Review round (ULTRACODE, 2026-09-17/18)
A 10-lens adversarial review, a 3-lens verification of every finding against both the PR head and the fix branch, and a live matrix over the last stable release of all 18 published Pipenv majors (0.2.8 … 2026.8.0) × {hosted, vendored, agent, out-of-tree agent} × {direct, dev, category, marker, marker-excluded, extras, transitive, crlf} × {in-dir,
--cwd, nested--cwd, symlinked cwd}, on macOS (native) and Linux (release binary in a container; the pre-2018 releases always run inpython:3.6.15-slim). The in-process CLI tests run on Linux, macOS and Windows in CI, and.github/workflows/pipenv-compatibility.ymlruns the live matrix on Linux and macOS. The branch is merged withmain(Poetry #241, Hatch #244).What the review found and fixed
Pipfile.lock, so a fresh clone (the CI shape hosted mode exists for) redirected nothing and vendored nothing, exit 0 — every prior install proof pre-installed the package first.Pipfile.lockis inventoried now (every category's==pins); vendored mode fetches the pristine wheel by one of the lock's recorded digests (LockIntegrity::Sha256AnyOf, Pipenv records every release file's hash without filenames) through PyPI's JSON API — only when the lock resolves from the public index;requirements.txtis read alongside it; Socket's own references stay discoverable, so a CI re-run ofscan --mode hosted --vexon an already-redirected checkout exits 0 and a vendored re-scan reportsalready_vendored.rollbackpruned the manifest while the venv stayed patched). The crawler now reproduces Pipenv's placement (WORKON_HOME—USERPROFILEon Windows — the.venvfile pointer,PIPENV_CUSTOM_VENV_NAME,PIPENV_PIPFILE, the interpreter suffix including the nested 2018–2021 form, the case-insensitive fallback), unchanged from Pipenv 7 through 2026.pipenv install,install --deployandsyncexit 0 and keep the upstream bytes on every major, hosted and vendored. The Python stale-install guard (redirect_pypi_stale_install, from Support hosted and legacy Poetry patches #241) now judges only the project's own venvs and names the Pipenv-specific verified remedy —pipenv run pip uninstall -y <pkg> && pipenv syncorpipenv --rm && pipenv sync;pipenv uninstallis not it (rewrites the Pipfile, re-locks the patch away) — and the vendored twinpypi_pipenv_stale_installdoes the same. Stale purls are excluded from the same-run--vex.redirect_pipenv_refused); a stale lock without the package, an abandoned lock, an old pipfile-spec, an unparseable or BOM-prefixed lock, or a digest-less patch isredirect_pipenv_skippedand no longer blocksrequirements.txt/uv.lock.pipenv lockregenerates the entry on every major (2026 byte-identical to the original, 2022 with another hash list, 2023+ keeping our reference on a marker-excluded entry while restoring upstream hashes + version,pipenv uninstalldropping it); hosted rollback refused forever and vendored rollback kept the orphaned wheel. Both rollbacks now compare entries as parsed JSON, tolerate a whole-file CRLF/LF conversion, retire a registry-shaped or removed entry, and restore the original when Pipenv only re-serialized around our reference.pipenvon absolutePATHentries only (a relative entry would have executed a repo-planted binary), requires the executable bit, finds.bat/.cmdshims on Windows, parses only theversiontoken, and honoursSOCKET_PIPENV_MAJOR.--vexattests lock-only pypi redirects (qualifier-stripped purl match; identical to Support hosted and legacy Poetry patches #241's fix).--keep-outdated) are re-planned instead of refused; ownership follows the grant's artifact-URL origin (custom patch servers); ledger originals must be JSON objects before rollback splices them; vendored mode preserves CRLF locks and warns on an unknown installer; the vendored guards name the remedy when they meet a hosted Socket reference.Validation
scripts/backtest-pipenv.py(macOS, all 18 majors × 8 shapes × 4 modes): 470/470 pass on the merged heade521093— 103 expected refusals (pipfile-spec < 6 in hosted and vendored mode on Pipenv 0–6, vendored mode on Pipenv 7–11) and 36 harness skips (Pipenv 0–6 mishandle inline-tablemarkers/extrasentries in agent mode, 0.x has no out-of-tree venv and cannot stamp the transitive fixture, Pipenv 7 cannot create its out-of-tree venv inside the Python 3.6 image). Twelve cells were re-run after harness fixes: four Docker-side oracle reads that raced the bind mount under load, and eight marker-excluded cells on 2023+ whose check demanded the relocked hybrid be kept although the CLI correctly rolled it back to the pristine lock byte for byte. The CLI output was right in all twelve.--cwd, nested--cwd, symlinked cwd × four modes × 2026.8.0 / 2022.12.19 / 2018.11.26 / 11.10.4): 48/48 pass (3 expected refusals: vendored mode on Pipenv 11).cargo test -p socket-patch-core --lib(3574),cargo test -p socket-patch-cli(all suites),cargo clippy --workspace --all-features -- -D warnings: green on the merged head (oneunnecessary_map_orlint fixed after the merge).crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs(lock-only redirect + same-run vex + idempotent rescan + byte-exact rollback,pathreferences viaSOCKET_PIPENV_MAJOR=11, the Bugbot scenario, a warm venv kept out of the attestation) plus unit coverage for the crawler placement algorithm, the inventory digest set and Socket-reference discovery, the veto scope, BOM, relock retirement (both modes), CRLF rollback, ledger recovery, and the version probe.Measured boundaries and the full results table:
docs/testing/pipenv-compatibility.md.The Pipenv annotation and SBOM implementation is in depscan #26366; its harness and fixtures are in depscan #26367.
🤖 Generated with Claude Code