Skip to content

Commit be7384f

Browse files
committed
Refuse to wire uv locks whose exclude-newer the unwind refuses
Second review round, plus the known follow-up. - A uv lock that records an `exclude-newer` cutoff for the package (a global one under `[options]` or, for uv 0.2.28-0.2.33, at the top level, or an `exclude-newer-package` naming it) was still wired by hosted mode, though the unwind always refuses such a pin. The rewrite now refuses it (`redirect_uv_lock_unsupported`), for `[[package]]` and `[[distribution]]` locks alike. One classifier, `upstream::lock_cutoff`, serves the rewrite and the unwind. - The rewrite's project-settings cutoff check for `[[distribution]]` locks re-implemented uv settings discovery and missed the settings file above the root (`UV_ANCESTOR_CONFIG_KEY`) the unwind reads. It now uses `pylock_uv_settings` / `root_uv_settings`, computed once per run rather than per dep. - The sdist / inline-dependencies refusal ran before the not-applicable checks, turning an entry the rewrite does not touch (a non-registry, non-direct source) into a hard refusal. It now runs after them. - docs/testing/uv-compatibility.md no longer contradicts itself on which release files a 0.2.x lock records. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 80180b8 commit be7384f

6 files changed

Lines changed: 237 additions & 88 deletions

