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
7 changes: 6 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,12 @@ worse, lets a warm cache silently serve unpatched bytes):
script locks the same holds after `uv remove` of the package, and after `uv remove` of the parent
of a TRANSITIVE vendored package, whose `[tool.uv]` override + source and `[manifest]` records
uv leaves in place: those are socket-patch's own records, which the revert restores, so only a
reference outside them counts as drift, #1287), removes the artifacts, prunes the
reference outside them counts as drift, #1287; with several PEP 723 scripts or PEP 751 locks
in one entry this is judged per pair — a script with its `.py.lock`, or one `pylock*.toml` — so
`uv remove --script` of one script still restores the others, #1285, and a wired script, script
lock or PEP 751 lock the user deleted warns `vendor_lockfile_missing` naming it while the files
that still exist are restored, #890, while a root `*.py` that still names the wheel (a renamed
script) keeps it, `vendor_revert_residual_reference`), removes the artifacts, prunes the
ledger, sweeps orphan uuid dirs, and (v5.0) prunes the now-empty `.socket/vendor/<eco>/` and
`.socket/vendor/` levels — `.socket/` itself is removed by the lock guard when nothing else is
left. It works without a manifest: with no manifest and no ledger it is a clean exit-0 no-op.
Expand Down
7 changes: 5 additions & 2 deletions crates/socket-patch-cli/src/commands/scan/hosted/takeover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ struct Staged {
uuid: String,
entry: VendorEntry,
/// The revert's own advisories that outlive the takeover (vlt's
/// reinstall notice).
/// reinstall notice, a deleted wiring file).
advisories: Vec<serde_json::Value>,
}

Expand Down Expand Up @@ -278,7 +278,10 @@ impl Takeover {
let advisories = outcome
.warnings
.iter()
.filter(|w| w.code == socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED)
.filter(|w| {
w.code == socket_patch_core::vendor::vlt_lock::REINSTALL_REQUIRED
|| w.code == "vendor_lockfile_missing"
})
.map(|w| serde_json::json!({ "code": w.code, "detail": w.detail }))
.collect();
self.staged.push(Staged {
Expand Down
211 changes: 211 additions & 0 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,217 @@ async fn assert_script_lock_unwinds(stage: StageFn, remove: fn(&Path)) {
}
}

/// Two uv PEP 723 scripts, `a.py` and `b.py`, each with its `.py.lock`,
/// vendored into one ledger entry; returns its wiring files.
fn stage_two_script_locks(root: &Path) -> &'static [&'static str] {
stage_script_lock(root);
for file in ["job.py", "job.py.lock"] {
let text = std::fs::read_to_string(root.join(file)).unwrap();
std::fs::remove_file(root.join(file)).unwrap();
for script in ["a", "b"] {
std::fs::write(root.join(file.replacen("job", script, 1)), &text).unwrap();
}
}
&["a.py", "a.py.lock", "b.py", "b.py.lock"]
}

/// Two PEP 751 locks, `pylock.toml` and `pylock.dev.toml`, both locking
/// six; returns its wiring files.
fn stage_two_pylocks(root: &Path) -> &'static [&'static str] {
let lock = format!(
"lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nwheels = [{{ url = \"https://files.pythonhosted.org/six-1.16.0-py2.py3-none-any.whl\", hashes = {{ sha256 = \"{WHEEL_SHA}\" }} }}]\n"
);
for file in ["pylock.toml", "pylock.dev.toml"] {
std::fs::write(root.join(file), &lock).unwrap();
}
&["pylock.toml", "pylock.dev.toml"]
}

/// `uv remove --script b.py six` on [`stage_two_script_locks`].
fn uv_remove_six_from_b(root: &Path) {
std::fs::write(
root.join("b.py"),
"# /// script\n# requires-python = \">=3.9\"\n# dependencies = []\n# ///\nimport six\n",
)
.unwrap();
std::fs::write(
root.join("b.py.lock"),
"version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n",
)
.unwrap();
}

