From 815beb6fac39b70a57dc36d55fb8c6dd61299492 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:26:41 +0000 Subject: [PATCH 1/3] Start fix for #407, #408 Assisted-by: Claude Code:claude-opus-5-5 From 1c3f5025b493c2be2c4f4037e1c87086a53e5dad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:31:07 +0000 Subject: [PATCH 2/3] 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 --- .../tests/upstream_restore_golden.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index acd3ca819..231d221ba 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -1705,6 +1705,63 @@ wheels = [{{ url = \"{wheel_url}\", upload-time = 2023-10-17T17:46:21.184Z, size assert_pypi_round_trip("pylock", &input, &[urllib3_dep()], None).await; } +/// A pylock as uv writes it: whole-second `upload-time`s (PyPI reports +/// microseconds), with (`uv export`) or without (`uv pip compile`) an +/// `index` on each registry package. +fn uv_pylock(index: bool) -> String { + let index = if index { "index = \"https://pypi.org/simple\"\n" } else { "" }; + format!( + "# This file was autogenerated by uv via the following command:\n\ +# uv pip compile --format pylock.toml req.in -o pylock.toml\n\ +lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.8\"\n\n\ +[[packages]]\nname = \"idna\"\nversion = \"3.7\"\n{index}\ +sdist = {{ url = \"https://files.pythonhosted.org/packages/21/ed/idna-3.7.tar.gz\", upload-time = 2024-04-11T03:34:43Z, size = 189575, hashes = {{ sha256 = \"{a}\" }} }}\n\ +wheels = [{{ url = \"https://files.pythonhosted.org/packages/e5/3e/idna-3.7-py3-none-any.whl\", upload-time = 2024-04-11T03:34:41Z, size = 66836, hashes = {{ sha256 = \"{b}\" }} }}]\n\n\ +[[packages]]\nname = \"urllib3\"\nversion = \"1.26.18\"\n{index}\ +sdist = {{ url = \"{sdist_url}\", upload-time = 2023-10-17T17:46:24Z, size = 305687, hashes = {{ sha256 = \"{URLLIB3_SDIST_SHA}\" }} }}\n\ +wheels = [{{ url = \"{wheel_url}\", upload-time = 2023-10-17T17:46:21Z, size = 143835, hashes = {{ sha256 = \"{URLLIB3_WHEEL_SHA}\" }} }}]\n", + a = "a".repeat(64), + b = "b".repeat(64), + sdist_url = pypi_file_url(URLLIB3_SDIST), + wheel_url = pypi_file_url(URLLIB3_WHEEL), + ) +} + +/// #407: `uv pip compile --format pylock.toml` records no `index`; its +/// PyPI-file siblings still show the registry, and the restore writes none. +#[tokio::test] +#[serial] +async fn pylock_without_index_round_trips() { + let (_server, _env) = pypi_mock(&[urllib3_release()]).await; + let lock = uv_pylock(false); + assert!(!lock.contains("index")); + for eol in ["\n", "\r\n"] { + let input = tree(&[("pylock.toml", lock.replace('\n', eol))]); + assert_pypi_round_trip(&format!("pip compile {eol:?}"), &input, &[urllib3_dep()], None) + .await; + } + // A sibling whose files come from another host is not PyPI. + let mirror = lock.replace( + "https://files.pythonhosted.org/packages/21/ed/", + "https://mirror.example.com/packages/21/ed/", + ); + let input = tree(&[("pylock.toml", mirror)]); + let (why, _, after) = + pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; + assert!(after["pylock.toml"].contains("patch.socket.dev"), "the pin stays wired"); + assert!(why.contains("not PyPI"), "{why}"); +} + +/// #408: uv writes pylock `upload-time` in whole seconds, so the restored +/// entry does too (uv.lock keeps milliseconds). +#[tokio::test] +#[serial] +async fn pylock_whole_second_upload_times_round_trip() { + let (_server, _env) = pypi_mock(&[urllib3_release()]).await; + let input = tree(&[("pylock.toml", uv_pylock(true))]); + assert_pypi_round_trip("uv export pylock", &input, &[urllib3_dep()], None).await; +} + #[tokio::test] #[serial] async fn uv_refusals() { From 116d02272aff362b7b83185196a44c7aa7c309bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 00:09:11 +0000 Subject: [PATCH 3/3] 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 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/vex_e2e_common/uv.rs | 45 ++++- .../src/patch/redirect/upstream/uv.rs | 165 +++++++++++++++--- 3 files changed, 178 insertions(+), 34 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..a24cc5131 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -846,7 +846,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; uv 0.2 `[[distribution]]` locks. 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. 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-cli/tests/vex_e2e_common/uv.rs b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs index 1bc5ac69d..59307bb8f 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/uv.rs @@ -688,7 +688,10 @@ struct Built { /// Build the lane's project with the real uv (network: PyPI). `Err` is a /// skip reason (PyPI unreachable, fixture command failed). -fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result { +/// `mode` hosted: the uv pylock lanes also lock a pure-Python PyPI sibling +/// (`idna`), which shows the hosted rollback the lock's registry and +/// artifact shape. +fn build(uv: &Uv, lane: Lane, mode: Mode, tmp: &Path) -> Result { let proj = tmp.join("proj"); std::fs::create_dir_all(&proj).unwrap(); let cache = tmp.join("uv-cache"); @@ -754,10 +757,16 @@ fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result { Lane::ExportPylock => { let src = tmp.join("export-src"); std::fs::create_dir_all(&src).unwrap(); + let deps = match mode { + Mode::Hosted => "\"six==1.16.0\", \"idna==3.7\"", + Mode::Vendored => "\"six==1.16.0\"", + }; std::fs::write( src.join("pyproject.toml"), - "[project]\nname = \"uv-vex-capstone\"\nversion = \"0.1.0\"\n\ - requires-python = \">=3.9\"\ndependencies = [\"six==1.16.0\"]\n", + format!( + "[project]\nname = \"uv-vex-capstone\"\nversion = \"0.1.0\"\n\ + requires-python = \">=3.9\"\ndependencies = [{deps}]\n" + ), ) .unwrap(); need(uv.run_py(&src, &["lock"], &cache), "uv lock")?; @@ -773,7 +782,13 @@ fn build(uv: &Uv, lane: Lane, tmp: &Path) -> Result { pylock_sync(uv, &proj, &cache)?; } Lane::CompilePylock => { - std::fs::write(proj.join("requirements.in"), "six==1.16.0\n").unwrap(); + // Hosted: `idna` is a sibling with no `index` (uv pip compile + // writes none). + let reqs = match mode { + Mode::Hosted => "six==1.16.0\nidna==3.7\n", + Mode::Vendored => "six==1.16.0\n", + }; + std::fs::write(proj.join("requirements.in"), reqs).unwrap(); need( uv.run_py( &proj, @@ -1171,7 +1186,7 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { return; } let tmp = tempfile::tempdir().unwrap(); - let built = match build(uv, lane, tmp.path()) { + let built = match build(uv, lane, mode, tmp.path()) { Ok(b) => b, Err(why) => { skip(suite, &format!("{}: {why}", report.what("setup"))); @@ -1611,6 +1626,11 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { ), }; if mode == Mode::Hosted { + // The uv pylock lanes lock a PyPI sibling, which shows the registry + // (an `index`, or for `uv pip compile` PyPI files with none, #407) + // and the artifact shape, so they restore to the bytes uv wrote + // (#408). + let byte_exact = matches!(lane, Lane::ExportPylock | Lane::CompilePylock); let env: Value = serde_json::from_slice(&out.stdout) .unwrap_or_else(|e| panic!("{}: ({e})\n{}", report.what("revert"), dump(&out))); let still_wired = @@ -1624,15 +1644,28 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) { report.what("revert"), dump(&out) ); - for (f, _) in &built.registry { + for (f, bytes) in &built.registry { assert!( !still_wired(f), "{}: {f} still names the hosted patch", report.what("revert") ); + if byte_exact { + assert_eq!( + String::from_utf8_lossy(&std::fs::read(proj.join(f)).unwrap()), + String::from_utf8_lossy(bytes), + "{}: {f} not byte-restored", + report.what("revert") + ); + } } report.row("revert", "restored to the upstream registry entry"); } + _ if byte_exact => panic!( + "{}: a uv pylock must restore:\n{}", + report.what("revert"), + dump(&out) + ), _ => { let error = env["hosted"]["failed"][0]["error"] .as_str() 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 321b01de7..1bdde1f67 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -9,11 +9,15 @@ //! * the entry's `source = { registry = R }` became `{ url = }` //! (pylock: `index = R` was dropped) — R is the ONE registry every other //! registry package of the lock names, and must be PyPI's simple index; -//! no sibling, or several registries, refuses; +//! no sibling, or several registries, refuses. A pylock from +//! `uv pip compile` records no `index` at all: siblings without one whose +//! files are all on `files.pythonhosted.org` show PyPI, and the entry is +//! 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` / -//! `hashes` this uv release records, one wheel per line or not). uv keeps +//! `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 //! a release with any wheel that is not pure Python 3 is refused, as is a //! lock whose `[options]` / `[tool.uv]` filter files (`exclude-newer`, @@ -61,6 +65,12 @@ struct Hit { struct Shape { /// The one registry index they name. registry: String, + /// pylock: they record that registry in an `index` key (`uv export` + /// does, `uv pip compile` does not). + index: bool, + /// pylock: their `upload-time`s are whole seconds (uv truncates them + /// in pylock files; uv.lock keeps milliseconds). + whole_seconds: bool, /// Artifact table keys, in order (`url`, `hash`, `size`, …). keys: Vec, /// `upload-time` is a TOML datetime (pylock), not a string (uv.lock). @@ -293,6 +303,68 @@ fn lock_hits( hits } +/// The registry a sibling package resolves from. +#[derive(PartialEq, Eq, PartialOrd, Ord)] +enum SiblingRegistry<'a> { + /// Its `source = { registry }` (uv.lock) or `index` (pylock). + Named(&'a str), + /// A pylock entry with no `index` whose artifacts are all PyPI files. + PypiFiles, + /// A pylock entry with no `index` whose artifacts come from this host. + Files(String), +} + +impl SiblingRegistry<'_> { + fn describe(&self) -> String { + match self { + Self::Named(r) => r.to_string(), + Self::PypiFiles => "PyPI files with no `index`".to_string(), + Self::Files(host) => format!("files on {host}"), + } + } +} + +/// A package's `wheels` and `sdist` artifact tables. +fn artifact_tables(package: &toml_edit::Table) -> impl Iterator { + let wheels = package + .get("wheels") + .and_then(Item::as_array) + .into_iter() + .flat_map(|a| a.iter()); + let sdist = package.get("sdist").and_then(Item::as_value); + wheels.chain(sdist).filter_map(Value::as_inline_table) +} + +/// The lowercased host of `url`. +fn url_host(url: &str) -> String { + let rest = url.split_once("://").map_or(url, |(_, rest)| rest); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + authority + .rsplit('@') + .next() + .unwrap_or(authority) + .to_ascii_lowercase() +} + +/// Where a pylock entry without an `index` resolves from: PyPI when every +/// artifact is a PyPI file (what `uv pip compile` writes for the default +/// index); `None` for an entry with no registry artifacts (a `vcs`, +/// `directory` or `archive` package). +fn pylock_unindexed_registry(package: &toml_edit::Table) -> Option> { + let urls: Vec<&str> = artifact_tables(package) + .filter_map(|a| a.get("url")?.as_str()) + .collect(); + let other = urls + .iter() + .map(|u| url_host(u)) + .find(|host| host != "files.pythonhosted.org"); + match (urls.is_empty(), other) { + (true, _) => None, + (false, None) => Some(SiblingRegistry::PypiFiles), + (false, Some(host)) => Some(SiblingRegistry::Files(host)), + } +} + /// The registry and artifact shape the lock's other registry packages show. fn lock_shape( doc: &DocumentMut, @@ -305,21 +377,32 @@ fn lock_shape( .get(collection) .and_then(Item::as_array_of_tables) .ok_or("the lock has no package array")?; - let mut registries: BTreeSet<&str> = BTreeSet::new(); + let mut registries: BTreeSet = BTreeSet::new(); let mut shape: Option<(Vec, bool, bool, Vec)> = None; + let mut fractional_seconds = false; for (i, package) in packages.iter().enumerate() { if hit_indices.contains(&i) { continue; } let registry = if pep751 { - package.get("index").and_then(Item::as_str) + match package.get("index").and_then(Item::as_str) { + Some(index) => Some(SiblingRegistry::Named(index)), + None => pylock_unindexed_registry(package), + } } else { - UvSource::of(package).and_then(UvSource::registry) + UvSource::of(package) + .and_then(UvSource::registry) + .map(SiblingRegistry::Named) }; let Some(registry) = registry else { continue; }; registries.insert(registry); + fractional_seconds |= artifact_tables(package).any(|a| { + a.get("upload-time") + .and_then(Value::as_datetime) + .is_some_and(|t| t.to_string().contains('.')) + }); if shape.is_some() { continue; } @@ -342,24 +425,31 @@ fn lock_shape( let package_keys = package.iter().map(|(k, _)| k.to_string()).collect(); shape = Some((keys, datetime, multiline, package_keys)); } - let registry = match registries.len() { - 0 => { + if registries.len() > 1 { + return Err(format!( + "the lock's packages come from several registries ({}); the entry's is \ + ambiguous", + registries + .iter() + .map(SiblingRegistry::describe) + .collect::>() + .join(", ") + )); + } + let (registry, index) = match registries.pop_first() { + None => { return Err( "no sibling registry package shows the registry and artifact fields this uv \ release records" .to_string(), ) } - 1 => registries - .into_iter() - .next() - .unwrap_or_default() - .to_string(), - _ => { + Some(SiblingRegistry::Named(registry)) => (registry.to_string(), true), + Some(SiblingRegistry::PypiFiles) => ("https://pypi.org/simple".to_string(), false), + Some(SiblingRegistry::Files(host)) => { return Err(format!( - "the lock's packages come from several registries ({}); the entry's is \ - ambiguous", - registries.into_iter().collect::>().join(", ") + "the lock's packages record no `index` and carry files from {host}, which is \ + not PyPI, so its release files cannot be re-derived" )) } }; @@ -381,6 +471,8 @@ fn lock_shape( } Ok(Shape { registry, + index, + whole_seconds: pep751 && !fractional_seconds, keys, datetime, multiline, @@ -388,15 +480,19 @@ fn lock_shape( }) } -/// uv's millisecond timestamp of a PyPI `upload_time_iso_8601` -/// (`2023-10-17T17:46:21.184066Z` → `2023-10-17T17:46:21.184Z`; trailing -/// fractional zeros are not printed). -fn upload_time(iso: &str) -> Option { +/// 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 +/// in pylock files (`2023-10-17T17:46:21Z`). +fn upload_time(iso: &str, whole_seconds: bool) -> Option { let iso = iso.strip_suffix('Z')?; let (seconds, fraction) = iso.split_once('.').unwrap_or((iso, "")); if seconds.len() != 19 || !fraction.bytes().all(|b| b.is_ascii_digit()) { return None; } + if whole_seconds { + return Some(format!("{seconds}Z")); + } let mut millis: String = fraction.chars().take(3).collect(); while millis.ends_with('0') { millis.pop(); @@ -425,7 +521,7 @@ fn render_artifact(file: &PypiFile, shape: &Shape) -> Result { let time = file .upload_time .as_deref() - .and_then(upload_time) + .and_then(|iso| upload_time(iso, shape.whole_seconds)) .ok_or_else(|| format!("PyPI reports no upload time for {}", file.filename))?; if shape.datetime { time @@ -587,7 +683,9 @@ fn restore_pylock_entry( .and_then(|p| p.get_mut(hit.index)) .ok_or("the package entry vanished")?; package.remove("archive"); - package.insert("index", toml_edit::value(shape.registry.clone())); + if shape.index { + package.insert("index", toml_edit::value(shape.registry.clone())); + } if let Some(sdist) = sdist { package.insert("sdist", Item::Value(sdist)); } @@ -1156,22 +1254,35 @@ mod tests { #[test] fn upload_times_are_milliseconds_without_trailing_zeros() { assert_eq!( - upload_time("2023-10-17T17:46:21.184066Z").as_deref(), + upload_time("2023-10-17T17:46:21.184066Z", false).as_deref(), Some("2023-10-17T17:46:21.184Z") ); assert_eq!( - upload_time("2023-10-17T17:46:21.100Z").as_deref(), + upload_time("2023-10-17T17:46:21.100Z", false).as_deref(), Some("2023-10-17T17:46:21.1Z") ); assert_eq!( - upload_time("2023-10-17T17:46:21.000400Z").as_deref(), + upload_time("2023-10-17T17:46:21.000400Z", false).as_deref(), Some("2023-10-17T17:46:21Z") ); assert_eq!( - upload_time("2023-10-17T17:46:21Z").as_deref(), + upload_time("2023-10-17T17:46:21Z", false).as_deref(), Some("2023-10-17T17:46:21Z") ); - assert_eq!(upload_time("2023-10-17 17:46"), None); + assert_eq!(upload_time("2023-10-17 17:46", false), None); + } + + #[test] + fn pylock_upload_times_truncate_to_whole_seconds() { + assert_eq!( + upload_time("2021-05-05T14:18:17.237000Z", true).as_deref(), + Some("2021-05-05T14:18:17Z") + ); + assert_eq!( + upload_time("2021-05-05T14:18:18.999Z", true).as_deref(), + Some("2021-05-05T14:18:18Z") + ); + assert_eq!(upload_time("2023-10-17 17:46", true), None); } #[test]