File tree

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 111 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6758,9 +6758,16 @@ fn rewrite_uv_lock(
67586758
.iter()
67596759
.any(|(path, _)| is_pep751_lock_name(path))
67606760
.then(|| pylock_uv_settings(files));
6761+
// The project settings' `exclude-newer`, which a `[[distribution]]`
6762+
// lock does not record (read once, as the unwind reads them).
6763+
let project_cutoff = distribution_project_cutoff(&pylock_uv_settings(files));
67616764
for (path, original) in locks {
67626765
let mut content = original.clone();
67636766
let mut session = PythonLockSession::new(original);
6767+
// The cutoff a uv lock records, which the unwind refuses to undo.
6768+
let lock_doc = (!is_pep751_lock_name(path))
6769+
.then(|| original.parse::<toml_edit::DocumentMut>().ok())
6770+
.flatten();
67646771
let pipenv_reads = pipenv_reads_pylock(path, |rel| files.contains_key(rel));
67656772
// Pipenv installs the pylocks it reads, and reads no uv setting.
67666773
let uv_pip_settings = pip_settings
@@ -6787,6 +6794,12 @@ fn rewrite_uv_lock(
67876794
});
67886795
continue;
67896796
}
6797+
let lock_cutoff = lock_doc.as_ref().and_then(|doc| {
6798+
upstream::lock_cutoff(
6799+
doc,
6800+
&crate::crawlers::python_crawler::canonicalize_pypi_name(&dep.name),
6801+
)
6802+
});
67906803
let plan = match planned {
67916804
Ok(Some(_)) if pipenv_reads => {
67926805
result
@@ -6795,21 +6808,35 @@ fn rewrite_uv_lock(
67956808
result.warnings.push(pipenv_pylock_refusal(path, dep));
67966809
continue;
67976810
}
6798-
Ok(Some(plan)) if plan.distribution_inline_artifacts() => {
6799-
match distribution_project_cutoff(files) {
6800-
Some(why) => {
6801-
result
6802-
.refused_python_lock_uuids
6803-
.insert(dep.patch_uuid.clone());
6804-
result.warnings.push(RewriteWarning {
6805-
code: "redirect_uv_lock_unsupported".into(),
6806-
detail: format!("{path}: {why}"),
6807-
});
6808-
continue;
6809-
}
6810-
None => plan,
6811-
}
6811+
Ok(Some(_)) if lock_cutoff.is_some() => {
6812+
result
6813+
.refused_python_lock_uuids
6814+
.insert(dep.patch_uuid.clone());
6815+
result.warnings.push(RewriteWarning {
6816+
code: "redirect_uv_lock_unsupported".into(),
6817+
detail: format!(
6818+
"{path}: the lock's `{}` option filters {}'s release files by \
6819+
upload time, so rollback could not tell which files to restore; \
6820+
re-lock without it to redirect this package",
6821+
lock_cutoff.unwrap_or_default(),
6822+
dep.name
6823+
),
6824+
});
6825+
continue;
68126826
}
6827+
Ok(Some(plan)) if plan.distribution_inline_artifacts() => match &project_cutoff {
6828+
Some(why) => {
6829+
result
6830+
.refused_python_lock_uuids
6831+
.insert(dep.patch_uuid.clone());
6832+
result.warnings.push(RewriteWarning {
6833+
code: "redirect_uv_lock_unsupported".into(),
6834+
detail: format!("{path}: {why}"),
6835+
});
6836+
continue;
6837+
}
6838+
None => plan,
6839+
},
68136840
Ok(Some(plan)) => plan,
68146841
Ok(None) => {
68156842
result.warnings.push(RewriteWarning {
@@ -6898,35 +6925,21 @@ fn rewrite_uv_lock(
68986925
}
68996926

69006927
/// Why a uv 0.2 `[[distribution]]` lock with inline artifacts may not be
6901-
/// restorable once rewritten: the project's uv settings (`uv.toml`, else
6902-
/// the pyproject's `[tool.uv]`, as uv reads them) set an `exclude-newer`
6903-
/// cutoff, which uv 0.2.12–0.2.27 apply without recording it in the lock,
6904-
/// so the unwind could not tell which release files it kept.
6905-
fn distribution_project_cutoff(files: &BTreeMap<String, String>) -> Option<String> {
6906-
use crate::utils::python_script::UV_TOML;
6907-
let parse = |rel: &str| files.get(rel)?.parse::<toml_edit::DocumentMut>().ok();
6908-
let (file, key) = match parse(UV_TOML) {
6909-
Some(doc) => (
6910-
UV_TOML,
6911-
crate::utils::python_lock::sets_exclude_newer(doc.as_table())?.to_string(),
6912-
),
6913-
None => {
6914-
let doc = parse("pyproject.toml")?;
6915-
let uv = doc
6916-
.get("tool")
6917-
.and_then(|t| t.get("uv"))
6918-
.and_then(toml_edit::Item::as_table_like)?;
6919-
(
6920-
"pyproject.toml",
6921-
crate::utils::python_lock::sets_exclude_newer(uv)?.to_string(),
6922-
)
6923-
}
6924-
};
6925-
Some(format!(
6926-
"{file} sets uv `{key}`, which uv 0.2.12–0.2.27 apply to a `[[distribution]]` lock \
6927-
without recording it, so rollback could not tell which release files the lock kept; \
6928-
re-lock with uv >=0.2.35"
6929-
))
6928+
/// restorable once rewritten: the project's uv settings (`settings`, as
6929+
/// [`pylock_uv_settings`] finds them: `uv.toml`, else the pyproject's
6930+
/// `[tool.uv]`, else the settings file above the root, as uv and the
6931+
/// unwind read them) set an `exclude-newer` cutoff, which uv 0.2.12–0.2.27
6932+
/// apply without recording it in the lock, so the unwind could not tell
6933+
/// which release files it kept.
6934+
fn distribution_project_cutoff(settings: &[(String, toml_edit::Table)]) -> Option<String> {
6935+
settings.iter().find_map(|(file, table)| {
6936+
let key = crate::utils::python_lock::sets_exclude_newer(table)?;
6937+
Some(format!(
6938+
"{file} sets uv `{key}`, which uv 0.2.12–0.2.27 apply to a `[[distribution]]` lock \
6939+
without recording it, so rollback could not tell which release files the lock \
6940+
kept; re-lock with uv >=0.2.35"
6941+
))
6942+
})
69306943
}
69316944

69326945
// ── composer.lock ────────────────────────────────────────────────────────────
@@ -10123,6 +10136,61 @@ mod tests {
1012310136
}
1012410137
}
1012510138

10139+
/// A uv lock that records an `exclude-newer` cutoff for the package
10140+
/// (globally under `[options]` or at the top level, or per package) is
10141+
/// never wired: the unwind refuses to undo such a pin, so hosted must
10142+
/// not write one. A package the per-package table exempts is wired.
10143+
#[test]
10144+
fn uv_lock_recording_a_cutoff_is_not_wired() {
10145+
const SIX: &str = "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = { registry = \"https://pypi.org/simple\" }\nwheels = [{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\" }]\n";
10146+
let dep = pypi_override(
10147+
"six",
10148+
"1.16.0",
10149+
"https://patch.test/six-1.16.0-py2.py3-none-any.whl",
10150+
&"c".repeat(64),
10151+
);
10152+
let lock = |head: &str| {
10153+
let mut files = BTreeMap::new();
10154+
files.insert(
10155+
"uv.lock".to_string(),
10156+
format!("version = 1\nrequires-python = \">=3.9\"\n{head}\n{SIX}"),
10157+
);
10158+
rewrite_registry_redirect(&files, std::slice::from_ref(&dep))
10159+
};
10160+
for head in [
10161+
"\n[options]\nexclude-newer = \"2024-06-01T00:00:00Z\"\n",
10162+
"exclude-newer = \"2024-06-01 00:00:00 UTC\"\n",
10163+
"\n[options]\nexclude-newer-package = { six = \"2024-06-01T00:00:00Z\" }\n",
10164+
] {
10165+
let r = lock(head);
10166+
assert!(r.files.is_empty(), "{head}: {:?}", r.files);
10167+
assert!(
10168+
r.refused_python_lock_uuids.contains(&dep.patch_uuid),
10169+
"{head}"
10170+
);
10171+
assert!(
10172+
r.warnings
10173+
.iter()
10174+
.any(|w| w.code == "redirect_uv_lock_unsupported"
10175+
&& w.detail.contains("exclude-newer")),
10176+
"{head}: {:?}",
10177+
r.warnings
10178+
);
10179+
}
10180+
for head in [
10181+
"",
10182+
"\n[options]\nexclude-newer = \"2024-06-01T00:00:00Z\"\nexclude-newer-package = { six = false }\n",
10183+
"\n[options]\nexclude-newer-package = { other = \"2024-06-01T00:00:00Z\" }\n",
10184+
] {
10185+
let r = lock(head);
10186+
assert!(
10187+
r.files.get("uv.lock").is_some_and(|l| l.contains("patch.test")),
10188+
"{head}: {:?}",
10189+
r.warnings
10190+
);
10191+
}
10192+
}
10193+
1012610194
/// Re-running a rewriter over its own output must be a no-op: zero new
1012710195
/// edits, byte-identical files. Recorded edits whose `original` is the
1012810196
/// already-redirected value would grow the committed ledger on every

‎crates/socket-patch-core/src/patch/redirect/upstream/mod.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ mod vlt;
4545
use client::rubygems_base;
4646
pub(crate) use client::UpstreamClient;
4747
pub(crate) use uv::{
48-
declared_lock_specifier, pylock_no_binary, respell_lock_specifier, root_uv_settings,
49-
LockRequirementArray,
48+
declared_lock_specifier, lock_cutoff, pylock_no_binary, respell_lock_specifier,
49+
root_uv_settings, LockRequirementArray,
5050
};
5151

5252
use super::staged::{flush_staged, read_rel, Staged, StagedBytes};

‎crates/socket-patch-core/src/patch/redirect/upstream/uv.rs‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -355,28 +355,8 @@ async fn restore_lock(
355355
if !version_ok {
356356
return refuse_hits(result, "unsupported lock version");
357357
}
358-
let options = doc.get("options").and_then(Item::as_table_like);
359-
// uv 0.2.28–0.2.33 record the cutoff at the lock's top level, later
360-
// releases under `[options]`.
361-
let global_cutoff = options
362-
.into_iter()
363-
.chain(Some(doc.as_table() as &dyn TableLike))
364-
.find_map(|t| {
365-
t.iter()
366-
.map(|(k, _)| k)
367-
.find(|k| k.starts_with("exclude-newer") && *k != "exclude-newer-package")
368-
});
369-
// A per-package cutoff (#1604) filters only the packages it names, and
370-
// one of `false` exempts its package from the global cutoff.
371-
let cutoffs = options.and_then(|o| o.get("exclude-newer-package"));
372358
for hit in &hits {
373-
let key = if cutoffs.is_some_and(|c| names_package(c, &hit.name)) {
374-
"exclude-newer-package"
375-
} else if let Some(key) =
376-
global_cutoff.filter(|_| !cutoffs.is_some_and(|c| exempts(c, &hit.name)))
377-
{
378-
key
379-
} else {
359+
let Some(key) = lock_cutoff(&doc, &hit.name) else {
380360
continue;
381361
};
382362
result.refuse(
@@ -957,6 +937,26 @@ fn names_package(item: &Item, name: &str) -> bool {
957937
}
958938
}
959939

940+
/// The `exclude-newer` option uv recorded in lock `doc` that filters
941+
/// canonical package `name`'s release files by upload time, if any: a
942+
/// per-package `exclude-newer-package` naming it (#1604), else a global
943+
/// cutoff it is not exempted from. uv 0.2.28–0.2.33 record the global
944+
/// cutoff at the lock's top level, later releases under `[options]`. The
945+
/// unwind cannot tell which files such a cutoff kept, so it refuses the
946+
/// pin, and the hosted rewrite refuses to write one it could not unwind.
947+
pub(crate) fn lock_cutoff<'a>(doc: &'a DocumentMut, name: &str) -> Option<&'a str> {
948+
let options = doc.get("options").and_then(Item::as_table_like);
949+
let global = options
950+
.into_iter()
951+
.chain(Some(doc.as_table() as &dyn TableLike))
952+
.find_map(crate::utils::python_lock::sets_exclude_newer);
953+
let cutoffs = options.and_then(|o| o.get("exclude-newer-package"));
954+
if cutoffs.is_some_and(|c| names_package(c, name)) {
955+
return Some("exclude-newer-package");
956+
}
957+
global.filter(|_| !cutoffs.is_some_and(|c| exempts(c, name)))
958+
}
959+
960960
/// Whether a per-package cutoff table exempts `name` from the global
961961
/// cutoff (an entry of `false`).
962962
fn exempts(cutoffs: &Item, name: &str) -> bool {

‎crates/socket-patch-core/src/utils/python_lock.rs‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,21 @@ fn plan_python_lock_rewrite(
931931
// `wheels = [ … ]` instead of the `[distribution.sdist]` /
932932
// `[[distribution.wheel]]` tables of uv ≤0.2.5.
933933
let legacy_artifact_tables = legacy && distribution_artifact_tables(packages, index);
934+
if !pep751
935+
&& !original_source.as_ref().is_some_and(|source| {
936+
source_identity(source).is_some_and(|(kind, value)| {
937+
kind != "legacy"
938+
|| value.starts_with("registry+")
939+
|| value.starts_with("direct+")
940+
|| value.starts_with("path+")
941+
})
942+
})
943+
{
944+
return Ok(None);
945+
}
946+
if pep751 && (package.contains_key("vcs") || package.contains_key("directory")) {
947+
return Ok(None);
948+
}
934949
// uv 0.2.18–0.2.22 write an entry's `sdist` before its inline
935950
// `dependencies`, 0.2.23–0.2.34 after them. The rewrite drops the sdist,
936951
// so the unwind can put it back only where another registry entry shows
@@ -949,21 +964,6 @@ fn plan_python_lock_rewrite(
949964
with uv >=0.2.35"
950965
));
951966
}
952-
if !pep751
953-
&& !original_source.as_ref().is_some_and(|source| {
954-
source_identity(source).is_some_and(|(kind, value)| {
955-
kind != "legacy"
956-
|| value.starts_with("registry+")
957-
|| value.starts_with("direct+")
958-
|| value.starts_with("path+")
959-
})
960-
})
961-
{
962-
return Ok(None);
963-
}
964-
if pep751 && (package.contains_key("vcs") || package.contains_key("directory")) {
965-
return Ok(None);
966-
}
967967
// An entry resolved from a direct URL (uv.lock `source = { url }`, a
968968
// pylock `archive = { url }`) is the user's own source choice unless it
969969
// is socket-patch's earlier hosted artifact for this release.
@@ -1266,6 +1266,21 @@ pub fn paired_metadata_path(lock: &str) -> Option<String> {
12661266
mod tests {
12671267
use super::*;
12681268

1269+
/// An entry the rewrite does not apply to (a non-registry, non-direct
1270+
/// string source) is not applicable, not refused: the sdist-order
1271+
/// refusal only judges entries the rewrite would change.
1272+
#[test]
1273+
fn a_not_applicable_distribution_entry_is_not_refused_for_its_sdist() {
1274+
let lock = "version = 1\nrequires-python = \">=3.9\"\n\n[[distribution]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nsource = \"git+https://github.com/urllib3/urllib3?rev=1.26.18#abc\"\nsdist = { url = \"https://example.test/urllib3-1.26.18.tar.gz\", hash = \"sha256:aa\" }\ndependencies = [{ name = \"idna\" }]\n";
1275+
let session = PythonLockSession::new(lock);
1276+
let planned = session.plan(
1277+
"urllib3",
1278+
"1.26.18",
1279+
ArtifactSource::Url("https://patch.test/urllib3-1.26.18-py3-none-any.whl"),
1280+
);
1281+
assert!(matches!(planned, Ok(None)), "{:?}", planned.err());
1282+
}
1283+
12691284
/// `exclude-newer-package` names packages (or exempts them with `false`);
12701285
/// uv 0.2 ignores it, so it is not the global cutoff that blocks a
12711286
/// `[[distribution]]` rewrite.

0 commit comments

Comments
 (0)