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/initramfs/image.rs b/src/commands/initramfs/image.rs index 8c7b3e43..526fe71d 100644 --- a/src/commands/initramfs/image.rs +++ b/src/commands/initramfs/image.rs @@ -311,6 +311,11 @@ export AVOCADO_OS_VERSION_ID if wrap_kab { env_vars.insert("KAB_KEYSET_FILE".to_string(), "/tmp/kab.keyset".to_string()); } + // 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 { 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..cb5f9e1c 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,47 @@ 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(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 + // 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" + ); + } + #[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..4112b1b2 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -534,6 +534,12 @@ impl RuntimeBuildCommand { env_vars.insert("AVOCADO_VERBOSE".to_string(), "1".to_string()); } + // 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( &self.runtime_name, target_arch, @@ -734,6 +740,7 @@ impl RuntimeBuildCommand { merged_container_args, container_helper, runs_on_context, + config.source_date_epoch, ) .await?; } @@ -2829,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, @@ -2839,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!( @@ -2872,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 70d28c06..c3447738 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -513,6 +513,26 @@ 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 +3133,47 @@ async fn read_output_stream( mod tests { use super::*; + /// Regression: `source_date_epoch` was plumbed into extension images only. + /// The rootfs script reads `${SOURCE_DATE_EPOCH:-0}` for `mkfs.erofs -T` 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 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" + ); +}