|
| 1 | +//! Build system validation checks. |
| 2 | +
|
| 3 | +use std::collections::BTreeMap; |
| 4 | + |
| 5 | +use anyhow::{Context, Result}; |
| 6 | +use camino::{Utf8Path, Utf8PathBuf}; |
| 7 | +use fn_error_context::context; |
| 8 | +use xshell::{cmd, Shell}; |
| 9 | + |
| 10 | +const DOCKERFILE_NETWORK_CUTOFF: &str = "external dependency cutoff point"; |
| 11 | + |
| 12 | +/// Check build system properties |
| 13 | +/// |
| 14 | +/// - Reproducible builds for the RPM |
| 15 | +/// - Dockerfile network isolation after cutoff point |
| 16 | +#[context("Checking build system")] |
| 17 | +pub fn check_buildsys(sh: &Shell, dockerfile_path: &Utf8Path) -> Result<()> { |
| 18 | + check_package_reproducibility(sh)?; |
| 19 | + check_dockerfile_network_isolation(dockerfile_path)?; |
| 20 | + Ok(()) |
| 21 | +} |
| 22 | + |
| 23 | +/// Verify that consecutive `just package` invocations produce identical RPM checksums. |
| 24 | +#[context("Checking package reproducibility")] |
| 25 | +fn check_package_reproducibility(sh: &Shell) -> Result<()> { |
| 26 | + println!("Checking reproducible builds..."); |
| 27 | + // Helper to compute SHA256 of bootc RPMs in target/packages/ |
| 28 | + fn get_rpm_checksums(sh: &Shell) -> Result<BTreeMap<String, String>> { |
| 29 | + // Find bootc*.rpm files in target/packages/ |
| 30 | + let packages_dir = Utf8Path::new("target/packages"); |
| 31 | + let mut rpm_files: Vec<Utf8PathBuf> = Vec::new(); |
| 32 | + for entry in std::fs::read_dir(packages_dir).context("Reading target/packages")? { |
| 33 | + let entry = entry?; |
| 34 | + let path = Utf8PathBuf::try_from(entry.path())?; |
| 35 | + if path.extension() == Some("rpm") { |
| 36 | + rpm_files.push(path); |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + assert!(!rpm_files.is_empty()); |
| 41 | + |
| 42 | + let mut checksums = BTreeMap::new(); |
| 43 | + for rpm_path in &rpm_files { |
| 44 | + let output = cmd!(sh, "sha256sum {rpm_path}").read()?; |
| 45 | + let (hash, filename) = output |
| 46 | + .split_once(" ") |
| 47 | + .with_context(|| format!("failed to parse sha256sum output: '{}'", output))?; |
| 48 | + checksums.insert(filename.to_owned(), hash.to_owned()); |
| 49 | + } |
| 50 | + Ok(checksums) |
| 51 | + } |
| 52 | + |
| 53 | + cmd!(sh, "just package").run()?; |
| 54 | + let first_checksums = get_rpm_checksums(sh)?; |
| 55 | + cmd!(sh, "just package").run()?; |
| 56 | + let second_checksums = get_rpm_checksums(sh)?; |
| 57 | + |
| 58 | + itertools::assert_equal(first_checksums, second_checksums); |
| 59 | + println!("ok package reproducibility"); |
| 60 | + |
| 61 | + Ok(()) |
| 62 | +} |
| 63 | + |
| 64 | +/// Verify that all RUN instructions in the Dockerfile after the network cutoff |
| 65 | +/// point include `--network=none`. |
| 66 | +#[context("Checking Dockerfile network isolation")] |
| 67 | +fn check_dockerfile_network_isolation(dockerfile_path: &Utf8Path) -> Result<()> { |
| 68 | + println!("Checking Dockerfile network isolation..."); |
| 69 | + let dockerfile = std::fs::read_to_string(dockerfile_path).context("Reading Dockerfile")?; |
| 70 | + verify_dockerfile_network_isolation(&dockerfile)?; |
| 71 | + println!("ok Dockerfile network isolation"); |
| 72 | + Ok(()) |
| 73 | +} |
| 74 | + |
| 75 | +const RUN_NETWORK_NONE: &str = "RUN --network=none"; |
| 76 | + |
| 77 | +/// Verify that all RUN instructions after the network cutoff marker start with |
| 78 | +/// `RUN --network=none`. |
| 79 | +/// |
| 80 | +/// Returns Ok(()) if all RUN instructions comply, or an error listing violations. |
| 81 | +pub fn verify_dockerfile_network_isolation(dockerfile: &str) -> Result<()> { |
| 82 | + // Find the cutoff point |
| 83 | + let cutoff_line = dockerfile |
| 84 | + .lines() |
| 85 | + .position(|line| line.contains(DOCKERFILE_NETWORK_CUTOFF)) |
| 86 | + .ok_or_else(|| { |
| 87 | + anyhow::anyhow!( |
| 88 | + "Dockerfile missing '{}' marker comment", |
| 89 | + DOCKERFILE_NETWORK_CUTOFF |
| 90 | + ) |
| 91 | + })?; |
| 92 | + |
| 93 | + // Check all RUN instructions after the cutoff point |
| 94 | + let mut errors = Vec::new(); |
| 95 | + |
| 96 | + for (idx, line) in dockerfile.lines().enumerate().skip(cutoff_line + 1) { |
| 97 | + let line_num = idx + 1; // 1-based line numbers |
| 98 | + let trimmed = line.trim(); |
| 99 | + |
| 100 | + // Check if this is a RUN instruction |
| 101 | + if trimmed.starts_with("RUN ") { |
| 102 | + // Must start with exactly "RUN --network=none" |
| 103 | + if !trimmed.starts_with(RUN_NETWORK_NONE) { |
| 104 | + errors.push(format!( |
| 105 | + " line {}: RUN instruction must start with `{}`", |
| 106 | + line_num, RUN_NETWORK_NONE |
| 107 | + )); |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + if !errors.is_empty() { |
| 113 | + anyhow::bail!( |
| 114 | + "Dockerfile has RUN instructions after '{}' that don't start with `{}`:\n{}", |
| 115 | + DOCKERFILE_NETWORK_CUTOFF, |
| 116 | + RUN_NETWORK_NONE, |
| 117 | + errors.join("\n") |
| 118 | + ); |
| 119 | + } |
| 120 | + |
| 121 | + Ok(()) |
| 122 | +} |
| 123 | + |
| 124 | +#[cfg(test)] |
| 125 | +mod tests { |
| 126 | + use super::*; |
| 127 | + |
| 128 | + #[test] |
| 129 | + fn test_network_isolation_valid() { |
| 130 | + let dockerfile = r#" |
| 131 | +FROM base |
| 132 | +RUN echo "before cutoff, no network restriction needed" |
| 133 | +# external dependency cutoff point |
| 134 | +RUN --network=none echo "good" |
| 135 | +RUN --network=none --mount=type=bind,from=foo,target=/bar some-command |
| 136 | +"#; |
| 137 | + verify_dockerfile_network_isolation(dockerfile).unwrap(); |
| 138 | + } |
| 139 | + |
| 140 | + #[test] |
| 141 | + fn test_network_isolation_missing_flag() { |
| 142 | + let dockerfile = r#" |
| 143 | +FROM base |
| 144 | +# external dependency cutoff point |
| 145 | +RUN --network=none echo "good" |
| 146 | +RUN echo "bad - missing network flag" |
| 147 | +"#; |
| 148 | + let err = verify_dockerfile_network_isolation(dockerfile).unwrap_err(); |
| 149 | + let msg = err.to_string(); |
| 150 | + assert!(msg.contains("line 5"), "error should mention line 5: {msg}"); |
| 151 | + } |
| 152 | + |
| 153 | + #[test] |
| 154 | + fn test_network_isolation_wrong_position() { |
| 155 | + // --network=none must come immediately after RUN |
| 156 | + let dockerfile = r#" |
| 157 | +FROM base |
| 158 | +# external dependency cutoff point |
| 159 | +RUN --mount=type=bind,from=foo,target=/bar --network=none echo "bad" |
| 160 | +"#; |
| 161 | + let err = verify_dockerfile_network_isolation(dockerfile).unwrap_err(); |
| 162 | + let msg = err.to_string(); |
| 163 | + assert!(msg.contains("line 4"), "error should mention line 4: {msg}"); |
| 164 | + } |
| 165 | +} |
0 commit comments