From 72dc9ef10382fa4c59a25934a599d25953ad753a Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Wed, 15 Oct 2025 16:05:23 -0400 Subject: [PATCH 1/2] reproducible Signed-off-by: Jason Hall --- src/image/mod.rs | 54 ++++++++++++++++++++++++++++- tests/integration_test.rs | 71 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/image/mod.rs b/src/image/mod.rs index d6b0577..22aac2c 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -9,6 +9,19 @@ use std::io::Write; use tar::Builder; use tracing::{debug, info}; +/// Get the timestamp to use for reproducible builds. +/// Respects SOURCE_DATE_EPOCH environment variable if set. +fn get_build_timestamp() -> String { + if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") { + if let Ok(timestamp) = epoch.parse::() { + if let Some(dt) = chrono::DateTime::from_timestamp(timestamp, 0) { + return dt.to_rfc3339(); + } + } + } + chrono::Utc::now().to_rfc3339() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageConfig { pub architecture: String, @@ -220,7 +233,7 @@ impl ImageBuilder { // Combine history (base history + app history) let mut merged_history = base_config.history.clone(); merged_history.push(History { - created: chrono::Utc::now().to_rfc3339(), + created: get_build_timestamp(), created_by: "krust".to_string(), comment: "Built with krust".to_string(), empty_layer: false, @@ -424,4 +437,43 @@ mod tests { assert_eq!(parsed.rootfs.diff_ids.len(), 1); assert_eq!(parsed.history.len(), 0); // Default empty for missing field } + + #[test] + fn test_get_build_timestamp_respects_source_date_epoch() { + // Set SOURCE_DATE_EPOCH + std::env::set_var("SOURCE_DATE_EPOCH", "1609459200"); + + let timestamp = super::get_build_timestamp(); + + // Should be 2021-01-01T00:00:00+00:00 + assert!(timestamp.starts_with("2021-01-01T00:00:00")); + + // Clean up + std::env::remove_var("SOURCE_DATE_EPOCH"); + } + + #[test] + fn test_get_build_timestamp_without_source_date_epoch() { + // Make sure SOURCE_DATE_EPOCH is not set + std::env::remove_var("SOURCE_DATE_EPOCH"); + + let timestamp = super::get_build_timestamp(); + + // Should be a valid RFC3339 timestamp + assert!(chrono::DateTime::parse_from_rfc3339(×tamp).is_ok()); + } + + #[test] + fn test_get_build_timestamp_invalid_epoch() { + // Set invalid SOURCE_DATE_EPOCH + std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number"); + + let timestamp = super::get_build_timestamp(); + + // Should fall back to current time + assert!(chrono::DateTime::parse_from_rfc3339(×tamp).is_ok()); + + // Clean up + std::env::remove_var("SOURCE_DATE_EPOCH"); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 3665f35..1d83761 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -123,3 +123,74 @@ fn test_multi_arch_build_and_run() -> Result<()> { Ok(()) } + +#[test] +fn test_reproducible_builds() -> Result<()> { + // This test verifies that building the same project twice with SOURCE_DATE_EPOCH + // produces identical image digests + + // Get the example project directory + let example_dir = env::current_dir()?.join("example").join("hello-krust"); + + let native_platform = if cfg!(target_arch = "aarch64") { + "linux/arm64" + } else { + "linux/amd64" + }; + + // Fixed timestamp for reproducibility + let source_date_epoch = "1609459200"; // 2021-01-01 00:00:00 UTC + + // Build the first time (pushing to ttl.sh for temporary storage) + let mut cmd1 = Command::cargo_bin("krust")?; + let output1 = cmd1 + .arg("build") + .arg("--platform") + .arg(native_platform) + .arg(".") + .env("KRUST_REPO", "ttl.sh/krust-reproducible-test") + .env("SOURCE_DATE_EPOCH", source_date_epoch) + .current_dir(&example_dir) + .output()?; + + if !output1.status.success() { + let stderr = String::from_utf8_lossy(&output1.stderr); + panic!("First build failed: {}", stderr); + } + + let digest1 = String::from_utf8_lossy(&output1.stdout).trim().to_string(); + + // Build the second time with the same SOURCE_DATE_EPOCH + let mut cmd2 = Command::cargo_bin("krust")?; + let output2 = cmd2 + .arg("build") + .arg("--platform") + .arg(native_platform) + .arg(".") + .env("KRUST_REPO", "ttl.sh/krust-reproducible-test") + .env("SOURCE_DATE_EPOCH", source_date_epoch) + .current_dir(&example_dir) + .output()?; + + if !output2.status.success() { + let stderr = String::from_utf8_lossy(&output2.stderr); + panic!("Second build failed: {}", stderr); + } + + let digest2 = String::from_utf8_lossy(&output2.stdout).trim().to_string(); + + // The digests should be identical + assert_eq!( + digest1, digest2, + "Builds with same SOURCE_DATE_EPOCH should produce identical digests.\nFirst: {}\nSecond: {}", + digest1, digest2 + ); + + // Both should contain a valid digest + assert!( + digest1.contains("@sha256:"), + "Output should contain a digest" + ); + + Ok(()) +} From 5dd37b57356ede556ce6adf0374ae29b7dd7744e Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Wed, 15 Oct 2025 16:11:48 -0400 Subject: [PATCH 2/2] whoops Signed-off-by: Jason Hall --- src/image/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/image/mod.rs b/src/image/mod.rs index 0d58f77..2462be0 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -475,6 +475,9 @@ mod tests { // Clean up std::env::remove_var("SOURCE_DATE_EPOCH"); + } + + #[test] fn test_parse_platform_invalid_format() { let builder = ImageBuilder::new( PathBuf::from("/tmp/test"),