From 696fc4483a2413ba8bbb290f57f19f6d2e2b96da Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Mon, 31 Aug 2026 15:49:37 +0530 Subject: [PATCH] lint: Check required runtime dependencies Container images can pass linting while omitting binaries that bootc needs after deployment, leaving failures to be discovered during switch or upgrade. Source the existing workspace dependency metadata and report missing binaries while preserving the external container-tool override. Closes: https://github.com/bootc-dev/bootc/issues/2420 Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- Cargo.lock | 28 ++++++++++++- crates/lib/Cargo.toml | 5 ++- crates/lib/build.rs | 18 ++++++++ crates/lib/src/lints.rs | 91 +++++++++++++++++++++++++++++++++++++++++ crates/lib/src/utils.rs | 42 +++++++++++++++++-- 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 crates/lib/build.rs diff --git a/Cargo.lock b/Cargo.lock index 4cd4325913..ccc70bc997 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,7 @@ dependencies = [ "camino", "canon-json", "cap-std-ext", + "cargo_metadata 0.19.2", "cfg-if", "chrono", "clap", @@ -540,6 +541,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + [[package]] name = "cargo-platform" version = "0.3.3" @@ -550,6 +560,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror 2.0.19", +] + [[package]] name = "cargo_metadata" version = "0.23.1" @@ -557,7 +581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", @@ -4166,7 +4190,7 @@ dependencies = [ "anstream", "anyhow", "camino", - "cargo_metadata", + "cargo_metadata 0.23.1", "chrono", "clap", "fn-error-context", diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index 9f5be3400b..46e22fcfd8 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -10,7 +10,7 @@ version = "1.16.10" # In general we try to keep this pinned to what's in the latest RHEL9. rust-version = "1.85.0" -include = ["/src", "LICENSE-APACHE", "LICENSE-MIT"] +include = ["/src", "/build.rs", "LICENSE-APACHE", "LICENSE-MIT"] [dependencies] # Internal crates @@ -92,5 +92,8 @@ rhsm = [] # Implementation detail of man page generation. docgen = ["clap_mangen"] +[build-dependencies] +cargo_metadata = "0.19" + [lints] workspace = true diff --git a/crates/lib/build.rs b/crates/lib/build.rs new file mode 100644 index 0000000000..6c4c219d7c --- /dev/null +++ b/crates/lib/build.rs @@ -0,0 +1,18 @@ +fn main() { + let metadata = cargo_metadata::MetadataCommand::new() + .current_dir(env!("CARGO_MANIFEST_DIR")) + .no_deps() + .exec() + .expect("running cargo metadata"); + let workspace_manifest = metadata.workspace_root.join("Cargo.toml"); + println!("cargo::rerun-if-changed={workspace_manifest}"); + + let bins = metadata.workspace_metadata["binary-dependencies"]["bins"] + .as_array() + .expect("workspace.metadata.binary-dependencies.bins must be an array"); + let bins: Vec<&str> = bins + .iter() + .map(|bin| bin.as_str().expect("binary dependency must be a string")) + .collect(); + println!("cargo::rustc-env=BOOTC_BINARY_DEPS={}", bins.join(",")); +} diff --git a/crates/lib/src/lints.rs b/crates/lib/src/lints.rs index 4ba138413f..aa41e9cfd4 100644 --- a/crates/lib/src/lints.rs +++ b/crates/lib/src/lints.rs @@ -895,6 +895,55 @@ fn prune_known_run_paths(paths: &mut BTreeSet) { } } +/// List sourced from [workspace.metadata.binary-dependencies] in the workspace +/// Cargo.toml via build.rs. +const BINARY_DEPS: &str = env!("BOOTC_BINARY_DEPS"); + +/// Return the resolved set of required runtime binaries. +/// +/// Entries that match the default podman/skopeo names are replaced with the +/// values from `bootc_utils::{podman,skopeo}_bin()` so that +/// `BOOTC_EXP_EXTERNAL_CONTAINER_TOOL` overrides are honoured. +fn resolve_runtime_bins<'a>(podman: &'a str, skopeo: &'a str) -> Vec<&'a str> { + let mut bins: Vec<&str> = BINARY_DEPS + .split(',') + .map(|b| match b { + "podman" => podman, + "skopeo" => skopeo, + other => other, + }) + .collect(); + bins.sort_unstable(); + bins.dedup(); + bins +} + +fn resolved_runtime_bins() -> Vec<&'static str> { + resolve_runtime_bins(bootc_utils::podman_bin(), bootc_utils::skopeo_bin()) +} + +#[distributed_slice(LINTS)] +static LINT_RUNTIME_DEPS: Lint = Lint::new_warning( + "runtime-deps", + "Check that required runtime dependencies are present in the image.", + check_runtime_deps, +); +fn check_runtime_deps(root: &Dir, _config: &LintExecutionConfig) -> LintResult { + let mut missing = Vec::new(); + for bin in resolved_runtime_bins() { + if !crate::utils::have_executable_in_root(root, bin)? { + missing.push(bin); + } + } + if missing.is_empty() { + return lint_ok(); + } + lint_err(format!( + "Missing required runtime dependencies: {}", + missing.join(", ") + )) +} + #[cfg(test)] mod tests { use std::sync::LazyLock; @@ -933,9 +982,19 @@ mod tests { root.create_dir_all(Utf8Path::new(PREPAREROOT_PATH).parent().unwrap())?; root.atomic_write(PREPAREROOT_PATH, PREPAREROOT)?; + for bin in resolved_runtime_bins() { + add_runtime_bin(&root, bin)?; + } + Ok(root) } + fn add_runtime_bin(root: &Dir, bin: &str) -> Result<()> { + root.create_dir_all("usr/bin")?; + root.write(format!("usr/bin/{bin}"), "")?; + Ok(()) + } + #[test] fn test_var_run() -> Result<()> { let root = &fixture()?; @@ -1460,4 +1519,36 @@ mod tests { Ok(()) } + + #[test] + fn test_runtime_deps() -> Result<()> { + let root = &fixture()?; + let config = &LintExecutionConfig::default(); + + let bins = resolved_runtime_bins(); + let err = check_runtime_deps(root, config)?.unwrap_err(); + assert_eq!( + err.to_string(), + format!("Missing required runtime dependencies: {}", bins.join(", ")) + ); + + for bin in &bins { + add_runtime_bin(root, bin)?; + } + check_runtime_deps(root, config)??; + + let podman = bootc_utils::podman_bin(); + root.remove_file(format!("usr/bin/{podman}"))?; + let err = check_runtime_deps(root, config)?.unwrap_err(); + assert_eq!( + err.to_string(), + format!("Missing required runtime dependencies: {podman}") + ); + + assert_eq!( + resolve_runtime_bins("dtool", "dtool"), + ["chcon", "dtool", "ostree", "setpriv", "systemctl", "zstd"] + ); + Ok(()) + } } diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 876b2e9f03..544415e0f0 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -68,12 +68,32 @@ pub(crate) fn find_mount_option<'a>( } pub fn have_executable(name: &str) -> Result { + for elt in executable_candidates(name) { + if elt.try_exists()? { + return Ok(true); + } + } + Ok(false) +} + +fn executable_candidates(name: &str) -> Vec { let Some(path) = std::env::var_os("PATH") else { - return Ok(false); + return Vec::new(); }; - for mut elt in std::env::split_paths(&path) { - elt.push(name); - if elt.try_exists()? { + std::env::split_paths(&path) + .map(|mut path| { + path.push(name); + path + }) + .collect() +} + +/// Like [`have_executable`], but checks inside a filesystem root (e.g. a +/// container image) rather than the host's `$PATH`. +pub fn have_executable_in_root(root: &Dir, name: &str) -> Result { + for candidate in executable_candidates(name) { + let candidate = candidate.strip_prefix("/").unwrap_or(&candidate); + if root.try_exists(candidate)? { return Ok(true); } } @@ -336,4 +356,18 @@ mod tests { assert!(have_executable("true").unwrap()); assert!(!have_executable("someexethatdoesnotexist").unwrap()); } + + #[test] + fn test_have_executable_in_root() -> anyhow::Result<()> { + use cap_std_ext::cap_std; + + let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?; + assert!(!have_executable_in_root(&root, "podman")?); + + root.create_dir_all("usr/bin")?; + root.write("usr/bin/podman", "")?; + assert!(have_executable_in_root(&root, "podman")?); + assert!(!have_executable_in_root(&root, "missing")?); + Ok(()) + } }