diff --git a/Cargo.lock b/Cargo.lock index ac1ea3d977c..edf8866683e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,8 +516,9 @@ dependencies = [ [[package]] name = "cargo-util-schemas" -version = "0.11.0" +version = "0.12.0" dependencies = [ + "jiff", "schemars", "semver", "serde", 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 44fbf7a1844..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 @@ -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/index.schema.json b/crates/cargo-util-schemas/index.schema.json index 5e9eab38a28..8901dc4718c 100644 --- a/crates/cargo-util-schemas/index.schema.json +++ b/crates/cargo-util-schemas/index.schema.json @@ -69,11 +69,12 @@ ] }, "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" - ] + ], + "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 adb9e0c6ca0..0b029ce15ab 100644 --- a/crates/cargo-util-schemas/src/index.rs +++ b/crates/cargo-util-schemas/src/index.rs @@ -49,7 +49,13 @@ 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, + /// + /// 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)] + pub pubtime: Option, /// The schema version for this entry. /// /// If this is None, it defaults to version `1`. Entries with unknown @@ -117,6 +123,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 } @@ -161,3 +237,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", None), + // Alt date/time separator + ("2025-11-12 19:30:12Z", None), + // 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 (parse_pubtime(input), 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 = 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(()) 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 5ec6c5d0a14..c2243d26d8f 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" "##]], ); @@ -418,3 +413,37 @@ 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_status(101) + .with_stderr_data(str![[r#" +[UPDATING] `dummy-registry` index +[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(); +}