mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats
PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files record the wall-clock time they were made in, so the same commands run twice gave different bytes although the pixels matched (#2880): PDF /CreationDate and /ModDate D:20261018155816 vs D:20261018155817 PNG the `Creation Time` tEXt entry .vectorcraft one byte of the stored date SVG carries no time, and already repeated `metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it holds a non-negative whole number of seconds, else the clock, as the reproducible-builds specification defines it. A date a command is given (`file.new {created}`, a save's `modified`) still wins over the variable, and a blank, negative or non-numeric value is ignored so that an export is never refused over the environment. `metadata::now_unix` keeps the real clock and now has exactly two callers, both of which must not be pinnable from the environment: the recovery heartbeat, and the seed PDF encryption draws on.
This commit is contained in:
@@ -82,6 +82,25 @@ impl DocMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time`
|
||||
/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock,
|
||||
/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same
|
||||
/// bytes twice (#2880).
|
||||
///
|
||||
/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds
|
||||
/// encryption: neither may be pinned by the environment.
|
||||
pub fn output_time() -> Option<i64> {
|
||||
stamp_from_env(|n| std::env::var(n).ok()).or_else(now_unix)
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable
|
||||
/// (<https://reproducible-builds.org/specs/source-date-epoch/>), a non-negative decimal integer of
|
||||
/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then
|
||||
/// the clock decides — an export is never refused over the environment.
|
||||
fn stamp_from_env(get: impl Fn(&str) -> Option<String>) -> Option<i64> {
|
||||
get("SOURCE_DATE_EPOCH")?.trim().parse::<i64>().ok().filter(|t| *t >= 0)
|
||||
}
|
||||
|
||||
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn now_unix() -> Option<i64> {
|
||||
@@ -148,4 +167,21 @@ mod tests {
|
||||
);
|
||||
assert!(DocMetadata::default().png_text("").is_empty());
|
||||
}
|
||||
|
||||
/// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative
|
||||
/// decimal integer; anything else leaves the clock to decide, so an export is never refused.
|
||||
#[test]
|
||||
fn source_date_epoch_is_read_only_as_a_non_negative_integer() {
|
||||
let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned);
|
||||
assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(env(Some("0"))), Some(0));
|
||||
assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980));
|
||||
for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] {
|
||||
assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(stamp_from_env(env(None)), None, "unset");
|
||||
// Another variable never stands in for it.
|
||||
assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980));
|
||||
assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user