diff --git a/Cargo.lock b/Cargo.lock index 4cd432591..ccc70bc99 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 9f5be3400..46e22fcfd 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 000000000..6c4c219d7 --- /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 4ba138413..aa41e9cfd 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 876b2e9f0..544415e0f 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(()) + } }