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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<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.
* **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 "<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.
* **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.<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`).
Expand Down
29 changes: 21 additions & 8 deletions crates/socket-patch-core/src/patch/redirect/upstream/uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('.'))
});
Expand All @@ -415,9 +416,7 @@ fn lock_shape(
continue;
};
let keys: Vec<String> = 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)
Expand Down Expand Up @@ -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}`"
));
Expand All @@ -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
Expand Down Expand Up @@ -517,7 +530,7 @@ fn render_artifact(file: &PypiFile, shape: &Shape) -> Result<String, String> {
.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()
Expand Down
43 changes: 43 additions & 0 deletions crates/socket-patch-core/tests/upstream_restore_golden.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading