Skip to content
Merged
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
91 changes: 88 additions & 3 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,14 @@ fn hosted_wheel() -> Vec<u8> {
/// Stage `.socket/manifest.json` + the after-hash blob so `vendor` builds
/// the vendored wheel from the prebuilt fixture server, offline.
fn stage_manifest(root: &Path) {
let after = compute_git_sha256_from_bytes(PATCHED);
stage_manifest_with(root, UUID, PATCHED);
}

/// [`stage_manifest`] for patch `uuid` whose patched `six.py` is `patched`.
fn stage_manifest_with(root: &Path, uuid: &str, patched: &[u8]) {
let after = compute_git_sha256_from_bytes(patched);
let manifest = json!({ "patches": { PURL: {
"uuid": UUID,
"uuid": uuid,
"exportedAt": "2026-01-01T00:00:00Z",
"files": { "six.py": {
"beforeHash": compute_git_sha256_from_bytes(ORIG),
Expand All @@ -83,7 +88,7 @@ fn stage_manifest(root: &Path) {
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
std::fs::write(socket.join("blobs").join(after), PATCHED).unwrap();
std::fs::write(socket.join("blobs").join(after), patched).unwrap();
}

/// The built binary with every ambient `SOCKET_*` var scrubbed. An EMPTY
Expand Down Expand Up @@ -292,6 +297,86 @@ python-versions = ">=3.9"
content-hash = "4b42a89b7ff7b26511b06acdc458dbd85312e5083db8f212b017482bc68cdd01"
"#;

/// #765: a vendored requirements.txt picks up a superseding patch. The
/// manifest moves `six` from patch A to patch B (different patched bytes);
/// the next `vendor` must re-wire the requirements line to B's wheel in
/// place, remove A's uuid dir (`vendor_stale_artifact_removed`) and exit 0.
/// Before the fix it failed `pypi_requirements_already_vendored` (exit 1)
/// and pip kept installing patch A. `vendor --revert` afterwards restores
/// the user's original pin, so the carried-over ledger record is intact.
#[tokio::test]
async fn requirements_vendored_revendors_superseding_patch() {
const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d";
const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n";
for original in [
"idna==3.7\nsix==1.16.0\n".to_string(),
format!(
"idna==3.7 --hash=sha256:{}\nsix==1.16.0 ; python_version >= \"3\" \\\n --hash=sha256:{WHEEL_SHA}\n",
"1".repeat(64)
),
] {
let (_tmp, root) = project();
std::fs::write(root.join("requirements.txt"), &original).unwrap();
vendor_project(&root, &["requirements.txt"]);
let wired_a = std::fs::read_to_string(root.join("requirements.txt")).unwrap();

stage_manifest_with(&root, UUID_B, PATCHED_B);
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "re-vendor to the superseding patch: {env:#}");
let rendered = env.to_string();
assert!(
!rendered.contains("pypi_requirements_already_vendored"),
"{env:#}"
);
assert!(
rendered.contains("vendor_stale_artifact_removed"),
"patch A's artifact is reclaimed: {env:#}"
);
let wired_b = std::fs::read_to_string(root.join("requirements.txt")).unwrap();
assert!(!wired_b.contains(UUID), "uuid A is gone:\n{wired_b}");
assert_eq!(
wired_b.matches(&format!(".socket/vendor/pypi/{UUID_B}/")).count(),
1,
"one six line, on patch B:\n{wired_b}"
);
assert_eq!(
wired_b.lines().count(),
wired_a.lines().count(),
"re-wired in place:\n{wired_a}\n{wired_b}"
);
assert_eq!(
wired_b.contains("--hash="),
original.contains("--hash="),
"hash mode kept:\n{wired_b}"
);
assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists());
let wheel_b = wired_b
.split_whitespace()
.find(|t| t.contains(UUID_B))
.unwrap();
assert!(root.join(wheel_b).is_file(), "patch B's wheel: {wheel_b}");
let ledger = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap();
assert!(ledger.contains(UUID_B) && !ledger.contains(UUID), "{ledger}");

// Re-running is settled: in sync, nothing rewritten.
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "{env:#}");
assert_eq!(
std::fs::read_to_string(root.join("requirements.txt")).unwrap(),
wired_b
);

let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]);
assert_eq!(code, 0, "revert after the re-vendor: {env:#}");
assert_eq!(
std::fs::read_to_string(root.join("requirements.txt")).unwrap(),
original,
"the user's own pin is restored"
);
assert!(!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists());
}
}

#[tokio::test]
async fn requirements_vendored_to_hosted() {
let (_tmp, root) = project();
Expand Down
221 changes: 212 additions & 9 deletions crates/socket-patch-core/src/vendor/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ use super::pypi_pdm::{PdmProject, PdmTarget};
use super::pypi_pipenv::{PipenvProject, PipenvTarget};
use super::pypi_poetry::{PoetryProject, PoetryTarget};
use super::pypi_requirements::{
preflight_requirements, revert_requirements, wire_requirements, RequirementsTarget,
preflight_requirements, revert_requirements, rewire_requirements, wire_requirements,
RequirementsTarget,
};
use super::pypi_uv::{
check_target_guards, load_uv_project, revert_uv, wire_uv, UvProject, UvTarget,
Expand Down Expand Up @@ -423,6 +424,9 @@ enum WiringPlan {
Uv(Box<UvProject>),
PythonLocks(super::pypi_lock::PythonLocks),
Requirements,
/// Re-wire the vendor lines an OLDER patch uuid's ledger entry recorded
/// to this uuid in place (#765).
RequirementsRewire(Box<VendorEntry>),
Hatch(super::pypi_hatch::HatchProject),
Poetry(Box<PoetryProject>),
Pdm(Box<PdmProject>),
Expand Down Expand Up @@ -796,6 +800,7 @@ async fn pypi_prelude<'p>(
WiringPlan::InSync
}
Ok(RequirementsTarget::Fresh) => WiringPlan::Requirements,
Ok(RequirementsTarget::Rewire { prev }) => WiringPlan::RequirementsRewire(prev),
Err((code, detail)) => return Err(refused(code, detail)),
}
}
Expand Down Expand Up @@ -1251,6 +1256,16 @@ pub async fn vendor_pypi_with_pipenv_version<'a>(
)
.await
.map(|wiring| (wiring, MetaSlot::None)),
WiringPlan::RequirementsRewire(prev) => rewire_requirements(
project_root,
&prev,
&canon_name,
version,
&rel_wheel,
&artifact.sha256_hex,
)
.await
.map(|wiring| (wiring, MetaSlot::None)),
WiringPlan::Poetry(project) => super::pypi_poetry::wire_poetry(
&project,
project_root,
Expand Down Expand Up @@ -2662,12 +2677,195 @@ wheels = [
);
}

/// A requirements file already wired to an EARLIER patch uuid for the
/// same package refuses (mirrors uv/poetry): appending a second wheel
/// line would leave pip two competing requirements, and the new entry
/// would clobber the old one's ledger record, orphaning its line.
/// #765: a requirements tree already wired to an EARLIER patch uuid for
/// the same package re-vendors in place to the superseding uuid, as the
/// `would_revendor` preview and the CLI contract promise. Every recorded
/// vendor line (a rewritten root pin with a marker, a pin in a `-r`
/// include, an appended transitive line) moves to the new wheel path,
/// keeps its marker / hash mode / `(transitive)` note, and keeps the
/// pre-vendor original, so `vendor --revert` of the NEW entry restores
/// the user's files byte for byte.
#[tokio::test]
async fn requirements_superseding_uuid_revendors_in_place() {
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
let hex = "0".repeat(64);
let shapes: Vec<(&str, Vec<(&str, String)>)> = vec![
("unhashed", vec![("requirements.txt", "six==1.16.0\nidna==3.7\n".into())]),
(
"hashed, marker, continuation, CRLF",
vec![(
"requirements.txt",
format!("six==1.16.0 ; python_version >= \"3\" \\\r\n --hash=sha256:{hex}\r\nidna==3.7 --hash=sha256:{hex}\r\n"),
)],
),
(
"-r include",
vec![
("requirements.txt", "-r base.txt\nidna==3.7\n".into()),
("base.txt", "six==1.16.0\n".into()),
],
),
("transitive", vec![("requirements.txt", "idna==3.7\n".into())]),
];
for (shape, files) in shapes {
let fx = e2e_fixture().await;
for (name, text) in &files {
touch(&fx.root, name, text).await;
}
let sources = PatchSources::blobs_only(&fx.blobs);
let vendor_with = |record: PatchRecord| {
let sources = &sources;
let fx = &fx;
async move {
crate::vendor::test_support::vendor_pypi(
"pkg:pypi/six@1.16.0",
&fx.site_packages,
&fx.root,
&record,
sources,
"2026-06-09T00:00:00Z",
false,
false,
None,
)
.await
}
};
let VendorOutcome::Done { result, entry, .. } = vendor_with(fx.record.clone()).await
else {
panic!("{shape}: first vendor must be Done");
};
assert!(result.success, "{shape}: {:?}", result.error);
let first = entry.expect("entry on success");
save_ledger_entry(&fx.root, &first).await;

// Same package, new patch generation (different uuid).
let mut record2 = fx.record.clone();
record2.uuid = UUID2.to_string();
let outcome = vendor_with(record2).await;
let VendorOutcome::Done { result, entry, .. } = outcome else {
panic!("{shape}: superseding uuid must re-vendor, got {outcome:?}");
};
assert!(result.success, "{shape}: {:?}", result.error);
let second = entry.expect("entry on success");
assert_eq!(second.uuid, UUID2);
assert_eq!(second.wiring.len(), first.wiring.len(), "{shape}");
for (old, new) in first.wiring.iter().zip(&second.wiring) {
assert_eq!(
(&old.file, &old.action, &old.key, &old.original),
(&new.file, &new.action, &new.key, &new.original),
"{shape}: the pre-vendor original is carried over"
);
let line = new.new.as_ref().and_then(|v| v.as_str()).unwrap();
let old_line = old.new.as_ref().and_then(|v| v.as_str()).unwrap();
assert_eq!(line, old_line.replace(UUID, UUID2), "{shape}");
}
for (name, _) in &files {
let text = tokio::fs::read_to_string(fx.root.join(name)).await.unwrap();
assert!(!text.contains(UUID), "{shape}: {name} kept uuid A:\n{text}");
}
let wired: String = {
let mut all = String::new();
for (name, _) in &files {
all.push_str(&tokio::fs::read_to_string(fx.root.join(name)).await.unwrap());
}
all
};
assert_eq!(
wired.matches(UUID2).count(),
1,
"{shape}: one six line:\n{wired}"
);
assert!(fx
.root
.join(format!(".socket/vendor/pypi/{UUID2}/{WHEEL_NAME}"))
.is_file());

// Revert of the NEW entry restores every file byte for byte.
save_ledger_entry(&fx.root, &second).await;
let reverted = revert_pypi(&second, &fx.root, false).await;
assert!(reverted.success, "{shape}: {:?}", reverted.error);
assert!(
reverted.warnings.is_empty(),
"{shape}: {:?}",
reverted.warnings
);
for (name, text) in &files {
assert_eq!(
&tokio::fs::read_to_string(fx.root.join(name)).await.unwrap(),
text,
"{shape}: {name} restored"
);
}
}
}

/// #765: a re-wire replays only what the older entry's ledger recorded. A
/// vendor line edited since vendoring (no longer verbatim what the ledger
/// recorded) refuses before anything is written.
#[tokio::test]
async fn requirements_superseding_uuid_drifted_line_refuses() {
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
let fx = e2e_fixture().await;
let sources = PatchSources::blobs_only(&fx.blobs);
let vendor_with = |record: PatchRecord| {
let sources = &sources;
let fx = &fx;
async move {
crate::vendor::test_support::vendor_pypi(
"pkg:pypi/six@1.16.0",
&fx.site_packages,
&fx.root,
&record,
sources,
"2026-06-09T00:00:00Z",
false,
false,
None,
)
.await
}
};
let VendorOutcome::Done { result, entry, .. } = vendor_with(fx.record.clone()).await else {
panic!("first vendor must be Done");
};
assert!(result.success, "{:?}", result.error);
save_ledger_entry(&fx.root, &entry.expect("entry on success")).await;
let wired = tokio::fs::read_to_string(fx.root.join("requirements.txt"))
.await
.unwrap();
let drifted = wired.replace(
" # socket-patch vendor:",
" --no-deps # socket-patch vendor:",
);
assert_ne!(drifted, wired);
touch(&fx.root, "requirements.txt", &drifted).await;

let mut record2 = fx.record.clone();
record2.uuid = UUID2.to_string();
let outcome = vendor_with(record2).await;
let VendorOutcome::Refused { code, detail } = outcome else {
panic!("expected Refused, got {outcome:?}");
};
assert_eq!(code, "pypi_requirements_already_vendored");
assert!(detail.contains("changed since vendoring"), "{detail}");
assert_eq!(
tokio::fs::read_to_string(fx.root.join("requirements.txt"))
.await
.unwrap(),
drifted
);
assert!(!fx
.root
.join(format!(".socket/vendor/pypi/{UUID2}"))
.exists());
}

/// #765: without a ledger entry for the older uuid there is no recorded
/// pre-vendor original to carry forward, so a re-wire could never be
/// reverted. That case still refuses, before anything is written.
#[tokio::test]
async fn requirements_stale_uuid_vendor_line_refuses() {
async fn requirements_superseding_uuid_without_ledger_refuses() {
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
let fx = e2e_fixture().await;
let sources = PatchSources::blobs_only(&fx.blobs);
Expand Down Expand Up @@ -2697,7 +2895,7 @@ wheels = [
.await
.unwrap();

// Same package, new patch generation (different uuid).
// No state.json was persisted (a lost or never-committed ledger).
let mut record2 = fx.record.clone();
record2.uuid = UUID2.to_string();
let outcome = vendor_with(record2).await;
Expand Down Expand Up @@ -3492,8 +3690,13 @@ wheels = [
tokio::fs::remove_dir_all(&uuid_dir).await.unwrap();
let bytes = served_wheel(b"service wheel at another filename");
let server = wiremock::MockServer::start().await;
mount_pypi_granted(&server, "six-1.16.0-py3-none-any.whl", &sri_sha512(&bytes), &bytes)
.await;
mount_pypi_granted(
&server,
"six-1.16.0-py3-none-any.whl",
&sri_sha512(&bytes),
&bytes,
)
.await;
let cfg = pypi_service_cfg(&server.uri(), VendorSource::Service, false);
let error = crate::vendor::test_support::expect_failure(vendor(Some(cfg)).await);
assert!(
Expand Down
Loading
Loading