From a0dc7eb093ca4b044e083da262f450b65fe20652 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 13:27:12 -0600 Subject: [PATCH 1/7] test(lockfile): Ensure no_time case doesn't have time --- tests/testsuite/generate_lockfile.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/testsuite/generate_lockfile.rs b/tests/testsuite/generate_lockfile.rs index 5ec6c5d0a14..6d686175cf3 100644 --- a/tests/testsuite/generate_lockfile.rs +++ b/tests/testsuite/generate_lockfile.rs @@ -320,12 +320,8 @@ fn publish_time() { Package::new("has_time", "2025.6.1") .pubtime("2025-06-01T06:00:00Z") .publish(); - Package::new("no_time", "2025.1.1") - .pubtime("2025-01-01T06:00:00Z") - .publish(); - Package::new("no_time", "2025.6.1") - .pubtime("2025-06-01T06:00:00Z") - .publish(); + Package::new("no_time", "2025.1.1").publish(); + Package::new("no_time", "2025.6.1").publish(); let p = project() .file( @@ -348,7 +344,6 @@ fn publish_time() { [UPDATING] `dummy-registry` index [LOCKING] 2 packages to latest compatible versions as of 2025-03-01T06:00:00Z [ADDING] has_time v2025.1.1 (available: v2025.6.1) -[ADDING] no_time v2025.1.1 (available: v2025.6.1) "#]]) .run(); @@ -377,9 +372,9 @@ checksum = "105ca3acbc796da3e728ff310cafc6961cebc48d0106285edb8341015b5cc2d7" [[package]] name = "no_time" -version = "2025.1.1" +version = "2025.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3832e543b832e9270f27f9a56a3f3170aeaf32debe355b2fa4e61ede80a39f" +checksum = "01e688c07975f1e85f526c033322273181a4d8fe97800543d813d0a0adc134e3" "##]], ); From fd294ac9b572e34b7adcbb67ee5ce4151d06f89e Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 13:33:34 -0600 Subject: [PATCH 2/7] test(index): Show parse error behavior --- tests/testsuite/generate_lockfile.rs | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/testsuite/generate_lockfile.rs b/tests/testsuite/generate_lockfile.rs index 6d686175cf3..b7537d83b27 100644 --- a/tests/testsuite/generate_lockfile.rs +++ b/tests/testsuite/generate_lockfile.rs @@ -413,3 +413,33 @@ required by package `foo v0.0.0 ([ROOT]/foo)` "#]]) .run(); } + +#[cargo_test] +fn publish_time_invalid() { + Package::new("has_time", "2025.6.1") + .pubtime("2025-06-01T06:00:00") + .publish(); + + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + + [dependencies] + has_time = "2025.0" + "#, + ) + .file("src/lib.rs", "") + .build(); + + p.cargo("generate-lockfile --publish-time 2025-03-01T06:00:00Z -Zunstable-options") + .masquerade_as_nightly_cargo(&["publish-time"]) + .with_stderr_data(str![[r#" +[UPDATING] `dummy-registry` index +[LOCKING] 1 package to latest compatible version as of 2025-03-01T06:00:00Z + +"#]]) + .run(); +} From 7bccc125c1cd9c3d9c412e7522b1f0b65d42c9b1 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 13:31:42 -0600 Subject: [PATCH 3/7] fix(index): Validate pubtime on parse --- Cargo.lock | 1 + crates/cargo-util-schemas/Cargo.toml | 1 + crates/cargo-util-schemas/src/index.rs | 3 ++- src/cargo/sources/registry/index/mod.rs | 2 +- tests/testsuite/generate_lockfile.rs | 6 +++++- 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac1ea3d977c..0700e257bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,7 @@ dependencies = [ name = "cargo-util-schemas" version = "0.11.0" dependencies = [ + "jiff", "schemars", "semver", "serde", diff --git a/crates/cargo-util-schemas/Cargo.toml b/crates/cargo-util-schemas/Cargo.toml index 44fbf7a1844..4a9c0437116 100644 --- a/crates/cargo-util-schemas/Cargo.toml +++ b/crates/cargo-util-schemas/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true description = "Deserialization schemas for Cargo" [dependencies] +jiff = { workspace = true, features = ["std", "serde"] } schemars = { workspace = true, features = ["preserve_order", "semver1"], optional = true } semver.workspace = true serde = { workspace = true, features = ["derive"] } diff --git a/crates/cargo-util-schemas/src/index.rs b/crates/cargo-util-schemas/src/index.rs index adb9e0c6ca0..48f8ff393db 100644 --- a/crates/cargo-util-schemas/src/index.rs +++ b/crates/cargo-util-schemas/src/index.rs @@ -49,7 +49,8 @@ pub struct IndexPackage<'a> { /// The publish time for the package. Unstable. /// /// In ISO8601 with UTC timezone (e.g. 2025-11-12T19:30:12Z) - pub pubtime: Option, + #[cfg_attr(feature = "unstable-schema", schemars(with = "Option"))] + pub pubtime: Option, /// The schema version for this entry. /// /// If this is None, it defaults to version `1`. Entries with unknown diff --git a/src/cargo/sources/registry/index/mod.rs b/src/cargo/sources/registry/index/mod.rs index 656fcfa35d6..db77bcab434 100644 --- a/src/cargo/sources/registry/index/mod.rs +++ b/src/cargo/sources/registry/index/mod.rs @@ -217,7 +217,7 @@ fn index_package_to_summary(pkg: &IndexPackage<'_>, source_id: SourceId) -> Carg let links: Option = pkg.links.as_ref().map(|l| l.as_ref().into()); let mut summary = Summary::new(pkgid, deps, &features, links, pkg.rust_version.clone())?; summary.set_checksum(pkg.cksum.clone()); - if let Some(pubtime) = pkg.pubtime.as_ref().and_then(|p| p.parse().ok()) { + if let Some(pubtime) = pkg.pubtime { summary.set_pubtime(pubtime); } Ok(summary) diff --git a/tests/testsuite/generate_lockfile.rs b/tests/testsuite/generate_lockfile.rs index b7537d83b27..c2243d26d8f 100644 --- a/tests/testsuite/generate_lockfile.rs +++ b/tests/testsuite/generate_lockfile.rs @@ -436,9 +436,13 @@ fn publish_time_invalid() { p.cargo("generate-lockfile --publish-time 2025-03-01T06:00:00Z -Zunstable-options") .masquerade_as_nightly_cargo(&["publish-time"]) + .with_status(101) .with_stderr_data(str![[r#" [UPDATING] `dummy-registry` index -[LOCKING] 1 package to latest compatible version as of 2025-03-01T06:00:00Z +[ERROR] failed to select a version for the requirement `has_time = "^2025.0"` + version 2025.6.1's index entry is invalid +location searched: `dummy-registry` index (which is replacing registry `crates-io`) +required by package `foo v0.0.0 ([ROOT]/foo)` "#]]) .run(); From 9a3f316fba0b6105bd19434c4cbcc16aff96857a Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 13:45:42 -0600 Subject: [PATCH 4/7] test(index): Show parse behavior --- crates/cargo-util-schemas/src/index.rs | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/cargo-util-schemas/src/index.rs b/crates/cargo-util-schemas/src/index.rs index 48f8ff393db..2b455b9432c 100644 --- a/crates/cargo-util-schemas/src/index.rs +++ b/crates/cargo-util-schemas/src/index.rs @@ -162,3 +162,41 @@ fn dump_index_schema() { let dump = serde_json::to_string_pretty(&schema).unwrap(); snapbox::assert_data_eq!(dump, snapbox::file!("../index.schema.json").raw()); } + +#[test] +fn pubtime_format() { + use snapbox::str; + + let input = [ + ("2025-11-12T19:30:12Z", Some(str!["2025-11-12T19:30:12Z"])), + // Padded values + ("2025-01-02T09:03:02Z", Some(str!["2025-01-02T09:03:02Z"])), + // Alt timezone format + ("2025-11-12T19:30:12-04", Some(str!["2025-11-12T23:30:12Z"])), + // Alt date/time separator + ("2025-11-12 19:30:12Z", Some(str!["2025-11-12T19:30:12Z"])), + // Non-padded values + ("2025-11-12T19:30:12+4", None), + ("2025-1-12T19:30:12+4", None), + ("2025-11-2T19:30:12+4", None), + ("2025-11-12T9:30:12Z", None), + ("2025-11-12T19:3:12Z", None), + ("2025-11-12T19:30:2Z", None), + ]; + for (input, expected) in input { + let (parsed, expected) = match (input.parse::(), expected) { + (Ok(_), None) => { + panic!("`{input}` did not error"); + } + (Ok(parsed), Some(expected)) => (parsed, expected), + (Err(err), Some(_)) => { + panic!("`{input}` did not parse successfully: {err}"); + } + _ => { + continue; + } + }; + let output = parsed.to_string(); + snapbox::assert_data_eq!(output, expected); + } +} From b8e73e65837086469e9e4cde255b68df890d813f Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 13:07:07 -0600 Subject: [PATCH 5/7] fix(index): Be conservative on datetime formats --- crates/cargo-util-schemas/index.schema.json | 3 +- crates/cargo-util-schemas/src/index.rs | 80 +++++++++++++++++++-- src/bin/cargo/commands/generate_lockfile.rs | 4 +- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/cargo-util-schemas/index.schema.json b/crates/cargo-util-schemas/index.schema.json index 5e9eab38a28..af34e73540e 100644 --- a/crates/cargo-util-schemas/index.schema.json +++ b/crates/cargo-util-schemas/index.schema.json @@ -73,7 +73,8 @@ "type": [ "string", "null" - ] + ], + "default": null }, "v": { "description": "The schema version for this entry.\n\nIf this is None, it defaults to version `1`. Entries with unknown\nversions are ignored.\n\nVersion `2` schema adds the `features2` field.\n\nVersion `3` schema adds `artifact`, `bindep_targes`, and `lib` for\nartifact dependencies support.\n\nThis provides a method to safely introduce changes to index entries\nand allow older versions of cargo to ignore newer entries it doesn't\nunderstand. This is honored as of 1.51, so unfortunately older\nversions will ignore it, and potentially misinterpret version 2 and\nnewer entries.\n\nThe intent is that versions older than 1.51 will work with a\npre-existing `Cargo.lock`, but they may not correctly process `cargo\nupdate` or build a lock from scratch. In that case, cargo may\nincorrectly select a new package that uses a new index schema. A\nworkaround is to downgrade any packages that are incompatible with the\n`--precise` flag of `cargo update`.", diff --git a/crates/cargo-util-schemas/src/index.rs b/crates/cargo-util-schemas/src/index.rs index 2b455b9432c..a90f4efd706 100644 --- a/crates/cargo-util-schemas/src/index.rs +++ b/crates/cargo-util-schemas/src/index.rs @@ -50,6 +50,8 @@ pub struct IndexPackage<'a> { /// /// In ISO8601 with UTC timezone (e.g. 2025-11-12T19:30:12Z) #[cfg_attr(feature = "unstable-schema", schemars(with = "Option"))] + #[serde(with = "serde_pubtime")] + #[serde(default)] pub pubtime: Option, /// The schema version for this entry. /// @@ -118,6 +120,76 @@ pub struct RegistryDependency<'a> { pub lib: bool, } +pub fn parse_pubtime(s: &str) -> Result { + let dt = jiff::civil::DateTime::strptime("%Y-%m-%dT%H:%M:%SZ", s)?; + if s.len() == 20 { + let zoned = dt.to_zoned(jiff::tz::TimeZone::UTC)?; + let timestamp = zoned.timestamp(); + Ok(timestamp) + } else { + Err(jiff::Error::from_args(format_args!( + "padding required for `{s}`" + ))) + } +} + +pub fn format_pubtime(t: jiff::Timestamp) -> String { + t.strftime("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +mod serde_pubtime { + #[inline] + pub(super) fn serialize( + timestamp: &Option, + se: S, + ) -> Result { + match *timestamp { + None => se.serialize_none(), + Some(ref ts) => { + let s = super::format_pubtime(*ts); + se.serialize_str(&s) + } + } + } + + #[inline] + pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>( + de: D, + ) -> Result, D::Error> { + de.deserialize_option(OptionalVisitor( + serde_untagged::UntaggedEnumVisitor::new() + .expecting("date time") + .string(|value| super::parse_pubtime(&value).map_err(serde::de::Error::custom)), + )) + } + + /// A generic visitor for `Option`. + struct OptionalVisitor(V); + + impl<'de, V: serde::de::Visitor<'de, Value = jiff::Timestamp>> serde::de::Visitor<'de> + for OptionalVisitor + { + type Value = Option; + + fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("date time") + } + + #[inline] + fn visit_some>( + self, + de: D, + ) -> Result, D::Error> { + de.deserialize_str(self.0).map(Some) + } + + #[inline] + fn visit_none(self) -> Result, E> { + Ok(None) + } + } +} + fn default_true() -> bool { true } @@ -172,9 +244,9 @@ fn pubtime_format() { // Padded values ("2025-01-02T09:03:02Z", Some(str!["2025-01-02T09:03:02Z"])), // Alt timezone format - ("2025-11-12T19:30:12-04", Some(str!["2025-11-12T23:30:12Z"])), + ("2025-11-12T19:30:12-04", None), // Alt date/time separator - ("2025-11-12 19:30:12Z", Some(str!["2025-11-12T19:30:12Z"])), + ("2025-11-12 19:30:12Z", None), // Non-padded values ("2025-11-12T19:30:12+4", None), ("2025-1-12T19:30:12+4", None), @@ -184,7 +256,7 @@ fn pubtime_format() { ("2025-11-12T19:30:2Z", None), ]; for (input, expected) in input { - let (parsed, expected) = match (input.parse::(), expected) { + let (parsed, expected) = match (parse_pubtime(input), expected) { (Ok(_), None) => { panic!("`{input}` did not error"); } @@ -196,7 +268,7 @@ fn pubtime_format() { continue; } }; - let output = parsed.to_string(); + let output = format_pubtime(parsed); snapbox::assert_data_eq!(output, expected); } } diff --git a/src/bin/cargo/commands/generate_lockfile.rs b/src/bin/cargo/commands/generate_lockfile.rs index ef02347b883..3f5d7a3e5eb 100644 --- a/src/bin/cargo/commands/generate_lockfile.rs +++ b/src/bin/cargo/commands/generate_lockfile.rs @@ -50,7 +50,9 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { if let Some(publish_time) = publish_time { gctx.cli_unstable() .fail_if_stable_opt("--publish-time", 5221)?; - ws.set_resolve_publish_time(publish_time.parse().map_err(anyhow::Error::from)?); + let publish_time = + cargo_util_schemas::index::parse_pubtime(publish_time).map_err(anyhow::Error::from)?; + ws.set_resolve_publish_time(publish_time); } ops::generate_lockfile(&ws)?; Ok(()) From e31f446011523a59cdfefadc075354411435aa0e Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 14:01:11 -0600 Subject: [PATCH 6/7] docs(index): Specify pubtime is independent of updates --- crates/cargo-util-schemas/index.schema.json | 2 +- crates/cargo-util-schemas/src/index.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cargo-util-schemas/index.schema.json b/crates/cargo-util-schemas/index.schema.json index af34e73540e..8901dc4718c 100644 --- a/crates/cargo-util-schemas/index.schema.json +++ b/crates/cargo-util-schemas/index.schema.json @@ -69,7 +69,7 @@ ] }, "pubtime": { - "description": "The publish time for the package. Unstable.\n\nIn ISO8601 with UTC timezone (e.g. 2025-11-12T19:30:12Z)", + "description": "The publish time for the package. Unstable.\n\nIn ISO8601 with UTC timezone (e.g. 2025-11-12T19:30:12Z)\n\nThis should be the original publish time and not changed on any status changes,\nlike [`IndexPackage::yanked`].", "type": [ "string", "null" diff --git a/crates/cargo-util-schemas/src/index.rs b/crates/cargo-util-schemas/src/index.rs index a90f4efd706..0b029ce15ab 100644 --- a/crates/cargo-util-schemas/src/index.rs +++ b/crates/cargo-util-schemas/src/index.rs @@ -49,6 +49,9 @@ pub struct IndexPackage<'a> { /// The publish time for the package. Unstable. /// /// In ISO8601 with UTC timezone (e.g. 2025-11-12T19:30:12Z) + /// + /// This should be the original publish time and not changed on any status changes, + /// like [`IndexPackage::yanked`]. #[cfg_attr(feature = "unstable-schema", schemars(with = "Option"))] #[serde(with = "serde_pubtime")] #[serde(default)] From 73ae7d5b990948a8545cfa5162482ee3212612e3 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Tue, 9 Dec 2025 14:11:25 -0600 Subject: [PATCH 7/7] chore(schemas): Bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/cargo-util-schemas/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0700e257bf9..edf8866683e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,7 +516,7 @@ dependencies = [ [[package]] name = "cargo-util-schemas" -version = "0.11.0" +version = "0.12.0" dependencies = [ "jiff", "schemars", diff --git a/Cargo.toml b/Cargo.toml index de271fd0ffe..ec094828812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ cargo-platform = { path = "crates/cargo-platform", version = "0.3.0" } cargo-test-macro = { version = "0.4.8", path = "crates/cargo-test-macro" } cargo-test-support = { version = "0.9.1", path = "crates/cargo-test-support" } cargo-util = { version = "0.2.26", path = "crates/cargo-util" } -cargo-util-schemas = { version = "0.11.0", path = "crates/cargo-util-schemas" } +cargo-util-schemas = { version = "0.12.0", path = "crates/cargo-util-schemas" } cargo_metadata = "0.23.1" clap = "4.5.53" clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] } diff --git a/crates/cargo-util-schemas/Cargo.toml b/crates/cargo-util-schemas/Cargo.toml index 4a9c0437116..a87b04988f4 100644 --- a/crates/cargo-util-schemas/Cargo.toml +++ b/crates/cargo-util-schemas/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-util-schemas" -version = "0.11.0" +version = "0.12.0" rust-version = "1.91" # MSRV:1 edition.workspace = true license.workspace = true