/// #1285 / #890: one vendored entry wires several scripts (or PEP 751
/// locks), and the user changes just one of them: `uv remove --script
/// b.py six` (#1285), or deletes `b.py` and/or `b.py.lock`, or
/// `pylock.dev.toml` (#890). Every unwind must still restore the files the
/// user left alone and retire the entry. Before the fix #1285's shape was
/// drift-kept as a whole (`vendor_lock_entry_drifted`, a.py stayed wired;
/// `remove` / `rollback` exit 1) and #890's failed every unwind with
/// "cannot read ./b.py.lock".
#[tokio::test]
async fn multi_script_unwinds_after_one_script_changes() {
type Change = (&'static str, StageFn, fn(&Path), &'static [&'static str]);
let changes: [Change; 5] = [
(
"uv remove b",
stage_two_script_locks,
uv_remove_six_from_b,
&[],
),
(
"rm b.py b.py.lock",
stage_two_script_locks,
|root| {
std::fs::remove_file(root.join("b.py")).unwrap();
std::fs::remove_file(root.join("b.py.lock")).unwrap();
},
&["b.py", "b.py.lock"],
),
(
"rm b.py.lock",
stage_two_script_locks,
|root| std::fs::remove_file(root.join("b.py.lock")).unwrap(),
&["b.py.lock"],
),
(
"rm b.py",
stage_two_script_locks,
|root| std::fs::remove_file(root.join("b.py")).unwrap(),
&["b.py"],
),
(
"rm pylock.dev.toml",
stage_two_pylocks,
|root| std::fs::remove_file(root.join("pylock.dev.toml")).unwrap(),
&["pylock.dev.toml"],
),
];
let server = MockServer::start().await;
let hosted_url = mount_hosted_api(&server, true).await;
let uri = server.uri();
for (label, stage, change, deleted) in changes {
for unwind in [
vec!["vendor", "--revert"],
vec!["remove", PURL, "--yes", "--offline"],
vec!["rollback", "--yes", "--offline"],
hosted_scan_args(&uri),
] {
let (_tmp, root) = project();
let files = stage(&root);
let originals: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
.collect();
vendor_project(&root, files);
change(&root);
let changed: Vec<Option<String>> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).ok())
.collect();

let (code, env) = run_cli(&root, &unwind, &[]);
assert_eq!(code, 0, "{label} / {unwind:?}: {env:#}");
let rendered = env.to_string();
let hosted = unwind.contains(&"hosted");
if hosted && deleted == ["b.py"] {
// Hosted mode refuses an orphan `.py.lock` whatever the
// vendored state, so the takeover keeps the project vendored
// (untouched) and names the hosted refusal, not a revert
// failure.
assert!(
rendered.contains("redirect_uv_script_missing")
&& rendered.contains("redirect_takeover_kept_vendored")
&& !rendered.contains("redirect_vendored_revert_failed"),
"{label} / {unwind:?}: {env:#}"
);
continue;
}
assert!(
!rendered.contains("vendor_lock_entry_drifted")
&& !rendered.contains("vendor_artifact_kept")
&& !rendered.contains("cannot read"),
"{label} / {unwind:?}: {env:#}"
);
for file in deleted {
assert!(
rendered.contains(&format!("{file} no longer exists")),
"{label} / {unwind:?}: the missing {file} is named: {env:#}"
);
}
if hosted {
assert!(
rendered.contains("redirect_takeover_reverted_vendored"),
"{label} / {unwind:?}: {env:#}"
);
}
assert!(
!root.join(format!(".socket/vendor/pypi/{UUID}")).exists(),
"{label} / {unwind:?}: the vendored wheel is reclaimed"
);
for ((f, original), changed) in files.iter().zip(&originals).zip(&changed) {
let after = std::fs::read_to_string(root.join(f)).ok();
let Some(after) = after else {
assert!(changed.is_none(), "{label} / {unwind:?}: {f} is gone");
continue;
};
assert!(
!after.contains(".socket/vendor/"),
"{label} / {unwind:?}: {f} is unwired:\n{after}"
);
let untouched = changed.as_deref().is_some_and(|c| c.contains(UUID));
if hosted && untouched && after.contains(&hosted_url) {
// The takeover re-pins what it restored, hosted.
continue;
}
let expected = if untouched {
original.as_str()
} else {
changed.as_deref().unwrap()
};
assert_eq!(after, expected, "{label} / {unwind:?}: {f}");
}
}
}
}

/// A vendored PEP 723 script the user renamed (`git mv job.py tool.py`)
/// still installs from the vendored wheel through its own
/// `[tool.uv.sources]`. The revert skips the missing `job.py` record
/// (`vendor_lockfile_missing`, #890), but must keep the wheel `tool.py`
/// still names instead of deleting it out from under `uv run tool.py`.
#[tokio::test]
async fn renamed_vendored_script_keeps_its_wheel() {
for unwind in [
vec!["vendor", "--revert"],
vec!["remove", PURL, "--yes", "--offline"],
vec!["rollback", "--yes", "--offline"],
] {
let (_tmp, root) = project();
let files = stage_script_lock(&root);
vendor_project(&root, files);
std::fs::rename(root.join("job.py"), root.join("tool.py")).unwrap();
let renamed = std::fs::read_to_string(root.join("tool.py")).unwrap();
assert!(renamed.contains(UUID), "{renamed}");

let (_, env) = run_cli(&root, &unwind, &[]);
let rendered = env.to_string();
assert!(
rendered.contains("tool.py still resolves through it"),
"{unwind:?}: the renamed script is named: {env:#}"
);
assert!(
root.join(format!(".socket/vendor/pypi/{UUID}")).exists(),
"{unwind:?}: the wheel tool.py installs from is kept"
);
assert_eq!(
std::fs::read_to_string(root.join("tool.py")).unwrap(),
renamed,
"{unwind:?}: the user's script is left alone"
);
}
}

/// #742 / #650 / #1136: a vendored uv project, uv script lock, Hatch
/// project and Poetry project (LF and CRLF) pick up a superseding patch. The manifest moves `six` from patch A to patch B
/// (different patched bytes); the next `vendor` must wire B's wheel, remove
Expand Down
6 changes: 5 additions & 1 deletion crates/socket-patch-core/src/vendor/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2293,7 +2293,11 @@ async fn pypi_reference_clause(project_root: &Path, uuid: &str, skip: &[&str]) -
continue;
};
let is_lock = crate::utils::python_lock::is_python_lock_name(&name);
if !is_lock && !is_export_name(&name) {
// A root `*.py` too: a PEP 723 script's `[tool.uv.sources]` path
// resolves from the script's own directory, so a vendored script
// the user renamed (`git mv job.py tool.py`) still installs from
// the wheel although no lock pairs with its new name.
if !is_lock && !is_export_name(&name) && !name.ends_with(".py") {
continue;
}
// lstat only: a regular file or ANY symlink is probed (the read
Expand Down
Loading
Loading