From f0516a78e571789d058759e1f80a56bcb8d20387 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Thu, 13 Aug 2026 10:59:03 -0400 Subject: [PATCH 1/4] wire source_date_epoch into rootfs and initramfs image builds The rootfs script passes `-T "${SOURCE_DATE_EPOCH:-0}"` to mkfs.erofs and the initramfs mtime pass reads the same variable, but nothing ever set it. The only place it gets exported is inside the extension image script's own body, so the `source_date_epoch` config key was honored for .raw extension images and silently ignored for every other image type. Set it from config in the container env at the three call sites that build images, mirroring inject_repo_tls_env. Left unset when the config key is absent rather than defaulting to 0 here: the scripts carry their own `:-0` fallback, and SOURCE_DATE_EPOCH is honored by unrelated tools that can run inside post_install hooks (gzip, tar, python bytecode), so exporting it unconditionally would change build behavior for projects that never opted in. Configuring nothing gives the same container env as before. --- src/commands/initramfs/image.rs | 2 ++ src/commands/rootfs/image.rs | 21 +++++++++++ src/commands/runtime/build.rs | 5 +++ src/utils/container.rs | 63 +++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/src/commands/initramfs/image.rs b/src/commands/initramfs/image.rs index 8c7b3e43..80048a45 100644 --- a/src/commands/initramfs/image.rs +++ b/src/commands/initramfs/image.rs @@ -311,6 +311,8 @@ export AVOCADO_OS_VERSION_ID if wrap_kab { env_vars.insert("KAB_KEYSET_FILE".to_string(), "/tmp/kab.keyset".to_string()); } + // Reproducibility stamp for the initramfs mtime-normalization pass. + crate::utils::container::inject_source_date_epoch(&mut env_vars, config.source_date_epoch); let container_args_with_keyset = if let Some(ref host_path) = kab_keyset_host_path { let mut args = merged_container_args.clone().unwrap_or_default(); diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs index bbe9f6a0..40ab9028 100644 --- a/src/commands/rootfs/image.rs +++ b/src/commands/rootfs/image.rs @@ -398,6 +398,8 @@ export AVOCADO_OS_VERSION_ID if wrap_kab { env_vars.insert("KAB_KEYSET_FILE".to_string(), "/tmp/kab.keyset".to_string()); } + // Reproducibility stamp for `mkfs.erofs -T` in the build script above. + crate::utils::container::inject_source_date_epoch(&mut env_vars, config.source_date_epoch); // Bind-mount the keyset into the container as a single -v arg // appended to whatever the user / config already has. @@ -517,6 +519,25 @@ export AVOCADO_OS_VERSION_ID mod tests { use super::*; + #[test] + fn test_rootfs_script_reads_source_date_epoch_from_the_env() { + // The other half of `inject_source_date_epoch`. Injection and + // consumption have to agree on the variable name, and a mismatch in + // either half fails silently — the script just falls back to 0 and the + // configured stamp is quietly ignored, which is the bug this pairing + // exists to fix. Pin the name on this side too. + let script = generate_rootfs_build_script( + "00000000-0000-0000-0000-000000000000", + "erofs-lz4", + None, + "", + ); + assert!( + script.contains(r#"-T "${SOURCE_DATE_EPOCH:-0}""#), + "mkfs.erofs must take its timestamp from $SOURCE_DATE_EPOCH" + ); + } + #[test] fn test_rootfs_script_guards_against_half_populated_sysroot() { // A stale or interrupted build volume can leave the sysroot with diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index f872f670..ea7a9826 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -534,6 +534,11 @@ impl RuntimeBuildCommand { env_vars.insert("AVOCADO_VERBOSE".to_string(), "1".to_string()); } + // Reproducibility stamp. This run executes the rootfs, initramfs and + // extension image sections in one container, so one insert covers all + // three. + crate::utils::container::inject_source_date_epoch(&mut env_vars, config.source_date_epoch); + if let Some(stone_paths) = config.get_stone_include_paths_for_runtime( &self.runtime_name, target_arch, diff --git a/src/utils/container.rs b/src/utils/container.rs index 70d28c06..96fa6043 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -513,6 +513,28 @@ pub fn inject_repo_tls_env(env_vars: &mut std::collections::HashMap, + source_date_epoch: Option, +) { + if let Some(epoch) = source_date_epoch { + env_vars.insert("SOURCE_DATE_EPOCH".to_string(), epoch.to_string()); + } +} + /// Configuration for running commands in containers #[derive(Debug, Clone)] pub struct RunConfig { @@ -3113,6 +3135,47 @@ async fn read_output_stream( mod tests { use super::*; + /// Regression: `source_date_epoch` was plumbed into extension images only. + /// The rootfs and initramfs scripts read `${SOURCE_DATE_EPOCH:-0}` but + /// nothing set the variable, so a project that configured it got a + /// reproducibility stamp on its `.raw` extensions and a silently ignored + /// key everywhere else. + mod source_date_epoch { + use super::*; + + #[test] + fn configured_value_reaches_the_container_env() { + let mut env_vars = std::collections::HashMap::new(); + inject_source_date_epoch(&mut env_vars, Some(1700000000)); + assert_eq!( + env_vars.get("SOURCE_DATE_EPOCH").map(String::as_str), + Some("1700000000") + ); + } + + #[test] + fn zero_is_a_real_value_not_an_absent_one() { + // `Some(0)` is an explicit opt-in to the epoch, distinct from unset. + let mut env_vars = std::collections::HashMap::new(); + inject_source_date_epoch(&mut env_vars, Some(0)); + assert_eq!( + env_vars.get("SOURCE_DATE_EPOCH").map(String::as_str), + Some("0") + ); + } + + #[test] + fn unset_config_leaves_the_var_absent() { + // Not defaulted to 0 on purpose: the build scripts carry their own + // `:-0` fallback, and SOURCE_DATE_EPOCH is honored by unrelated + // tools that may run in post_install hooks. Projects that never + // opted in must see the same container env as before. + let mut env_vars = std::collections::HashMap::new(); + inject_source_date_epoch(&mut env_vars, None); + assert!(!env_vars.contains_key("SOURCE_DATE_EPOCH")); + } + } + /// Wiring tests for container stdio. /// /// `utils::interactivity` tests the *decision*; these test that the From 39aa61dfabfe50c6cdf2fc49208ab771cbb19081 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Thu, 13 Aug 2026 11:21:46 -0400 Subject: [PATCH 2/4] pin the rootfs erofs UUID and --all-root in tests Neither was covered. Dropping -U randomizes the image UUID per build; dropping --all-root takes ownership from whoever ran the build instead of normalizing to root. Both silently reintroduce per-build variance. Counted rather than `contains`, here and for the existing -T assertion: both mkfs branches are emitted unconditionally, so a containment check still passes when one branch loses the flag. --- src/commands/rootfs/image.rs | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs index 40ab9028..be11da73 100644 --- a/src/commands/rootfs/image.rs +++ b/src/commands/rootfs/image.rs @@ -532,9 +532,36 @@ mod tests { None, "", ); - assert!( - script.contains(r#"-T "${SOURCE_DATE_EPOCH:-0}""#), - "mkfs.erofs must take its timestamp from $SOURCE_DATE_EPOCH" + // Counted, not just `contains`. Both mkfs branches (erofs-zst and + // erofs-lz4) are emitted unconditionally, so a `contains` check passes + // even if one branch loses the flag — asserting the count is what + // actually catches that. + assert_eq!( + script.matches(r#"-T "${SOURCE_DATE_EPOCH:-0}""#).count(), + 2, + "both mkfs.erofs branches must take their timestamp from $SOURCE_DATE_EPOCH" + ); + } + + /// The rest of the rootfs reproducibility contract. Nothing pinned these, + /// and each silently reintroduces per-build variance if dropped: the image + /// UUID would be randomized, and ownership would come from whoever ran the + /// build rather than being normalized to root. + #[test] + fn test_rootfs_image_reproducibility_flags_are_pinned() { + let script = generate_rootfs_build_script(NAMESPACE_UUID, "erofs-lz4", None, ""); + + assert_eq!( + script + .matches("-U 00000000-0000-0000-0000-000000000000") + .count(), + 2, + "both mkfs.erofs branches must pin the image UUID" + ); + assert_eq!( + script.matches("--all-root").count(), + 2, + "both mkfs.erofs branches must normalize ownership to root" ); } From 3cc5cfd4221b7217c434bc70ec815091669233ce Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Thu, 13 Aug 2026 11:33:49 -0400 Subject: [PATCH 3/4] correct the SOURCE_DATE_EPOCH comments about initramfs consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the initramfs script on this base has no reader for the variable — the mtime-normalization step that consumes it lands separately — so the comments claimed a consumer that isn't there. The injection stays (the epoch belongs in the env for every image-building run, not per-consumer) but is now labelled inert rather than described as feeding a pass that doesn't exist yet. --- src/commands/initramfs/image.rs | 5 ++++- src/utils/container.rs | 16 ++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/commands/initramfs/image.rs b/src/commands/initramfs/image.rs index 80048a45..526fe71d 100644 --- a/src/commands/initramfs/image.rs +++ b/src/commands/initramfs/image.rs @@ -311,7 +311,10 @@ export AVOCADO_OS_VERSION_ID if wrap_kab { env_vars.insert("KAB_KEYSET_FILE".to_string(), "/tmp/kab.keyset".to_string()); } - // Reproducibility stamp for the initramfs mtime-normalization pass. + // Reproducibility stamp. Inert for now — the generated initramfs script + // has no reader yet, since the mtime-normalization step that consumes + // the epoch lands separately. Set anyway so every image-building run + // carries the same env. crate::utils::container::inject_source_date_epoch(&mut env_vars, config.source_date_epoch); let container_args_with_keyset = if let Some(ref host_path) = kab_keyset_host_path { diff --git a/src/utils/container.rs b/src/utils/container.rs index 96fa6043..0360ebec 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -515,11 +515,15 @@ pub fn inject_repo_tls_env(env_vars: &mut std::collections::HashMap Date: Thu, 13 Aug 2026 21:33:11 -0400 Subject: [PATCH 4/4] review: wire post_build, pin the call sites, correct the drifted comment post_build ran with runtime_env_vars(), which carries only AVOCADO_RUNTIME, so a hook that builds an artifact got no epoch -- the same silent-ignore this PR exists to close, one container over. It now takes the epoch as a parameter and injects it into its own env, since the build run's env does not reach a separate container. Pinned the injection call sites in tests/source_date_epoch_wiring.rs. Both halves were already covered and their connection was not: deleting the call left all 1395 tests green. The first version of this guard lived in a `mod tests` inside the file it scanned, so the needle matched the assertion's own string literal and it passed with the real call deleted. Caught by running the mutation rather than trusting it. Moved out to tests/, where the needle cannot match itself, and confirmed it now fails on that deletion. Also from review: - The comment claiming this container "executes the rootfs, initramfs and extension image sections" was wrong -- it only copies pre-built ext artifacts, and ext/image.rs has no `:-0` fallback, so anyone trusting the comment and dropping that export gets an unset var. - Collapsed the 16-line doc on a 5-line function, and said outright that ext image's unconditional `unwrap_or(0)` makes the key behave two ways rather than leaving that contradiction between two doc comments. - The reproducibility test passed the zero UUID as namespace_uuid; it uses NAMESPACE_UUID now, like its neighbour. - CHANGELOG entry, including why the two-way behavior is deliberate. --- CHANGELOG.md | 14 +++++++++ src/commands/rootfs/image.rs | 7 +---- src/commands/runtime/build.rs | 20 ++++++++++--- src/utils/container.rs | 22 ++++++--------- tests/source_date_epoch_wiring.rs | 47 +++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 24 deletions(-) create mode 100644 tests/source_date_epoch_wiring.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 57007d78..7d7d8061 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 that collapse is what let an unparseable version fall through as a pass. ### Fixed +- **`source_date_epoch` is honored outside extension images.** The key was + read only by `ext image`, which exports it inside its own script. The rootfs + build script has always passed `-T "${SOURCE_DATE_EPOCH:-0}"` to + `mkfs.erofs`, but nothing ever set the variable, so a project that + configured an epoch got a reproducibility stamp on its `.raw` extensions and + a silently ignored key everywhere else — including in `post_build` hooks, + which run in their own container. Rootfs, initramfs and `post_build` runs + now all carry it. + + Note the key still behaves two ways by design: absent config leaves the + variable unset for rootfs/initramfs/`post_build`, because + `SOURCE_DATE_EPOCH` changes the behavior of tools well beyond ours (gzip, + tar, python bytecode) and hooks should not inherit that unasked. `ext image` + continues to export `0`. - **Rootfs and initramfs no longer reinstall on every run.** `avocado sdk install` wiped and rebuilt both sysroots from scratch on every invocation, even with nothing changed. Removal detection compared the lockfile against diff --git a/src/commands/rootfs/image.rs b/src/commands/rootfs/image.rs index be11da73..cb5f9e1c 100644 --- a/src/commands/rootfs/image.rs +++ b/src/commands/rootfs/image.rs @@ -526,12 +526,7 @@ mod tests { // either half fails silently — the script just falls back to 0 and the // configured stamp is quietly ignored, which is the bug this pairing // exists to fix. Pin the name on this side too. - let script = generate_rootfs_build_script( - "00000000-0000-0000-0000-000000000000", - "erofs-lz4", - None, - "", - ); + let script = generate_rootfs_build_script(NAMESPACE_UUID, "erofs-lz4", None, ""); // Counted, not just `contains`. Both mkfs branches (erofs-zst and // erofs-lz4) are emitted unconditionally, so a `contains` check passes // even if one branch loses the flag — asserting the count is what diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index ea7a9826..4112b1b2 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -534,9 +534,10 @@ impl RuntimeBuildCommand { env_vars.insert("AVOCADO_VERBOSE".to_string(), "1".to_string()); } - // Reproducibility stamp. This run executes the rootfs, initramfs and - // extension image sections in one container, so one insert covers all - // three. + // Reproducibility stamp. This run executes the rootfs and initramfs + // image sections, so one insert covers both. Extension images are NOT + // built here -- this run only copies their pre-built artifacts, and + // `ext image` exports the epoch inside its own script. crate::utils::container::inject_source_date_epoch(&mut env_vars, config.source_date_epoch); if let Some(stone_paths) = config.get_stone_include_paths_for_runtime( @@ -739,6 +740,7 @@ impl RuntimeBuildCommand { merged_container_args, container_helper, runs_on_context, + config.source_date_epoch, ) .await?; } @@ -2834,6 +2836,7 @@ rpm --root="$AVOCADO_EXT_SYSROOTS/{ext_name}" --dbpath=/var/lib/extension.d/rpm /// - `AVOCADO_RUNTIME_BUILD_DIR`: `/opt/_avocado//runtimes/` — /// the runtime build dir the script can inspect or post-process. #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] async fn run_post_build( &self, script_path: &str, @@ -2844,6 +2847,7 @@ rpm --root="$AVOCADO_EXT_SYSROOTS/{ext_name}" --dbpath=/var/lib/extension.d/rpm merged_container_args: &Option>, container_helper: &SdkContainer, runs_on_context: Option<&RunsOnContext>, + source_date_epoch: Option, ) -> Result<()> { print_info( &format!( @@ -2877,7 +2881,15 @@ rpm --root="$AVOCADO_EXT_SYSROOTS/{ext_name}" --dbpath=/var/lib/extension.d/rpm dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), tui_context: self.tui_context.clone(), - env_vars: self.runtime_env_vars(), + env_vars: { + // post_build runs in its own container, so it needs its own + // injection -- the build run's env does not reach it. A hook + // that produces an artifact gets the same stamp the image + // steps do, rather than silently building against wall clock. + let mut env = self.runtime_env_vars().unwrap_or_default(); + crate::utils::container::inject_source_date_epoch(&mut env, source_date_epoch); + Some(env) + }, ..Default::default() }; diff --git a/src/utils/container.rs b/src/utils/container.rs index 0360ebec..c3447738 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -515,21 +515,15 @@ pub fn inject_repo_tls_env(env_vars: &mut std::collections::HashMap, source_date_epoch: Option, diff --git a/tests/source_date_epoch_wiring.rs b/tests/source_date_epoch_wiring.rs new file mode 100644 index 00000000..a6e43ced --- /dev/null +++ b/tests/source_date_epoch_wiring.rs @@ -0,0 +1,47 @@ +//! Guard: the image runs actually inject `SOURCE_DATE_EPOCH`. +//! +//! Both halves of this feature are unit-tested — `inject_source_date_epoch`'s +//! three-way `Option` behavior in `utils::container`, and the rootfs script's +//! `mkfs.erofs -T "${SOURCE_DATE_EPOCH:-0}"` in `rootfs::image` — but the call +//! that connects them was covered by neither. Deleting it leaves the whole +//! suite green while a configured epoch silently stops reaching the container, +//! which is the exact regression the feature exists to prevent. +//! +//! The check lives here rather than in a `mod tests` inside those files +//! because the needle would then appear in the file it scans, and the +//! assertion would hold with the real call site deleted. (Confirmed the hard +//! way.) Same reason `no_hand_rolled_stdio_flags.rs` sits out here. +//! +//! ponytail: pins the call's spelling, not its effect. Testing the effect +//! means lifting env-map construction out of the async run path in both +//! commands; worth doing when a third image type wants the same stamp. + +use std::fs; +use std::path::PathBuf; + +/// The call every image-building run has to make. +const INJECTION: &str = "inject_source_date_epoch(&mut env_vars, config.source_date_epoch)"; + +fn source(relative: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative); + fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {}: {e}", path.display())) +} + +#[test] +fn rootfs_image_run_injects_source_date_epoch() { + assert!( + source("src/commands/rootfs/image.rs").contains(INJECTION), + "the rootfs image run must inject SOURCE_DATE_EPOCH into its container env" + ); +} + +#[test] +fn initramfs_image_run_injects_source_date_epoch() { + // Inert on this base — the initramfs script has no reader until the + // mtime-normalization pass lands — so a deletion here would be entirely + // invisible without this. + assert!( + source("src/commands/initramfs/image.rs").contains(INJECTION), + "the initramfs image run must inject SOURCE_DATE_EPOCH into its container env" + ); +}