Skip to content

Commit 7825622

Browse files
Fix uv pylock rollback shape (#407, #408) (#512)
* Start fix for #407, #408 Assisted-by: Claude Code:claude-opus-5-5 * Add failing tests for uv pylock rollback shape A uv pip compile pylock (no index key) refuses to roll back (#407), and a restored pylock entry gets millisecond upload-time values where uv writes whole seconds (#408). Assisted-by: Claude Code:claude-opus-5-5 * Fix uv pylock rollback refusing and drifting Hosted rollback, remove and the vendored takeover refused every pylock.toml written by `uv pip compile`, because that command records no `index` key and the restore only read the registry from one (#407). Packages without an `index` whose files are all on PyPI now show the registry, and the entry is restored without an `index` too. A rolled-back pylock also never matched what uv writes: restored `upload-time` values kept milliseconds, while uv writes whole seconds in pylock files (#408). The restore now follows the lock's own precision. The real-uv hosted e2e lanes for `uv export` and `uv pip compile` pylocks now lock a PyPI sibling and require a byte-exact rollback. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 242fef7 commit 7825622

4 files changed

Lines changed: 235 additions & 34 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
850850
* **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.
851851
* **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-<uuid>"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-<uuid>]` block leaves the project cargo config. A declaration it cannot unpin refuses.
852852
* **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.
853-
* **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; uv 0.2 `[[distribution]]` locks. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`).
853+
* **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`).
854854
* **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 "<patch registry>" 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 "<name>", "<version>"`. 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.
855855
* **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.
856856
* **maven** — `pom.xml` (the `-socket.<hex8>` version suffix, the added `<repository>` / `<dependencyManagement>` 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`).

‎crates/socket-patch-cli/tests/vex_e2e_common/uv.rs‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,10 @@ struct Built {
688688

689689
/// Build the lane's project with the real uv (network: PyPI). `Err` is a
690690
/// skip reason (PyPI unreachable, fixture command failed).
691-
fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result<Built, String> {
691+
/// `mode` hosted: the uv pylock lanes also lock a pure-Python PyPI sibling
692+
/// (`idna`), which shows the hosted rollback the lock's registry and
693+
/// artifact shape.
694+
fn build(uv: &Uv, lane: Lane, mode: Mode, tmp: &Path) -> Result<Built, String> {
692695
let proj = tmp.join("proj");
693696
std::fs::create_dir_all(&proj).unwrap();
694697
let cache = tmp.join("uv-cache");
@@ -754,10 +757,16 @@ fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result<Built, String> {
754757
Lane::ExportPylock => {
755758
let src = tmp.join("export-src");
756759
std::fs::create_dir_all(&src).unwrap();
760+
let deps = match mode {
761+
Mode::Hosted => "\"six==1.16.0\", \"idna==3.7\"",
762+
Mode::Vendored => "\"six==1.16.0\"",
763+
};
757764
std::fs::write(
758765
src.join("pyproject.toml"),
759-
"[project]\nname = \"uv-vex-capstone\"\nversion = \"0.1.0\"\n\
760-
requires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n",
766+
format!(
767+
"[project]\nname = \"uv-vex-capstone\"\nversion = \"0.1.0\"\n\
768+
requires-python = \">=3.9\"\ndependencies = [{deps}]\n"
769+
),
761770
)
762771
.unwrap();
763772
need(uv.run_py(&src, &["lock"], &cache), "uv lock")?;
@@ -773,7 +782,13 @@ fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result<Built, String> {
773782
pylock_sync(uv, &proj, &cache)?;
774783
}
775784
Lane::CompilePylock => {
776-
std::fs::write(proj.join("requirements.in"), "six==1.16.0\n").unwrap();
785+
// Hosted: `idna` is a sibling with no `index` (uv pip compile
786+
// writes none).
787+
let reqs = match mode {
788+
Mode::Hosted => "six==1.16.0\nidna==3.7\n",
789+
Mode::Vendored => "six==1.16.0\n",
790+
};
791+
std::fs::write(proj.join("requirements.in"), reqs).unwrap();
777792
need(
778793
uv.run_py(
779794
&proj,
@@ -1171,7 +1186,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) {
11711186
return;
11721187
}
11731188
let tmp = tempfile::tempdir().unwrap();
1174-
let built = match build(uv, lane, tmp.path()) {
1189+
let built = match build(uv, lane, mode, tmp.path()) {
11751190
Ok(b) => b,
11761191
Err(why) => {
11771192
skip(suite, &format!("{}: {why}", report.what("setup")));
@@ -1611,6 +1626,11 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) {
16111626
),
16121627
};
16131628
if mode == Mode::Hosted {
1629+
// The uv pylock lanes lock a PyPI sibling, which shows the registry
1630+
// (an `index`, or for `uv pip compile` PyPI files with none, #407)
1631+
// and the artifact shape, so they restore to the bytes uv wrote
1632+
// (#408).
1633+
let byte_exact = matches!(lane, Lane::ExportPylock | Lane::CompilePylock);
16141634
let env: Value = serde_json::from_slice(&out.stdout)
16151635
.unwrap_or_else(|e| panic!("{}: ({e})\n{}", report.what("revert"), dump(&out)));
16161636
let still_wired =
@@ -1624,15 +1644,28 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) {
16241644
report.what("revert"),
16251645
dump(&out)
16261646
);
1627-
for (f, _) in &built.registry {
1647+
for (f, bytes) in &built.registry {
16281648
assert!(
16291649
!still_wired(f),
16301650
"{}: {f} still names the hosted patch",
16311651
report.what("revert")
16321652
);
1653+
if byte_exact {
1654+
assert_eq!(
1655+
String::from_utf8_lossy(&std::fs::read(proj.join(f)).unwrap()),
1656+
String::from_utf8_lossy(bytes),
1657+
"{}: {f} not byte-restored",
1658+
report.what("revert")
1659+
);
1660+
}
16331661
}
16341662
report.row("revert", "restored to the upstream registry entry");
16351663
}
1664+
_ if byte_exact => panic!(
1665+
"{}: a uv pylock must restore:\n{}",
1666+
report.what("revert"),
1667+
dump(&out)
1668+
),
16361669
_ => {
16371670
let error = env["hosted"]["failed"][0]["error"]
16381671
.as_str()

0 commit comments

Comments
 (0)