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
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
3 changes: 2 additions & 1 deletion crates/cargo-util-schemas/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"] }
Expand Down
5 changes: 3 additions & 2 deletions crates/cargo-util-schemas/index.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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`.",
Expand Down
116 changes: 115 additions & 1 deletion crates/cargo-util-schemas/src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
///
/// 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<String>"))]
#[serde(with = "serde_pubtime")]
#[serde(default)]
pub pubtime: Option<jiff::Timestamp>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we intend to expose the concrete jiff::Timestamp to users? Should we wrap it with our own type?

(Similar discussion in #15293 (comment). I am fine with either)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comment from that thread

Yes and we already had this problem with time in the puiblic API. I was just wondering if we should consolidate the breaking changes or going ahead and moving forward. No strong opinion. So long as the serialization format is unchanged, breaking changes to this API are not too big of a deal; the target audience is very small.

Just adding a field is a breaking change so not to worried and adding a custom type seems like boiler plate with little benefit

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. Just want to make sure this is intentional. Also cargo-util-schemas is a package we are not worried about bumping version.

/// The schema version for this entry.
///
/// If this is None, it defaults to version `1`. Entries with unknown
Expand Down Expand Up @@ -117,6 +123,76 @@ pub struct RegistryDependency<'a> {
pub lib: bool,
}

pub fn parse_pubtime(s: &str) -> Result<jiff::Timestamp, jiff::Error> {
let dt = jiff::civil::DateTime::strptime("%Y-%m-%dT%H:%M:%SZ", s)?;
if s.len() == 20 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cargo isn't future-proof for years beyond 9999 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope it can last that long! Perhaps this code still is buried in the Arctic mountain at that time🤣

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<S: serde::Serializer>(
timestamp: &Option<jiff::Timestamp>,
se: S,
) -> Result<S::Ok, S::Error> {
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<Option<jiff::Timestamp>, 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<DateTime>`.
struct OptionalVisitor<V>(V);

impl<'de, V: serde::de::Visitor<'de, Value = jiff::Timestamp>> serde::de::Visitor<'de>
for OptionalVisitor<V>
{
type Value = Option<jiff::Timestamp>;

fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("date time")
}

#[inline]
fn visit_some<D: serde::de::Deserializer<'de>>(
self,
de: D,
) -> Result<Option<jiff::Timestamp>, D::Error> {
de.deserialize_str(self.0).map(Some)
}

#[inline]
fn visit_none<E: serde::de::Error>(self) -> Result<Option<jiff::Timestamp>, E> {
Ok(None)
}
}
}

fn default_true() -> bool {
true
}
Expand Down Expand Up @@ -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);
}
}
4 changes: 3 additions & 1 deletion src/bin/cargo/commands/generate_lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down
2 changes: 1 addition & 1 deletion src/cargo/sources/registry/index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ fn index_package_to_summary(pkg: &IndexPackage<'_>, source_id: SourceId) -> Carg
let links: Option<InternedString> = 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)
Expand Down
47 changes: 38 additions & 9 deletions tests/testsuite/generate_lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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"

"##]],
);
Expand Down Expand Up @@ -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();
}