Skip to main content

bootc_lib/
lints.rs

1//! # Implementation of container build lints
2//!
3//! This module implements `bootc container lint`.
4
5// Unfortunately needed here to work with linkme
6#![allow(unsafe_code)]
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::env::consts::ARCH;
10use std::fmt::{Display, Write as WriteFmt};
11use std::num::NonZeroUsize;
12use std::ops::ControlFlow;
13use std::os::unix::ffi::OsStrExt;
14use std::path::Path;
15
16use anyhow::Result;
17use bootc_utils::PathQuotedDisplay;
18use camino::{Utf8Path, Utf8PathBuf};
19use cap_std::fs::Dir;
20use cap_std_ext::cap_std;
21use cap_std_ext::cap_std::fs::MetadataExt;
22use cap_std_ext::dirext::WalkConfiguration;
23use cap_std_ext::dirext::{CapStdExtDirExt as _, WalkComponent};
24use fn_error_context::context;
25use indoc::indoc;
26use linkme::distributed_slice;
27use ostree_ext::ostree_prepareroot;
28use serde::Serialize;
29
30use crate::bootc_composefs::boot::EFI_LINUX;
31use crate::bootc_composefs::image::is_composefs_native;
32
33/// Create a default WalkConfiguration with noxdev enabled.
34///
35/// This ensures we skip directory mount points when walking,
36/// which is important to avoid descending into bind mounts, tmpfs, etc.
37/// Note that non-directory mount points (e.g. bind-mounted regular files)
38/// will still be visited.
39fn walk_configuration() -> WalkConfiguration<'static> {
40    WalkConfiguration::default().noxdev()
41}
42
43/// Reference to embedded default baseimage content that should exist.
44const BASEIMAGE_REF: &str = "usr/share/doc/bootc/baseimage/base";
45// https://systemd.io/API_FILE_SYSTEMS/ with /var added for us
46const API_DIRS: &[&str] = &["dev", "proc", "sys", "run", "tmp", "var"];
47
48/// Only output this many items by default
49const DEFAULT_TRUNCATED_OUTPUT: NonZeroUsize = const { NonZeroUsize::new(5).unwrap() };
50
51/// A lint check has failed.
52#[derive(thiserror::Error, Debug)]
53struct LintError(String);
54
55/// The outer error is for unexpected fatal runtime problems; the
56/// inner error is for the lint failing in an expected way.
57type LintResult = Result<std::result::Result<(), LintError>>;
58
59/// Everything is OK - we didn't encounter a runtime error, and
60/// the targeted check passed.
61fn lint_ok() -> LintResult {
62    Ok(Ok(()))
63}
64
65/// We successfully found a lint failure.
66fn lint_err(msg: impl AsRef<str>) -> LintResult {
67    Ok(Err(LintError::new(msg)))
68}
69
70impl std::fmt::Display for LintError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(&self.0)
73    }
74}
75
76impl LintError {
77    fn new(msg: impl AsRef<str>) -> Self {
78        Self(msg.as_ref().to_owned())
79    }
80}
81
82#[derive(Debug, Default)]
83struct LintExecutionConfig {
84    no_truncate: bool,
85}
86
87type LintFn = fn(&Dir, config: &LintExecutionConfig) -> LintResult;
88type LintRecursiveResult = LintResult;
89type LintRecursiveFn = fn(&WalkComponent, config: &LintExecutionConfig) -> LintRecursiveResult;
90/// A lint can either operate as it pleases on a target root, or it
91/// can be recursive.
92#[derive(Debug)]
93enum LintFnTy {
94    /// A lint that doesn't traverse the whole filesystem
95    Regular(LintFn),
96    /// A recursive lint
97    Recursive(LintRecursiveFn),
98}
99#[distributed_slice]
100pub(crate) static LINTS: [Lint];
101
102/// The classification of a lint type.
103#[derive(Debug, Serialize)]
104#[serde(rename_all = "kebab-case")]
105enum LintType {
106    /// If this fails, it is known to be fatal - the system will not install or
107    /// is effectively guaranteed to fail at runtime.
108    Fatal,
109    /// This is not a fatal problem, but something you likely want to fix.
110    Warning,
111}
112
113#[derive(Debug, Copy, Clone)]
114pub(crate) enum WarningDisposition {
115    AllowWarnings,
116    FatalWarnings,
117}
118
119#[derive(Debug, Copy, Clone, Serialize, PartialEq, Eq)]
120pub(crate) enum RootType {
121    Running,
122    Alternative,
123}
124
125#[derive(Debug, Serialize)]
126#[serde(rename_all = "kebab-case")]
127struct Lint {
128    name: &'static str,
129    #[serde(rename = "type")]
130    ty: LintType,
131    #[serde(skip)]
132    f: LintFnTy,
133    description: &'static str,
134    // Set if this only applies to a specific root type.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    root_type: Option<RootType>,
137}
138
139// We require lint names to be unique, so we can just compare based on those.
140impl PartialEq for Lint {
141    fn eq(&self, other: &Self) -> bool {
142        self.name == other.name
143    }
144}
145impl Eq for Lint {}
146
147impl std::hash::Hash for Lint {
148    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
149        self.name.hash(state);
150    }
151}
152
153impl PartialOrd for Lint {
154    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
155        Some(self.cmp(other))
156    }
157}
158impl Ord for Lint {
159    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
160        self.name.cmp(other.name)
161    }
162}
163
164impl Lint {
165    pub(crate) const fn new_fatal(
166        name: &'static str,
167        description: &'static str,
168        f: LintFn,
169    ) -> Self {
170        Lint {
171            name,
172            ty: LintType::Fatal,
173            f: LintFnTy::Regular(f),
174            description,
175            root_type: None,
176        }
177    }
178
179    pub(crate) const fn new_warning(
180        name: &'static str,
181        description: &'static str,
182        f: LintFn,
183    ) -> Self {
184        Lint {
185            name,
186            ty: LintType::Warning,
187            f: LintFnTy::Regular(f),
188            description,
189            root_type: None,
190        }
191    }
192
193    const fn set_root_type(mut self, v: RootType) -> Self {
194        self.root_type = Some(v);
195        self
196    }
197}
198
199pub(crate) fn lint_list(output: impl std::io::Write) -> Result<()> {
200    // Dump in yaml format by default, it's readable enough
201    serde_yaml::to_writer(output, &*LINTS)?;
202    Ok(())
203}
204
205#[derive(Debug)]
206struct LintExecutionResult {
207    warnings: usize,
208    passed: usize,
209    skipped: usize,
210    fatal: usize,
211}
212
213// Helper function to format items with optional truncation
214fn format_items<T>(
215    config: &LintExecutionConfig,
216    header: &str,
217    items: impl Iterator<Item = T>,
218    o: &mut String,
219) -> Result<()>
220where
221    T: Display,
222{
223    let mut items = items.into_iter();
224    if config.no_truncate {
225        let Some(first) = items.next() else {
226            return Ok(());
227        };
228        writeln!(o, "{header}:")?;
229        writeln!(o, "  {first}")?;
230        for item in items {
231            writeln!(o, "  {item}")?;
232        }
233        return Ok(());
234    } else {
235        let Some((samples, rest)) = bootc_utils::collect_until(items, DEFAULT_TRUNCATED_OUTPUT)
236        else {
237            return Ok(());
238        };
239        writeln!(o, "{header}:")?;
240        for item in samples {
241            writeln!(o, "  {item}")?;
242        }
243        if rest > 0 {
244            writeln!(o, "  ...and {rest} more")?;
245        }
246    }
247    Ok(())
248}
249
250// Helper to build a lint error message from multiple sections.
251// The closure `build_message_fn` is responsible for calling `format_items`
252// to populate the message buffer.
253fn format_lint_err_from_items<T>(
254    config: &LintExecutionConfig,
255    header: &str,
256    items: impl Iterator<Item = T>,
257) -> LintResult
258where
259    T: Display,
260{
261    let mut msg = String::new();
262    // SAFETY: Writing to a string can't fail
263    format_items(config, header, items, &mut msg).unwrap();
264    lint_err(msg)
265}
266
267fn lint_inner<'skip>(
268    root: &Dir,
269    root_type: RootType,
270    config: &LintExecutionConfig,
271    skip: impl IntoIterator<Item = &'skip str>,
272    mut output: impl std::io::Write,
273) -> Result<LintExecutionResult> {
274    let mut fatal = 0usize;
275    let mut warnings = 0usize;
276    let mut passed = 0usize;
277    let skip: std::collections::HashSet<_> = skip.into_iter().collect();
278    let (mut applicable_lints, skipped_lints): (Vec<_>, Vec<_>) = LINTS.iter().partition(|lint| {
279        if skip.contains(lint.name) {
280            return false;
281        }
282        if let Some(lint_root_type) = lint.root_type {
283            if lint_root_type != root_type {
284                return false;
285            }
286        }
287        true
288    });
289    // SAFETY: Length must be smaller.
290    let skipped = skipped_lints.len();
291    // Default to predictablility here
292    applicable_lints.sort_by(|a, b| a.name.cmp(b.name));
293    // Split the lints by type
294    let (nonrec_lints, recursive_lints): (Vec<_>, Vec<_>) = applicable_lints
295        .into_iter()
296        .partition(|lint| matches!(lint.f, LintFnTy::Regular(_)));
297    let mut results = Vec::new();
298    for lint in nonrec_lints {
299        let f = match lint.f {
300            LintFnTy::Regular(f) => f,
301            LintFnTy::Recursive(_) => unreachable!(),
302        };
303        results.push((lint, f(&root, &config)));
304    }
305
306    let mut recursive_lints = BTreeSet::from_iter(recursive_lints);
307    let mut recursive_errors = BTreeMap::new();
308    root.walk(
309        &walk_configuration().path_base(Path::new("/")),
310        |e| -> std::io::Result<_> {
311            // If there's no recursive lints, we're done!
312            if recursive_lints.is_empty() {
313                return Ok(ControlFlow::Break(()));
314            }
315            // Keep track of any errors we caught while iterating over
316            // the recursive lints.
317            let mut this_iteration_errors = Vec::new();
318            // Call each recursive lint on this directory entry.
319            for &lint in recursive_lints.iter() {
320                let f = match &lint.f {
321                    // SAFETY: We know this set only holds recursive lints
322                    LintFnTy::Regular(_) => unreachable!(),
323                    LintFnTy::Recursive(f) => f,
324                };
325                // Keep track of the error if we found one
326                match f(e, &config) {
327                    Ok(Ok(())) => {}
328                    o => this_iteration_errors.push((lint, o)),
329                }
330            }
331            // For each recursive lint that errored, remove it from
332            // the set that we will continue running.
333            for (lint, err) in this_iteration_errors {
334                recursive_lints.remove(lint);
335                recursive_errors.insert(lint, err);
336            }
337            Ok(ControlFlow::Continue(()))
338        },
339    )?;
340    // Extend our overall result set with the recursive-lint errors.
341    results.extend(recursive_errors);
342    // Any recursive lint still in this list succeeded.
343    results.extend(recursive_lints.into_iter().map(|lint| (lint, lint_ok())));
344    for (lint, r) in results {
345        let name = lint.name;
346        let r = match r {
347            Ok(r) => r,
348            Err(e) => {
349                return Err(e.context(format!("Unexpected runtime error running lint {name}")));
350            }
351        };
352
353        if let Err(e) = r {
354            match lint.ty {
355                LintType::Fatal => {
356                    writeln!(output, "Failed lint: {name}: {e}")?;
357                    fatal += 1;
358                }
359                LintType::Warning => {
360                    writeln!(output, "Lint warning: {name}: {e}")?;
361                    warnings += 1;
362                }
363            }
364        } else {
365            // We'll be quiet for now
366            tracing::debug!("OK {name} (type={:?})", lint.ty);
367            passed += 1;
368        }
369    }
370
371    Ok(LintExecutionResult {
372        passed,
373        skipped,
374        warnings,
375        fatal,
376    })
377}
378
379#[context("Linting")]
380pub(crate) fn lint<'skip>(
381    root: &Dir,
382    warning_disposition: WarningDisposition,
383    root_type: RootType,
384    skip: impl IntoIterator<Item = &'skip str>,
385    mut output: impl std::io::Write,
386    no_truncate: bool,
387) -> Result<()> {
388    let config = LintExecutionConfig { no_truncate };
389    let r = lint_inner(root, root_type, &config, skip, &mut output)?;
390    writeln!(output, "Checks passed: {}", r.passed)?;
391    if r.skipped > 0 {
392        writeln!(output, "Checks skipped: {}", r.skipped)?;
393    }
394    let fatal = if matches!(warning_disposition, WarningDisposition::FatalWarnings) {
395        r.fatal + r.warnings
396    } else {
397        r.fatal
398    };
399    if r.warnings > 0 {
400        writeln!(output, "Warnings: {}", r.warnings)?;
401    }
402    if fatal > 0 {
403        anyhow::bail!("Checks failed: {}", fatal)
404    }
405    Ok(())
406}
407
408/// check for the existence of the /var/run directory
409/// if it exists we need to check that it links to /run if not error
410#[distributed_slice(LINTS)]
411static LINT_VAR_RUN: Lint = Lint::new_fatal(
412    "var-run",
413    "Check for /var/run being a physical directory; this is always a bug.",
414    check_var_run,
415);
416fn check_var_run(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
417    if let Some(meta) = root.symlink_metadata_optional("var/run")? {
418        if !meta.is_symlink() {
419            return lint_err("Not a symlink: var/run");
420        }
421    }
422    lint_ok()
423}
424
425#[distributed_slice(LINTS)]
426static LINT_BUILDAH_INJECTED: Lint = Lint::new_warning(
427    "buildah-injected",
428    indoc::indoc! { "
429        Check for an invalid /etc/hostname or /etc/resolv.conf that may have been injected by
430        a container build system." },
431    check_buildah_injected,
432)
433// This one doesn't make sense to run looking at the running root,
434// because we do expect /etc/hostname to be injected as
435.set_root_type(RootType::Alternative);
436fn check_buildah_injected(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
437    const RUNTIME_INJECTED: &[&str] = &["etc/hostname", "etc/resolv.conf"];
438    for ent in RUNTIME_INJECTED {
439        if let Some(meta) = root.symlink_metadata_optional(ent)? {
440            if meta.is_file() && meta.size() == 0 {
441                return lint_err(format!(
442                    "/{ent} is an empty file; this may have been synthesized by a container runtime."
443                ));
444            }
445        }
446    }
447    lint_ok()
448}
449
450#[distributed_slice(LINTS)]
451static LINT_ETC_USRUSETC: Lint = Lint::new_fatal(
452    "etc-usretc",
453    indoc! { r#"
454Verify that only one of /etc or /usr/etc exist. You should only have /etc
455in a container image. It will cause undefined behavior to have both /etc
456and /usr/etc.
457"# },
458    check_usretc,
459);
460fn check_usretc(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
461    let etc_exists = root.symlink_metadata_optional("etc")?.is_some();
462    // For compatibility/conservatism don't bomb out if there's no /etc.
463    if !etc_exists {
464        return lint_ok();
465    }
466    // But having both /etc and /usr/etc is not something we want to support.
467    if root.symlink_metadata_optional("usr/etc")?.is_some() {
468        return lint_err(
469            "Found /usr/etc - this is a bootc implementation detail and not supported to use in containers",
470        );
471    }
472    lint_ok()
473}
474
475/// Validate that we can parse the /usr/lib/bootc/kargs.d files.
476#[distributed_slice(LINTS)]
477static LINT_KARGS: Lint = Lint::new_fatal(
478    "bootc-kargs",
479    "Verify syntax of /usr/lib/bootc/kargs.d.",
480    check_parse_kargs,
481);
482fn check_parse_kargs(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
483    let args = crate::bootc_kargs::get_kargs_in_root(root, ARCH)?;
484    tracing::debug!("found kargs: {args:?}");
485    lint_ok()
486}
487
488#[distributed_slice(LINTS)]
489static LINT_KERNEL: Lint = Lint::new_fatal(
490    "kernel",
491    indoc! { r#"
492             Check for multiple kernels, i.e. multiple directories of the form /usr/lib/modules/$kver.
493             Only one kernel is supported in an image.
494     "# },
495    check_kernel,
496);
497fn check_kernel(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
498    let result = ostree_ext::bootabletree::find_kernel_dir_fs(&root)?;
499    tracing::debug!("Found kernel: {:?}", result);
500    lint_ok()
501}
502
503// This one can be lifted in the future, see https://github.com/bootc-dev/bootc/issues/975
504#[distributed_slice(LINTS)]
505static LINT_UTF8: Lint = Lint {
506    name: "utf8",
507    description: indoc! { r#"
508Check for non-UTF8 filenames. Currently, the ostree backend of bootc only supports
509UTF-8 filenames. Non-UTF8 filenames will cause a fatal error.
510"#},
511    ty: LintType::Fatal,
512    root_type: None,
513    f: LintFnTy::Recursive(check_utf8),
514};
515fn check_utf8(e: &WalkComponent, _config: &LintExecutionConfig) -> LintRecursiveResult {
516    let path = e.path;
517    let filename = e.filename;
518    let dirname = path.parent().unwrap_or(Path::new("/"));
519    if filename.to_str().is_none() {
520        // This escapes like "abc\xFFdéf"
521        return lint_err(format!(
522            "{}: Found non-utf8 filename {filename:?}",
523            PathQuotedDisplay::new(&dirname)
524        ));
525    };
526
527    if e.file_type.is_symlink() {
528        let target = e.dir.read_link_contents(filename)?;
529        if target.to_str().is_none() {
530            return lint_err(format!(
531                "{}: Found non-utf8 symlink target",
532                PathQuotedDisplay::new(&path)
533            ));
534        }
535    }
536    lint_ok()
537}
538
539fn check_prepareroot_composefs_norecurse(dir: &Dir) -> LintResult {
540    let path = ostree_ext::ostree_prepareroot::CONF_PATH;
541    let Some(config) = ostree_prepareroot::load_config_from_root(dir)? else {
542        return lint_err(format!("{path} is not present to enable composefs"));
543    };
544    if !ostree_prepareroot::overlayfs_enabled_in_config(&config)? {
545        return lint_err(format!("{path} does not have composefs enabled"));
546    }
547    lint_ok()
548}
549
550#[distributed_slice(LINTS)]
551static LINT_API_DIRS: Lint = Lint::new_fatal(
552    "api-base-directories",
553    indoc! { r#"
554Verify that expected base API directories exist. For more information
555on these, see <https://systemd.io/API_FILE_SYSTEMS/>.
556
557Note that in addition, bootc requires that `/var` exist as a directory.
558"#},
559    check_api_dirs,
560);
561fn check_api_dirs(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
562    for d in API_DIRS {
563        let Some(meta) = root.symlink_metadata_optional(d)? else {
564            return lint_err(format!("Missing API filesystem base directory: /{d}"));
565        };
566        if !meta.is_dir() {
567            return lint_err(format!(
568                "Expected directory for API filesystem base directory: /{d}"
569            ));
570        }
571    }
572    lint_ok()
573}
574
575#[distributed_slice(LINTS)]
576static LINT_COMPOSEFS: Lint = Lint::new_warning(
577    "baseimage-composefs",
578    indoc! { r#"
579Check that composefs is enabled for ostree. Skipped for composefs-native
580images, i.e. those that ship /usr/lib/composefs/setup-root-conf.toml. More in
581<https://ostreedev.github.io/ostree/composefs/>.
582"#},
583    check_composefs,
584);
585fn check_composefs(dir: &Dir, _config: &LintExecutionConfig) -> LintResult {
586    // ostree's prepare-root.conf is irrelevant for composefs-native images.
587    if is_composefs_native(dir)? {
588        return lint_ok();
589    }
590    if let Err(e) = check_prepareroot_composefs_norecurse(dir)? {
591        return Ok(Err(e));
592    }
593    // If we have our own documentation with the expected root contents
594    // embedded, then check that too! Mostly just because recursion is fun.
595    if let Some(dir) = dir.open_dir_optional(BASEIMAGE_REF)? {
596        if let Err(e) = check_prepareroot_composefs_norecurse(&dir)? {
597            return Ok(Err(e));
598        }
599    }
600    lint_ok()
601}
602
603/// Check for a few files and directories we expect in the base image.
604fn check_baseimage_root_norecurse(dir: &Dir, _config: &LintExecutionConfig) -> LintResult {
605    // Check /sysroot
606    let meta = dir.symlink_metadata_optional("sysroot")?;
607    match meta {
608        Some(meta) if !meta.is_dir() => return lint_err("Expected a directory for /sysroot"),
609        None => return lint_err("Missing /sysroot"),
610        _ => {}
611    }
612
613    // Check /ostree -> sysroot/ostree
614    let Some(meta) = dir.symlink_metadata_optional("ostree")? else {
615        // Composefs-native images don't use ostree, so they don't need it.
616        if is_composefs_native(dir)? {
617            return lint_ok();
618        }
619        return lint_err("Missing ostree -> sysroot/ostree link");
620    };
621    if !meta.is_symlink() {
622        return lint_err("/ostree should be a symlink");
623    }
624    let link = dir.read_link_contents("ostree")?;
625    let expected = "sysroot/ostree";
626    if link.as_os_str().as_bytes() != expected.as_bytes() {
627        return lint_err(format!("Expected /ostree -> {expected}, not {link:?}"));
628    }
629
630    lint_ok()
631}
632
633/// Check ostree-related base image content.
634#[distributed_slice(LINTS)]
635static LINT_BASEIMAGE_ROOT: Lint = Lint::new_fatal(
636    "baseimage-root",
637    indoc! { r#"
638Check that expected files are present in the root of the filesystem; such
639as /sysroot and a composefs configuration for ostree. The /ostree symlink
640is not required for composefs-native images, i.e. those that ship
641/usr/lib/composefs/setup-root-conf.toml. More in
642<https://bootc.dev/bootc/bootc-images.html#standard-image-content>.
643"#},
644    check_baseimage_root,
645);
646fn check_baseimage_root(dir: &Dir, config: &LintExecutionConfig) -> LintResult {
647    if let Err(e) = check_baseimage_root_norecurse(dir, config)? {
648        return Ok(Err(e));
649    }
650    // If we have our own documentation with the expected root contents
651    // embedded, then check that too! Mostly just because recursion is fun.
652    if let Some(dir) = dir.open_dir_optional(BASEIMAGE_REF)? {
653        if let Err(e) = check_baseimage_root_norecurse(&dir, config)? {
654            return Ok(Err(e));
655        }
656    }
657    lint_ok()
658}
659
660fn collect_nonempty_regfiles(
661    root: &Dir,
662    path: &Utf8Path,
663    out: &mut BTreeSet<Utf8PathBuf>,
664) -> Result<()> {
665    for entry in root.entries_utf8()? {
666        let entry = entry?;
667        let ty = entry.file_type()?;
668        let path = path.join(entry.file_name()?);
669        if ty.is_file() {
670            let meta = entry.metadata()?;
671            if meta.size() > 0 {
672                out.insert(path);
673            }
674        } else if ty.is_dir() {
675            let d = entry.open_dir()?;
676            collect_nonempty_regfiles(d.as_cap_std(), &path, out)?;
677        }
678    }
679    Ok(())
680}
681
682#[distributed_slice(LINTS)]
683static LINT_VARLOG: Lint = Lint::new_warning(
684    "var-log",
685    indoc! { r#"
686Check for non-empty regular files in `/var/log`. It is often undesired
687to ship log files in container images. Log files in general are usually
688per-machine state in `/var`. Additionally, log files often include
689timestamps, causing unreproducible container images, and may contain
690sensitive build system information.
691"#},
692    check_varlog,
693);
694fn check_varlog(root: &Dir, config: &LintExecutionConfig) -> LintResult {
695    let Some(d) = root.open_dir_optional("var/log")? else {
696        return lint_ok();
697    };
698    let mut nonempty_regfiles = BTreeSet::new();
699    collect_nonempty_regfiles(&d, "/var/log".into(), &mut nonempty_regfiles)?;
700
701    if nonempty_regfiles.is_empty() {
702        return lint_ok();
703    }
704
705    let header = "Found non-empty logfiles";
706    let items = nonempty_regfiles.iter().map(PathQuotedDisplay::new);
707    format_lint_err_from_items(config, header, items)
708}
709
710#[distributed_slice(LINTS)]
711static LINT_VAR_TMPFILES: Lint = Lint::new_warning(
712    "var-tmpfiles",
713    indoc! { r#"
714Check for content in /var that does not have corresponding systemd tmpfiles.d entries.
715This can cause a problem across upgrades because content in /var from the container
716image will only be applied on the initial provisioning.
717
718Instead, it's recommended to have /var effectively empty in the container image,
719and use systemd tmpfiles.d to generate empty directories and compatibility symbolic links
720as part of each boot.
721"#},
722    check_var_tmpfiles,
723)
724.set_root_type(RootType::Running);
725
726fn check_var_tmpfiles(_root: &Dir, config: &LintExecutionConfig) -> LintResult {
727    let r = bootc_tmpfiles::find_missing_tmpfiles_current_root()?;
728    if r.tmpfiles.is_empty() && r.unsupported.is_empty() {
729        return lint_ok();
730    }
731    let mut msg = String::new();
732    let header = "Found content in /var missing systemd tmpfiles.d entries";
733    format_items(config, header, r.tmpfiles.iter().map(|v| v as &_), &mut msg)?;
734    let header = "Found non-directory/non-symlink files in /var";
735    let items = r.unsupported.iter().map(PathQuotedDisplay::new);
736    format_items(config, header, items, &mut msg)?;
737    lint_err(msg)
738}
739
740#[distributed_slice(LINTS)]
741static LINT_SYSUSERS: Lint = Lint::new_warning(
742    "sysusers",
743    indoc! { r#"
744Check for users in /etc/passwd and groups in /etc/group that do not have corresponding
745systemd sysusers.d entries in /usr/lib/sysusers.d.
746This can cause a problem across upgrades because if /etc is not transient and is locally
747modified (commonly due to local user additions), then the contents of /etc/passwd in the new container
748image may not be visible.
749
750Using systemd-sysusers to allocate users and groups will ensure that these are allocated
751on system startup alongside other users.
752
753More on this topic in <https://bootc.dev/bootc/building/users-and-groups.html>
754"# },
755    check_sysusers,
756);
757fn check_sysusers(rootfs: &Dir, config: &LintExecutionConfig) -> LintResult {
758    let r = bootc_sysusers::analyze(rootfs)?;
759    if r.is_empty() {
760        return lint_ok();
761    }
762    let mut msg = String::new();
763    let header = "Found /etc/passwd entry without corresponding systemd sysusers.d";
764    let items = r.missing_users.iter().map(|v| v as &dyn std::fmt::Display);
765    format_items(config, header, items, &mut msg)?;
766    let header = "Found /etc/group entry without corresponding systemd sysusers.d";
767    format_items(config, header, r.missing_groups.into_iter(), &mut msg)?;
768    lint_err(msg)
769}
770
771#[distributed_slice(LINTS)]
772static LINT_NONEMPTY_BOOT: Lint = Lint::new_warning(
773    "nonempty-boot",
774    indoc! { r#"
775The `/boot` directory should be present, but empty. The kernel
776content should be in /usr/lib/modules instead in the container image.
777Any content here in the container image will be masked at runtime.
778"#},
779    check_boot,
780);
781fn check_boot(root: &Dir, config: &LintExecutionConfig) -> LintResult {
782    let Some(d) = root.open_dir_optional(crate::install::BOOT)? else {
783        return lint_err("Missing /boot directory");
784    };
785
786    // First collect all entries to determine if the directory is empty
787    let entries: Result<BTreeSet<_>, _> = d
788        .entries()?
789        .into_iter()
790        .map(|v| {
791            let v = v?;
792            anyhow::Ok(v.file_name())
793        })
794        .collect();
795    let mut entries = entries?;
796    {
797        // Work around https://github.com/containers/composefs-rs/issues/131
798        let efidir = Utf8Path::new(EFI_LINUX)
799            .parent()
800            .map(|b| b.as_std_path())
801            .unwrap();
802        entries.remove(efidir.as_os_str());
803    }
804    if entries.is_empty() {
805        return lint_ok();
806    }
807
808    let header = "Found non-empty /boot";
809    let items = entries.iter().map(PathQuotedDisplay::new);
810    format_lint_err_from_items(config, header, items)
811}
812
813/// Directories that should be empty in container images.
814/// These are tmpfs at runtime and any content is build-time artifacts.
815const RUNTIME_ONLY_DIRS: &[&str] = &["run", "tmp"];
816
817/// Files injected by container runtimes (podman/buildah) that should be
818/// ignored when linting. Podman bind-mounts stub-resolv.conf for DNS
819/// resolution, creating parent directories as a side effect.
820/// As of only recently, buildah will clean this up.
821/// See <https://github.com/containers/buildah/pull/6233>
822/// See <https://github.com/bootc-dev/bootc/issues/2050>
823const CONTAINER_RUNTIME_FILES: &[&str] = &["/run/systemd/resolve/stub-resolv.conf"];
824
825#[distributed_slice(LINTS)]
826static LINT_RUNTIME_ONLY_DIRS: Lint = Lint::new_warning(
827    "nonempty-run-tmp",
828    indoc! { r#"
829The `/run` and `/tmp` directories should be empty in container images.
830These directories are normally mounted as `tmpfs` at runtime
831(masking any content in the underlying image) and any content here is typically build-time
832artifacts that serve no purpose in the final image.
833"#},
834    check_runtime_only_dirs,
835);
836
837fn check_runtime_only_dirs(root: &Dir, config: &LintExecutionConfig) -> LintResult {
838    let mut found_content = BTreeSet::new();
839
840    for dirname in RUNTIME_ONLY_DIRS {
841        // Use open_dir_noxdev so that if the directory is a mount point
842        // (e.g. the user passed --mount=type=tmpfs,target=/run) we skip
843        // it — content on a different filesystem is ephemeral and won't
844        // end up in the image.
845        let Some(d) = root.open_dir_noxdev(dirname)? else {
846            continue;
847        };
848
849        d.walk(
850            &walk_configuration().path_base(Path::new(dirname)),
851            |entry| -> std::io::Result<_> {
852                // Skip mount points (bind mounts, tmpfs, etc.) - these are
853                // container-runtime injected content like .containerenv
854                if entry.dir.is_mountpoint(entry.filename)? == Some(true) {
855                    return Ok(ControlFlow::Continue(()));
856                }
857
858                let full_path = Utf8Path::new("/").join(entry.path.to_string_lossy().as_ref());
859                found_content.insert(full_path);
860
861                Ok(ControlFlow::Continue(()))
862            },
863        )?;
864    }
865
866    // Remove known container-runtime injected paths under /run
867    // (e.g. stub-resolv.conf and its parent directories if they have
868    // no other children).
869    prune_known_run_paths(&mut found_content);
870
871    if found_content.is_empty() {
872        return lint_ok();
873    }
874
875    let header = "Found content in runtime-only directories (/run, /tmp)";
876    let items = found_content.iter().map(PathQuotedDisplay::new);
877    format_lint_err_from_items(config, header, items)
878}
879
880/// Remove known container-runtime injected paths from `/run`.
881///
882/// Podman/buildah inject files like `/run/systemd/resolve/stub-resolv.conf`
883/// for DNS resolution, creating the parent directory tree as a side effect.
884/// The file itself is usually already filtered out (it's a bind mount detected
885/// by `is_mountpoint()`), but the parent directories `/run/systemd` and
886/// `/run/systemd/resolve` remain.
887///
888/// Remove the known file if present, then walk up removing each parent
889/// under `/run` that has no remaining children in the set.
890fn prune_known_run_paths(paths: &mut BTreeSet<Utf8PathBuf>) {
891    let run_prefix = Utf8Path::new("/run");
892    for known in CONTAINER_RUNTIME_FILES {
893        let known = Utf8Path::new(known);
894        paths.remove(known);
895        // Walk up the parent directories, removing each one only if
896        // nothing else in the set is underneath it.
897        let mut dir = known.parent();
898        while let Some(d) = dir {
899            if !d.starts_with(run_prefix) || d == run_prefix {
900                break;
901            }
902            let has_other_children = paths.iter().any(|p| p != d && p.starts_with(d));
903            if has_other_children {
904                break;
905            }
906            paths.remove(d);
907            dir = d.parent();
908        }
909    }
910}
911
912/// List sourced from [workspace.metadata.binary-dependencies] in the workspace
913/// Cargo.toml via build.rs.
914const BINARY_DEPS: &str = env!("BOOTC_BINARY_DEPS");
915
916/// Return the resolved set of required runtime binaries.
917///
918/// Entries that match the default podman/skopeo names are replaced with the
919/// values from `bootc_utils::{podman,skopeo}_bin()` so that
920/// `BOOTC_EXP_EXTERNAL_CONTAINER_TOOL` overrides are honoured.
921fn resolve_runtime_bins<'a>(podman: &'a str, skopeo: &'a str) -> Vec<&'a str> {
922    let mut bins: Vec<&str> = BINARY_DEPS
923        .split(',')
924        .map(|b| match b {
925            "podman" => podman,
926            "skopeo" => skopeo,
927            other => other,
928        })
929        .collect();
930    bins.sort_unstable();
931    bins.dedup();
932    bins
933}
934
935fn resolved_runtime_bins() -> Vec<&'static str> {
936    resolve_runtime_bins(bootc_utils::podman_bin(), bootc_utils::skopeo_bin())
937}
938
939#[distributed_slice(LINTS)]
940static LINT_RUNTIME_DEPS: Lint = Lint::new_warning(
941    "runtime-deps",
942    "Check that required runtime dependencies are present in the image.",
943    check_runtime_deps,
944);
945fn check_runtime_deps(root: &Dir, _config: &LintExecutionConfig) -> LintResult {
946    let mut missing = Vec::new();
947    for bin in resolved_runtime_bins() {
948        if !crate::utils::have_executable_in_root(root, bin)? {
949            missing.push(bin);
950        }
951    }
952    if missing.is_empty() {
953        return lint_ok();
954    }
955    lint_err(format!(
956        "Missing required runtime dependencies: {}",
957        missing.join(", ")
958    ))
959}
960
961#[cfg(test)]
962mod tests {
963    use std::sync::LazyLock;
964
965    use super::*;
966    use crate::bootc_composefs::image::setup_root_conf_path;
967
968    static ALTROOT_LINTS: LazyLock<usize> = LazyLock::new(|| {
969        LINTS
970            .iter()
971            .filter(|lint| lint.root_type != Some(RootType::Running))
972            .count()
973    });
974
975    fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
976        // Create a new temporary directory for test fixtures.
977        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
978        Ok(tempdir)
979    }
980
981    fn passing_fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
982        // Create a temporary directory fixture that is expected to pass most lints.
983        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
984        for d in API_DIRS {
985            root.create_dir(d)?;
986        }
987        root.create_dir_all("usr/lib/modules/5.7.2")?;
988        root.write("usr/lib/modules/5.7.2/vmlinuz", "vmlinuz")?;
989
990        root.create_dir("boot")?;
991        root.create_dir("sysroot")?;
992        root.symlink_contents("sysroot/ostree", "ostree")?;
993
994        const PREPAREROOT_PATH: &str = "usr/lib/ostree/prepare-root.conf";
995        const PREPAREROOT: &str =
996            include_str!("../../../baseimage/base/usr/lib/ostree/prepare-root.conf");
997        root.create_dir_all(Utf8Path::new(PREPAREROOT_PATH).parent().unwrap())?;
998        root.atomic_write(PREPAREROOT_PATH, PREPAREROOT)?;
999
1000        for bin in resolved_runtime_bins() {
1001            add_runtime_bin(&root, bin)?;
1002        }
1003
1004        Ok(root)
1005    }
1006
1007    fn add_runtime_bin(root: &Dir, bin: &str) -> Result<()> {
1008        root.create_dir_all("usr/bin")?;
1009        root.write(format!("usr/bin/{bin}"), "")?;
1010        Ok(())
1011    }
1012
1013    #[test]
1014    fn test_var_run() -> Result<()> {
1015        let root = &fixture()?;
1016        let config = &LintExecutionConfig::default();
1017        // This one should pass
1018        check_var_run(root, config).unwrap().unwrap();
1019        root.create_dir_all("var/run/foo")?;
1020        assert!(check_var_run(root, config).unwrap().is_err());
1021        root.remove_dir_all("var/run")?;
1022        // Now we should pass again
1023        check_var_run(root, config).unwrap().unwrap();
1024        Ok(())
1025    }
1026
1027    #[test]
1028    fn test_api() -> Result<()> {
1029        let root = &passing_fixture()?;
1030        let config = &LintExecutionConfig::default();
1031        // This one should pass
1032        check_api_dirs(root, config).unwrap().unwrap();
1033        root.remove_dir("var")?;
1034        assert!(check_api_dirs(root, config).unwrap().is_err());
1035        root.write("var", "a file for var")?;
1036        assert!(check_api_dirs(root, config).unwrap().is_err());
1037        Ok(())
1038    }
1039
1040    #[test]
1041    fn test_lint_main() -> Result<()> {
1042        let root = &passing_fixture()?;
1043        let config = &LintExecutionConfig::default();
1044        let mut out = Vec::new();
1045        let warnings = WarningDisposition::FatalWarnings;
1046        let root_type = RootType::Alternative;
1047        lint(root, warnings, root_type, [], &mut out, config.no_truncate).unwrap();
1048        root.create_dir_all("var/run/foo")?;
1049        let mut out = Vec::new();
1050        assert!(lint(root, warnings, root_type, [], &mut out, config.no_truncate).is_err());
1051        Ok(())
1052    }
1053
1054    #[test]
1055    fn test_lint_inner() -> Result<()> {
1056        let root = &passing_fixture()?;
1057        let config = &LintExecutionConfig::default();
1058
1059        // Verify that all lints run
1060        let mut out = Vec::new();
1061        let root_type = RootType::Alternative;
1062        let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
1063        let running_only_lints = LINTS.len().checked_sub(*ALTROOT_LINTS).unwrap();
1064        assert_eq!(r.warnings, 0);
1065        assert_eq!(r.fatal, 0);
1066        assert_eq!(r.skipped, running_only_lints);
1067        assert_eq!(r.passed, *ALTROOT_LINTS);
1068
1069        let r = lint_inner(root, root_type, config, ["var-log"], &mut out).unwrap();
1070        // Trigger a failure in var-log by creating a non-empty log file.
1071        root.create_dir_all("var/log/dnf")?;
1072        root.write("var/log/dnf/dnf.log", b"dummy dnf log")?;
1073        assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
1074        assert_eq!(r.fatal, 0);
1075        assert_eq!(r.skipped, running_only_lints + 1);
1076        assert_eq!(r.warnings, 0);
1077
1078        // But verify that not skipping it results in a warning
1079        let mut out = Vec::new();
1080        let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
1081        assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
1082        assert_eq!(r.fatal, 0);
1083        assert_eq!(r.skipped, running_only_lints);
1084        assert_eq!(r.warnings, 1);
1085        Ok(())
1086    }
1087
1088    #[test]
1089    fn test_kernel_lint() -> Result<()> {
1090        let root = &fixture()?;
1091        let config = &LintExecutionConfig::default();
1092        // This one should pass
1093        check_kernel(root, config).unwrap().unwrap();
1094        root.create_dir_all("usr/lib/modules/5.7.2")?;
1095        root.write("usr/lib/modules/5.7.2/vmlinuz", "old vmlinuz")?;
1096        root.create_dir_all("usr/lib/modules/6.3.1")?;
1097        root.write("usr/lib/modules/6.3.1/vmlinuz", "new vmlinuz")?;
1098        assert!(check_kernel(root, config).is_err());
1099        root.remove_dir_all("usr/lib/modules/5.7.2")?;
1100        // Now we should pass again
1101        check_kernel(root, config).unwrap().unwrap();
1102        Ok(())
1103    }
1104
1105    #[test]
1106    fn test_kargs() -> Result<()> {
1107        let root = &fixture()?;
1108        let config = &LintExecutionConfig::default();
1109        check_parse_kargs(root, config).unwrap().unwrap();
1110        root.create_dir_all("usr/lib/bootc")?;
1111        root.write("usr/lib/bootc/kargs.d", "not a directory")?;
1112        assert!(check_parse_kargs(root, config).is_err());
1113        Ok(())
1114    }
1115
1116    #[test]
1117    fn test_usr_etc() -> Result<()> {
1118        let root = &fixture()?;
1119        let config = &LintExecutionConfig::default();
1120        // This one should pass
1121        check_usretc(root, config).unwrap().unwrap();
1122        root.create_dir_all("etc")?;
1123        root.create_dir_all("usr/etc")?;
1124        assert!(check_usretc(root, config).unwrap().is_err());
1125        root.remove_dir_all("etc")?;
1126        // Now we should pass again
1127        check_usretc(root, config).unwrap().unwrap();
1128        Ok(())
1129    }
1130
1131    #[test]
1132    fn test_varlog() -> Result<()> {
1133        let root = &fixture()?;
1134        let config = &LintExecutionConfig::default();
1135        check_varlog(root, config).unwrap().unwrap();
1136        root.create_dir_all("var/log")?;
1137        check_varlog(root, config).unwrap().unwrap();
1138        root.symlink_contents("../../usr/share/doc/systemd/README.logs", "var/log/README")?;
1139        check_varlog(root, config).unwrap().unwrap();
1140
1141        root.atomic_write("var/log/somefile.log", "log contents")?;
1142        let Err(e) = check_varlog(root, config).unwrap() else {
1143            unreachable!()
1144        };
1145        similar_asserts::assert_eq!(
1146            e.to_string(),
1147            "Found non-empty logfiles:\n  /var/log/somefile.log\n"
1148        );
1149        root.create_dir_all("var/log/someproject")?;
1150        root.atomic_write("var/log/someproject/audit.log", "audit log")?;
1151        root.atomic_write("var/log/someproject/info.log", "info")?;
1152        let Err(e) = check_varlog(root, config).unwrap() else {
1153            unreachable!()
1154        };
1155        similar_asserts::assert_eq!(
1156            e.to_string(),
1157            indoc! { r#"
1158                Found non-empty logfiles:
1159                  /var/log/somefile.log
1160                  /var/log/someproject/audit.log
1161                  /var/log/someproject/info.log
1162                "# }
1163        );
1164
1165        Ok(())
1166    }
1167
1168    #[test]
1169    fn test_boot() -> Result<()> {
1170        let root = &passing_fixture()?;
1171        let config = &LintExecutionConfig::default();
1172        check_boot(&root, config).unwrap().unwrap();
1173
1174        // Verify creating EFI doesn't error
1175        root.create_dir_all("EFI/Linux")?;
1176        root.write("EFI/Linux/foo.efi", b"some dummy efi")?;
1177        check_boot(&root, config).unwrap().unwrap();
1178
1179        root.create_dir("boot/somesubdir")?;
1180        let Err(e) = check_boot(&root, config).unwrap() else {
1181            unreachable!()
1182        };
1183        assert!(e.to_string().contains("somesubdir"));
1184
1185        Ok(())
1186    }
1187
1188    #[test]
1189    fn test_runtime_only_dirs() -> Result<()> {
1190        let root = &fixture()?;
1191        let config = &LintExecutionConfig::default();
1192
1193        root.create_dir_all("run")?;
1194        root.create_dir_all("tmp")?;
1195
1196        // Simulate the exact scenario from https://github.com/bootc-dev/bootc/issues/2050:
1197        // podman creates /run/systemd/resolve/stub-resolv.conf as a bind mount
1198        // for DNS, leaving the directory tree behind. When /run/systemd/resolve
1199        // only contains the known runtime file (or is empty because the file is
1200        // a mount point that was filtered), the whole tree should be pruned.
1201        root.create_dir_all("run/systemd/resolve")?;
1202        check_runtime_only_dirs(root, config).unwrap().unwrap();
1203
1204        // Same but with the stub-resolv.conf file actually present
1205        root.write("run/systemd/resolve/stub-resolv.conf", "data")?;
1206        check_runtime_only_dirs(root, config).unwrap().unwrap();
1207        root.remove_file("run/systemd/resolve/stub-resolv.conf")?;
1208
1209        // If there's *other* content under /run/systemd, we should still warn
1210        // about it (the pruning only removes dirs that are solely parents of
1211        // known runtime files).
1212        root.write("run/systemd/resolve/something-else", "data")?;
1213        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1214            unreachable!("expected warning for unknown file under /run/systemd")
1215        };
1216        let msg = e.to_string();
1217        assert!(
1218            msg.contains("/run/systemd/resolve/something-else"),
1219            "should warn about the unknown file: {msg}"
1220        );
1221        // The parent dirs should still appear since they have real children
1222        assert!(
1223            msg.contains("/run/systemd"),
1224            "parent dirs with real children should appear: {msg}"
1225        );
1226        root.remove_file("run/systemd/resolve/something-else")?;
1227
1228        // Unknown directories should still warn
1229        root.create_dir("run/dnf")?;
1230        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1231            unreachable!("expected warning for /run/dnf")
1232        };
1233        let msg = e.to_string();
1234        assert!(
1235            msg.contains("/run/dnf"),
1236            "should warn about /run/dnf: {msg}"
1237        );
1238        assert!(
1239            !msg.contains("/run/systemd"),
1240            "should not mention /run/systemd: {msg}"
1241        );
1242        root.remove_dir("run/dnf")?;
1243
1244        // Files in /run should warn
1245        root.write("run/leaked-file", "data")?;
1246        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1247            unreachable!("expected warning for /run/leaked-file")
1248        };
1249        assert!(e.to_string().contains("/run/leaked-file"));
1250        root.remove_file("run/leaked-file")?;
1251
1252        // Files in /tmp should warn
1253        root.write("tmp/build-artifact", "some data")?;
1254        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1255            unreachable!("expected warning for /tmp/build-artifact")
1256        };
1257        assert!(e.to_string().contains("/tmp/build-artifact"));
1258        root.remove_file("tmp/build-artifact")?;
1259
1260        // Clean state should pass
1261        check_runtime_only_dirs(root, config).unwrap().unwrap();
1262
1263        Ok(())
1264    }
1265
1266    fn run_recursive_lint(
1267        root: &Dir,
1268        f: LintRecursiveFn,
1269        config: &LintExecutionConfig,
1270    ) -> LintResult {
1271        // Helper function to execute a recursive lint function over a directory.
1272        let mut result = lint_ok();
1273        root.walk(
1274            &walk_configuration().path_base(Path::new("/")),
1275            |e| -> Result<_> {
1276                let r = f(e, config)?;
1277                match r {
1278                    Ok(()) => Ok(ControlFlow::Continue(())),
1279                    Err(e) => {
1280                        result = Ok(Err(e));
1281                        Ok(ControlFlow::Break(()))
1282                    }
1283                }
1284            },
1285        )?;
1286        result
1287    }
1288
1289    #[test]
1290    fn test_non_utf8() {
1291        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
1292
1293        let root = &fixture().unwrap();
1294        let config = &LintExecutionConfig::default();
1295
1296        // Try to create some adversarial symlink situations to ensure the walk doesn't crash
1297        root.create_dir("subdir").unwrap();
1298        // Self-referential symlinks
1299        root.symlink("self", "self").unwrap();
1300        // Infinitely looping dir symlinks
1301        root.symlink("..", "subdir/parent").unwrap();
1302        // Broken symlinks
1303        root.symlink("does-not-exist", "broken").unwrap();
1304        // Out-of-scope symlinks
1305        root.symlink("../../x", "escape").unwrap();
1306        // Should be fine
1307        run_recursive_lint(root, check_utf8, config)
1308            .unwrap()
1309            .unwrap();
1310
1311        // But this will cause an issue
1312        let baddir = OsStr::from_bytes(b"subdir/2/bad\xffdir");
1313        root.create_dir("subdir/2").unwrap();
1314        root.create_dir(baddir).unwrap();
1315        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1316            unreachable!("Didn't fail");
1317        };
1318        assert_eq!(
1319            err.to_string(),
1320            r#"/subdir/2: Found non-utf8 filename "bad\xFFdir""#
1321        );
1322        root.remove_dir(baddir).unwrap(); // Get rid of the problem
1323        run_recursive_lint(root, check_utf8, config)
1324            .unwrap()
1325            .unwrap(); // Check it
1326
1327        // Create a new problem in the form of a regular file
1328        let badfile = OsStr::from_bytes(b"regular\xff");
1329        root.write(badfile, b"Hello, world!\n").unwrap();
1330        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1331            unreachable!("Didn't fail");
1332        };
1333        assert_eq!(
1334            err.to_string(),
1335            r#"/: Found non-utf8 filename "regular\xFF""#
1336        );
1337        root.remove_file(badfile).unwrap(); // Get rid of the problem
1338        run_recursive_lint(root, check_utf8, config)
1339            .unwrap()
1340            .unwrap(); // Check it
1341
1342        // And now test invalid symlink targets
1343        root.symlink(badfile, "subdir/good-name").unwrap();
1344        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1345            unreachable!("Didn't fail");
1346        };
1347        assert_eq!(
1348            err.to_string(),
1349            r#"/subdir/good-name: Found non-utf8 symlink target"#
1350        );
1351        root.remove_file("subdir/good-name").unwrap(); // Get rid of the problem
1352        run_recursive_lint(root, check_utf8, config)
1353            .unwrap()
1354            .unwrap(); // Check it
1355
1356        // Finally, test a self-referential symlink with an invalid name.
1357        // We should spot the invalid name before we check the target.
1358        root.symlink(badfile, badfile).unwrap();
1359        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1360            unreachable!("Didn't fail");
1361        };
1362        assert_eq!(
1363            err.to_string(),
1364            r#"/: Found non-utf8 filename "regular\xFF""#
1365        );
1366        root.remove_file(badfile).unwrap(); // Get rid of the problem
1367        run_recursive_lint(root, check_utf8, config)
1368            .unwrap()
1369            .unwrap(); // Check it
1370    }
1371
1372    #[test]
1373    fn test_baseimage_root() -> Result<()> {
1374        let td = fixture()?;
1375        let config = &LintExecutionConfig::default();
1376
1377        // An empty root should fail our test
1378        assert!(check_baseimage_root(&td, config).unwrap().is_err());
1379
1380        drop(td);
1381        let td = passing_fixture()?;
1382        check_baseimage_root(&td, config).unwrap().unwrap();
1383
1384        // Composefs-native images don't need /ostree...
1385        td.remove_file("ostree")?;
1386        assert!(check_baseimage_root(&td, config).unwrap().is_err());
1387        let conf = Utf8Path::new(setup_root_conf_path());
1388        td.create_dir_all(conf.parent().unwrap())?;
1389        td.write(conf, "")?;
1390        check_baseimage_root(&td, config).unwrap().unwrap();
1391        // ...but if they have it, it must still be correct
1392        td.create_dir("ostree")?;
1393        assert!(check_baseimage_root(&td, config).unwrap().is_err());
1394        td.remove_dir("ostree")?;
1395        // ...and they still need /sysroot
1396        td.remove_dir("sysroot")?;
1397        assert!(check_baseimage_root(&td, config).unwrap().is_err());
1398        Ok(())
1399    }
1400
1401    #[test]
1402    fn test_composefs() -> Result<()> {
1403        let td = fixture()?;
1404        let config = &LintExecutionConfig::default();
1405
1406        // An empty root should fail our test
1407        assert!(check_composefs(&td, config).unwrap().is_err());
1408
1409        drop(td);
1410        let td = passing_fixture()?;
1411        // This should pass as the fixture includes a valid composefs config.
1412        check_composefs(&td, config).unwrap().unwrap();
1413
1414        td.write(
1415            "usr/lib/ostree/prepare-root.conf",
1416            b"[composefs]\nenabled = false",
1417        )?;
1418        // Now it should fail because composefs is explicitly disabled.
1419        assert!(check_composefs(&td, config).unwrap().is_err());
1420
1421        // ...unless the image is composefs-native, where ostree's
1422        // configuration doesn't matter.
1423        let conf = Utf8Path::new(setup_root_conf_path());
1424        td.create_dir_all(conf.parent().unwrap())?;
1425        td.write(conf, "")?;
1426        check_composefs(&td, config).unwrap().unwrap();
1427
1428        Ok(())
1429    }
1430
1431    #[test]
1432    fn test_buildah_injected() -> Result<()> {
1433        let td = fixture()?;
1434        let config = &LintExecutionConfig::default();
1435        td.create_dir("etc")?;
1436        assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1437        td.write("etc/hostname", b"")?;
1438        assert!(check_buildah_injected(&td, config).unwrap().is_err());
1439        td.write("etc/hostname", b"some static hostname")?;
1440        assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1441        Ok(())
1442    }
1443
1444    #[test]
1445    fn test_list() {
1446        let mut r = Vec::new();
1447        lint_list(&mut r).unwrap();
1448        let lints: Vec<serde_yaml::Value> = serde_yaml::from_slice(&r).unwrap();
1449        assert_eq!(lints.len(), LINTS.len());
1450    }
1451
1452    #[test]
1453    fn test_format_items_no_truncate() -> Result<()> {
1454        let config = LintExecutionConfig { no_truncate: true };
1455        let header = "Test Header";
1456        let mut output_str = String::new();
1457
1458        // Test case 1: Empty iterator
1459        let items_empty: Vec<String> = vec![];
1460        format_items(&config, header, items_empty.iter(), &mut output_str)?;
1461        assert_eq!(output_str, "");
1462        output_str.clear();
1463
1464        // Test case 2: Iterator with one item
1465        let items_one = ["item1"];
1466        format_items(&config, header, items_one.iter(), &mut output_str)?;
1467        assert_eq!(output_str, "Test Header:\n  item1\n");
1468        output_str.clear();
1469
1470        // Test case 3: Iterator with multiple items
1471        let items_multiple = (1..=3).map(|v| format!("item{v}")).collect::<Vec<_>>();
1472        format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1473        assert_eq!(output_str, "Test Header:\n  item1\n  item2\n  item3\n");
1474        output_str.clear();
1475
1476        // Test case 4: Iterator with items > DEFAULT_TRUNCATED_OUTPUT
1477        let items_multiple = (1..=8).map(|v| format!("item{v}")).collect::<Vec<_>>();
1478        format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1479        assert_eq!(
1480            output_str,
1481            "Test Header:\n  item1\n  item2\n  item3\n  item4\n  item5\n  item6\n  item7\n  item8\n"
1482        );
1483        output_str.clear();
1484
1485        Ok(())
1486    }
1487
1488    #[test]
1489    fn test_format_items_truncate() -> Result<()> {
1490        let config = LintExecutionConfig::default();
1491        let header = "Test Header";
1492        let mut output_str = String::new();
1493
1494        // Test case 1: Empty iterator
1495        let items_empty: Vec<String> = vec![];
1496        format_items(&config, header, items_empty.iter(), &mut output_str)?;
1497        assert_eq!(output_str, "");
1498        output_str.clear();
1499
1500        // Test case 2: Iterator with fewer items than DEFAULT_TRUNCATED_OUTPUT
1501        let items_few = ["item1", "item2"];
1502        format_items(&config, header, items_few.iter(), &mut output_str)?;
1503        assert_eq!(output_str, "Test Header:\n  item1\n  item2\n");
1504        output_str.clear();
1505
1506        // Test case 3: Iterator with exactly DEFAULT_TRUNCATED_OUTPUT items
1507        let items_exact: Vec<_> = (0..DEFAULT_TRUNCATED_OUTPUT.get())
1508            .map(|i| format!("item{}", i + 1))
1509            .collect();
1510        format_items(&config, header, items_exact.iter(), &mut output_str)?;
1511        let mut expected_output = String::from("Test Header:\n");
1512        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1513            writeln!(expected_output, "  item{}", i + 1)?;
1514        }
1515        assert_eq!(output_str, expected_output);
1516        output_str.clear();
1517
1518        // Test case 4: Iterator with more items than DEFAULT_TRUNCATED_OUTPUT
1519        let items_many: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 2))
1520            .map(|i| format!("item{}", i + 1))
1521            .collect();
1522        format_items(&config, header, items_many.iter(), &mut output_str)?;
1523        let mut expected_output = String::from("Test Header:\n");
1524        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1525            writeln!(expected_output, "  item{}", i + 1)?;
1526        }
1527        writeln!(expected_output, "  ...and 2 more")?;
1528        assert_eq!(output_str, expected_output);
1529        output_str.clear();
1530
1531        // Test case 5: Iterator with one more item than DEFAULT_TRUNCATED_OUTPUT
1532        let items_one_more: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 1))
1533            .map(|i| format!("item{}", i + 1))
1534            .collect();
1535        format_items(&config, header, items_one_more.iter(), &mut output_str)?;
1536        let mut expected_output = String::from("Test Header:\n");
1537        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1538            writeln!(expected_output, "  item{}", i + 1)?;
1539        }
1540        writeln!(expected_output, "  ...and 1 more")?;
1541        assert_eq!(output_str, expected_output);
1542        output_str.clear();
1543
1544        Ok(())
1545    }
1546
1547    #[test]
1548    fn test_format_items_display_impl() -> Result<()> {
1549        let config = LintExecutionConfig::default();
1550        let header = "Numbers";
1551        let mut output_str = String::new();
1552
1553        let items_numbers = [1, 2, 3];
1554        format_items(&config, header, items_numbers.iter(), &mut output_str)?;
1555        similar_asserts::assert_eq!(output_str, "Numbers:\n  1\n  2\n  3\n");
1556
1557        Ok(())
1558    }
1559
1560    #[test]
1561    fn test_runtime_deps() -> Result<()> {
1562        let root = &fixture()?;
1563        let config = &LintExecutionConfig::default();
1564
1565        let bins = resolved_runtime_bins();
1566        let err = check_runtime_deps(root, config)?.unwrap_err();
1567        assert_eq!(
1568            err.to_string(),
1569            format!("Missing required runtime dependencies: {}", bins.join(", "))
1570        );
1571
1572        for bin in &bins {
1573            add_runtime_bin(root, bin)?;
1574        }
1575        check_runtime_deps(root, config)??;
1576
1577        let podman = bootc_utils::podman_bin();
1578        root.remove_file(format!("usr/bin/{podman}"))?;
1579        let err = check_runtime_deps(root, config)?.unwrap_err();
1580        assert_eq!(
1581            err.to_string(),
1582            format!("Missing required runtime dependencies: {podman}")
1583        );
1584
1585        assert_eq!(
1586            resolve_runtime_bins("dtool", "dtool"),
1587            ["chcon", "dtool", "ostree", "setpriv", "systemctl", "zstd"]
1588        );
1589        Ok(())
1590    }
1591
1592    #[test]
1593    fn test_runtime_deps_escape() -> Result<()> {
1594        let root = &fixture()?;
1595        root.create_dir("usr")?;
1596        // Same shape as /usr/local -> ../../var/usrlocal, on the directory
1597        // runtime dependency lookup actually searches.
1598        root.symlink_contents("../../var/usrbin", "usr/bin")?;
1599
1600        let config = &LintExecutionConfig::default();
1601        let mut out = Vec::new();
1602        let err = lint_inner(
1603            root,
1604            RootType::Alternative,
1605            config,
1606            LINTS
1607                .iter()
1608                .filter(|lint| lint.name != "runtime-deps")
1609                .map(|lint| lint.name),
1610            &mut out,
1611        )
1612        .expect_err("lookup failure must remain a runtime error");
1613
1614        let bin = resolved_runtime_bins()
1615            .into_iter()
1616            .next()
1617            .expect("runtime dependency");
1618        let attempted = format!("usr/bin/{bin}");
1619        assert_eq!(
1620            err.to_string(),
1621            "Unexpected runtime error running lint runtime-deps"
1622        );
1623        assert!(
1624            err.chain()
1625                .any(|source| source.to_string().contains(&attempted)),
1626            "{err:?}"
1627        );
1628        assert!(
1629            err.chain().any(|source| {
1630                source
1631                    .downcast_ref::<std::io::Error>()
1632                    .is_some_and(|io_err| {
1633                        io_err
1634                            .to_string()
1635                            .contains("a path led outside of the filesystem")
1636                    })
1637            }),
1638            "{err:?}"
1639        );
1640        Ok(())
1641    }
1642}