diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..ff93b915f 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -850,7 +850,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. - * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). + * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index 6b853d3e8..55e5bac77 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -15,7 +15,8 @@ //! restored without one too; //! * `sdist` / `wheels` were replaced by the patched wheel (pylock: an //! `archive`) — re-derived from PyPI's JSON API in the artifact shape a -//! sibling registry package shows (which of `size` / `upload-time` / +//! sibling registry package shows (which of `size` / `upload-time` (or +//! uv 0.6.15–0.6.17's `upload_time`) / //! `hashes` this uv release records, one wheel per line or not; pylock //! `upload-time`s in whole seconds unless a sibling shows a fraction). uv keeps //! only the wheels its `requires-python` and environments can install, so @@ -399,7 +400,7 @@ fn lock_shape( }; registries.insert(registry); fractional_seconds |= artifact_tables(package).any(|a| { - a.get("upload-time") + upload_time_value(a) .and_then(Value::as_datetime) .is_some_and(|t| t.to_string().contains('.')) }); @@ -415,9 +416,7 @@ fn lock_shape( continue; }; let keys: Vec = artifact.iter().map(|(k, _)| k.to_string()).collect(); - let datetime = artifact - .get("upload-time") - .is_some_and(|v| v.as_datetime().is_some()); + let datetime = upload_time_value(artifact).is_some_and(|v| v.as_datetime().is_some()); let multiline = package .get("wheels") .and_then(Item::as_array) @@ -463,8 +462,11 @@ fn lock_shape( "no sibling registry package records an artifact, so which artifact fields this uv \ release records is not derivable", )?; - let known = ["url", "hash", "hashes", "size", "upload-time", "name"]; - if let Some(unknown) = keys.iter().find(|k| !known.contains(&k.as_str())) { + let known = ["url", "hash", "hashes", "size", "name"]; + if let Some(unknown) = keys + .iter() + .find(|k| !known.contains(&k.as_str()) && !UPLOAD_TIME_KEYS.contains(&k.as_str())) + { return Err(format!( "sibling artifacts carry an unknown field `{unknown}`" )); @@ -480,6 +482,17 @@ fn lock_shape( }) } +/// The artifact timestamp key, in both spellings uv has written: +/// `upload_time` (uv 0.6.15–0.6.17, lock revision 2) and `upload-time` +/// (uv 0.7.0 and later, and PEP 751 pylock files). A re-derived artifact +/// keeps the spelling its sibling shows. +const UPLOAD_TIME_KEYS: [&str; 2] = ["upload-time", "upload_time"]; + +/// An artifact's timestamp, under whichever spelling it records. +fn upload_time_value(artifact: &toml_edit::InlineTable) -> Option<&Value> { + UPLOAD_TIME_KEYS.iter().find_map(|k| artifact.get(k)) +} + /// uv's timestamp of a PyPI `upload_time_iso_8601`: milliseconds in /// uv.lock (`2023-10-17T17:46:21.184066Z` → `2023-10-17T17:46:21.184Z`; /// trailing fractional zeros are not printed), truncated to whole seconds @@ -517,7 +530,7 @@ fn render_artifact(file: &PypiFile, shape: &Shape) -> Result { .size .ok_or_else(|| format!("PyPI reports no size for {}", file.filename))? .to_string(), - "upload-time" => { + key if UPLOAD_TIME_KEYS.contains(&key) => { let time = file .upload_time .as_deref() diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 231d221ba..8d0ce6a10 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -1684,6 +1684,49 @@ async fn uv_script_lock_round_trips() { assert_pypi_round_trip("uv script", &input, &[urllib3_dep()], None).await; } +/// uv 0.6.15–0.6.17 lock revision 2 spells the artifact timestamp +/// `upload_time` (#788): the unwind re-derives the entry in that spelling +/// instead of refusing the key, for project and script locks alike. +#[tokio::test] +#[serial] +async fn uv_underscore_upload_time_locks_round_trip() { + let (_server, _env) = pypi_mock(&[urllib3_release()]).await; + let lock = uv_lock( + " { name = \"idna\" },\n { name = \"urllib3\" },\n", + " { name = \"idna\", specifier = \">=3\" },\n { name = \"urllib3\", specifier = \"==1.26.18\" },\n", + "", + ) + .replace("upload-time", "upload_time"); + assert!(!lock.contains("upload-time"), "{lock}"); + let pyproject = "[project]\nname = \"proj\"\nversion = \"0.1.0\"\ndependencies = [\"idna>=3\", \"urllib3==1.26.18\"]\n"; + for eol in ["\n", "\r\n"] { + let input = tree(&[ + ("uv.lock", lock.replace('\n', eol)), + ("pyproject.toml", pyproject.replace('\n', eol)), + ]); + assert_pypi_round_trip( + &format!("uv 0.6.17 project {eol:?}"), + &input, + &[urllib3_dep()], + None, + ) + .await; + } + let script = "#!/usr/bin/env python3\n# /// script\n# dependencies = [\"idna>=3\", \"urllib3==1.26.18\"]\n# ///\nprint('hi')\n"; + let lock = uv_lock("", "", "\n[manifest]\nrequirements = [\n { name = \"idna\", specifier = \">=3\" },\n { name = \"urllib3\", specifier = \"==1.26.18\" },\n]\n") + .replace( + "[[package]]\nname = \"proj\"\nversion = \"0.1.0\"\nsource = { virtual = \".\" }\ndependencies = [\n]\n\n[package.metadata]\nrequires-dist = [\n]\n\n", + "", + ) + .replace("upload-time", "upload_time"); + assert!( + !lock.contains("proj") && !lock.contains("upload-time"), + "{lock}" + ); + let input = tree(&[("tool.py", script.into()), ("tool.py.lock", lock)]); + assert_pypi_round_trip("uv 0.6.17 script", &input, &[urllib3_dep()], None).await; +} + #[tokio::test] #[serial] async fn pylock_round_trips() {