Read SOURCE_DATE_EPOCH for better reproducibility (#3809)

This commit is contained in:
frozolotl 2024-04-03 12:34:17 +02:00 committed by GitHub
parent 0619ae98a8
commit d4b3ae0925
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 65 additions and 22 deletions

View File

@ -62,6 +62,7 @@ zip = { workspace = true, optional = true }
openssl = { workspace = true } openssl = { workspace = true }
[build-dependencies] [build-dependencies]
chrono = { workspace = true }
clap = { workspace = true, features = ["string"] } clap = { workspace = true, features = ["string"] }
clap_complete = { workspace = true } clap_complete = { workspace = true }
clap_mangen = { workspace = true } clap_mangen = { workspace = true }

View File

@ -1,6 +1,7 @@
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::path::PathBuf; use std::path::PathBuf;
use chrono::{DateTime, Utc};
use clap::builder::ValueParser; use clap::builder::ValueParser;
use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum}; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum};
use semver::Version; use semver::Version;
@ -167,6 +168,12 @@ pub struct SharedArgs {
)] )]
pub font_paths: Vec<PathBuf>, pub font_paths: Vec<PathBuf>,
/// The document's creation date formatted as a UNIX timestamp.
///
/// For more information, see <https://reproducible-builds.org/specs/source-date-epoch/>.
#[clap(env = "SOURCE_DATE_EPOCH", value_parser = parse_source_date_epoch)]
pub source_date_epoch: Option<DateTime<Utc>>,
/// The format to emit diagnostics in /// The format to emit diagnostics in
#[clap( #[clap(
long, long,
@ -176,6 +183,15 @@ pub struct SharedArgs {
pub diagnostic_format: DiagnosticFormat, pub diagnostic_format: DiagnosticFormat,
} }
/// Parses a UNIX timestamp according to <https://reproducible-builds.org/specs/source-date-epoch/>
fn parse_source_date_epoch(raw: &str) -> Result<DateTime<Utc>, 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. /// An input that is either stdin or a real path.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Input { pub enum Input {

View File

@ -166,7 +166,10 @@ fn export(
/// Export to a PDF. /// Export to a PDF.
fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> { 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 command
.output() .output()
.write(&buffer) .write(&buffer)
@ -174,16 +177,15 @@ fn export_pdf(document: &Document, command: &CompileCommand) -> StrResult<()> {
Ok(()) Ok(())
} }
/// Get the current date and time in UTC. /// Convert [`chrono::DateTime`] to [`Datetime`]
fn now() -> Option<Datetime> { fn convert_datetime(date_time: chrono::DateTime<chrono::Utc>) -> Option<Datetime> {
let now = chrono::Local::now().naive_utc();
Datetime::from_ymd_hms( Datetime::from_ymd_hms(
now.year(), date_time.year(),
now.month().try_into().ok()?, date_time.month().try_into().ok()?,
now.day().try_into().ok()?, date_time.day().try_into().ok()?,
now.hour().try_into().ok()?, date_time.hour().try_into().ok()?,
now.minute().try_into().ok()?, date_time.minute().try_into().ok()?,
now.second().try_into().ok()?, date_time.second().try_into().ok()?,
) )
} }

View File

@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::{fmt, fs, io, mem}; use std::{fmt, fs, io, mem};
use chrono::{DateTime, Datelike, Local}; use chrono::{DateTime, Datelike, FixedOffset, Local, Utc};
use comemo::Prehashed; use comemo::Prehashed;
use ecow::{eco_format, EcoString}; use ecow::{eco_format, EcoString};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
@ -42,8 +42,9 @@ pub struct SystemWorld {
/// Maps file ids to source files and buffers. /// Maps file ids to source files and buffers.
slots: Mutex<HashMap<FileId, FileSlot>>, slots: Mutex<HashMap<FileId, FileSlot>>,
/// The current datetime if requested. This is stored here to ensure it is /// The current datetime if requested. This is stored here to ensure it is
/// always the same within one compilation. Reset between compilations. /// always the same within one compilation.
now: OnceLock<DateTime<Local>>, /// Reset between compilations if not [`Now::Fixed`].
now: Now,
/// The export cache, used for caching output files in `typst watch` /// The export cache, used for caching output files in `typst watch`
/// sessions. /// sessions.
export_cache: ExportCache, export_cache: ExportCache,
@ -104,6 +105,11 @@ impl SystemWorld {
let mut searcher = FontSearcher::new(); let mut searcher = FontSearcher::new();
searcher.search(&command.font_paths); searcher.search(&command.font_paths);
let now = match command.source_date_epoch {
Some(time) => Now::Fixed(time),
None => Now::System(OnceLock::new()),
};
Ok(Self { Ok(Self {
workdir: std::env::current_dir().ok(), workdir: std::env::current_dir().ok(),
root, root,
@ -112,7 +118,7 @@ impl SystemWorld {
book: Prehashed::new(searcher.book), book: Prehashed::new(searcher.book),
fonts: searcher.fonts, fonts: searcher.fonts,
slots: Mutex::new(HashMap::new()), slots: Mutex::new(HashMap::new()),
now: OnceLock::new(), now,
export_cache: ExportCache::new(), export_cache: ExportCache::new(),
}) })
} }
@ -146,7 +152,9 @@ impl SystemWorld {
for slot in self.slots.get_mut().values_mut() { for slot in self.slots.get_mut().values_mut() {
slot.reset(); slot.reset();
} }
self.now.take(); if let Now::System(time_lock) = &mut self.now {
time_lock.take();
}
} }
/// Lookup a source file by id. /// Lookup a source file by id.
@ -187,17 +195,24 @@ impl World for SystemWorld {
} }
fn today(&self, offset: Option<i64>) -> Option<Datetime> { fn today(&self, offset: Option<i64>) -> Option<Datetime> {
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 { // The time with the specified UTC offset, or within the local time zone.
None => now.naive_local(), let with_offset = match offset {
Some(o) => now.naive_utc() + chrono::Duration::try_hours(o)?, 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( Datetime::from_ymd(
naive.year(), with_offset.year(),
naive.month().try_into().ok()?, with_offset.month().try_into().ok()?,
naive.day().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))?) 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<Utc>),
/// The current date and time if the time is not externally fixed.
System(OnceLock<DateTime<Utc>>),
}
/// An error that occurs during world construction. /// An error that occurs during world construction.
#[derive(Debug)] #[derive(Debug)]
pub enum WorldCreationError { pub enum WorldCreationError {