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