From d4b3ae0925748ef37a778b56ee26b63bf4585b06 Mon Sep 17 00:00:00 2001 From: frozolotl <44589151+frozolotl@users.noreply.github.com> Date: Wed, 3 Apr 2024 12:34:17 +0200 Subject: [PATCH] Read `SOURCE_DATE_EPOCH` for better reproducibility (#3809) --- crates/typst-cli/Cargo.toml | 1 + crates/typst-cli/src/args.rs | 16 +++++++++++ crates/typst-cli/src/compile.rs | 22 ++++++++------- crates/typst-cli/src/world.rs | 48 ++++++++++++++++++++++++--------- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index c2b23df06..9f90f430d 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -62,6 +62,7 @@ zip = { workspace = true, optional = true } openssl = { workspace = true } [build-dependencies] +chrono = { workspace = true } clap = { workspace = true, features = ["string"] } clap_complete = { workspace = true } clap_mangen = { workspace = true } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index a8878611b..14173a550 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -1,6 +1,7 @@ use std::fmt::{self, Display, Formatter}; use std::path::PathBuf; +use chrono::{DateTime, Utc}; use clap::builder::ValueParser; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum}; use semver::Version; @@ -167,6 +168,12 @@ pub struct SharedArgs { )] pub font_paths: Vec, + /// The document's creation date formatted as a UNIX timestamp. + /// + /// For more information, see . + #[clap(env = "SOURCE_DATE_EPOCH", value_parser = parse_source_date_epoch)] + pub source_date_epoch: Option>, + /// The format to emit diagnostics in #[clap( long, @@ -176,6 +183,15 @@ pub struct SharedArgs { pub diagnostic_format: DiagnosticFormat, } +/// Parses a UNIX timestamp according to +fn parse_source_date_epoch(raw: &str) -> Result, String> { + let timestamp: i64 = raw + .parse() + .map_err(|err| format!("timestamp must be decimal integer ({err})"))?; + DateTime::from_timestamp(timestamp, 0) + .ok_or_else(|| "timestamp out of range".to_string()) +} + /// An input that is either stdin or a real path. #[derive(Debug, Clone)] pub enum Input { diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 272ca2923..e6f8b241f 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -166,7 +166,10 @@ fn export( /// Export to a PDF. fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { - let buffer = typst_pdf::pdf(document, Smart::Auto, now()); + let timestamp = convert_datetime( + command.common.source_date_epoch.unwrap_or_else(chrono::Utc::now), + ); + let buffer = typst_pdf::pdf(document, Smart::Auto, timestamp); command .output() .write(&buffer) @@ -174,16 +177,15 @@ fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { Ok(()) } -/// Get the current date and time in UTC. -fn now() -> Option { - let now = chrono::Local::now().naive_utc(); +/// Convert [`chrono::DateTime`] to [`Datetime`] +fn convert_datetime(date_time: chrono::DateTime) -> Option { Datetime::from_ymd_hms( - now.year(), - now.month().try_into().ok()?, - now.day().try_into().ok()?, - now.hour().try_into().ok()?, - now.minute().try_into().ok()?, - now.second().try_into().ok()?, + date_time.year(), + date_time.month().try_into().ok()?, + date_time.day().try_into().ok()?, + date_time.hour().try_into().ok()?, + date_time.minute().try_into().ok()?, + date_time.second().try_into().ok()?, ) } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 4e0bcd54a..e0215130f 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::{fmt, fs, io, mem}; -use chrono::{DateTime, Datelike, Local}; +use chrono::{DateTime, Datelike, FixedOffset, Local, Utc}; use comemo::Prehashed; use ecow::{eco_format, EcoString}; use once_cell::sync::Lazy; @@ -42,8 +42,9 @@ pub struct SystemWorld { /// Maps file ids to source files and buffers. slots: Mutex>, /// The current datetime if requested. This is stored here to ensure it is - /// always the same within one compilation. Reset between compilations. - now: OnceLock>, + /// always the same within one compilation. + /// Reset between compilations if not [`Now::Fixed`]. + now: Now, /// The export cache, used for caching output files in `typst watch` /// sessions. export_cache: ExportCache, @@ -104,6 +105,11 @@ impl SystemWorld { let mut searcher = FontSearcher::new(); searcher.search(&command.font_paths); + let now = match command.source_date_epoch { + Some(time) => Now::Fixed(time), + None => Now::System(OnceLock::new()), + }; + Ok(Self { workdir: std::env::current_dir().ok(), root, @@ -112,7 +118,7 @@ impl SystemWorld { book: Prehashed::new(searcher.book), fonts: searcher.fonts, slots: Mutex::new(HashMap::new()), - now: OnceLock::new(), + now, export_cache: ExportCache::new(), }) } @@ -146,7 +152,9 @@ impl SystemWorld { for slot in self.slots.get_mut().values_mut() { slot.reset(); } - self.now.take(); + if let Now::System(time_lock) = &mut self.now { + time_lock.take(); + } } /// Lookup a source file by id. @@ -187,17 +195,24 @@ impl World for SystemWorld { } fn today(&self, offset: Option) -> Option { - let now = self.now.get_or_init(chrono::Local::now); + let now = match &self.now { + Now::Fixed(time) => time, + Now::System(time) => time.get_or_init(Utc::now), + }; - let naive = match offset { - None => now.naive_local(), - Some(o) => now.naive_utc() + chrono::Duration::try_hours(o)?, + // The time with the specified UTC offset, or within the local time zone. + let with_offset = match offset { + None => now.with_timezone(&Local).fixed_offset(), + Some(hours) => { + let seconds = i32::try_from(hours).ok()?.checked_mul(3600)?; + now.with_timezone(&FixedOffset::east_opt(seconds)?) + } }; Datetime::from_ymd( - naive.year(), - naive.month().try_into().ok()?, - naive.day().try_into().ok()?, + with_offset.year(), + with_offset.month().try_into().ok()?, + with_offset.day().try_into().ok()?, ) } } @@ -384,6 +399,15 @@ fn decode_utf8(buf: &[u8]) -> FileResult<&str> { Ok(std::str::from_utf8(buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf))?) } +/// The current date and time. +enum Now { + /// The date and time if the environment `SOURCE_DATE_EPOCH` is set. + /// Used for reproducible builds. + Fixed(DateTime), + /// The current date and time if the time is not externally fixed. + System(OnceLock>), +} + /// An error that occurs during world construction. #[derive(Debug)] pub enum WorldCreationError {