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#[distributed_slice(LINTS)]
804static LINT_RUNTIME_ONLY_DIRS: Lint = Lint::new_warning(
805    "nonempty-run-tmp",
806    indoc! { r#"
807The `/run` and `/tmp` directories should be empty in container images.
808These directories are normally mounted as `tmpfs` at runtime
809(masking any content in the underlying image) and any content here is typically build-time
810artifacts that serve no purpose in the final image.
811"#},
812    check_runtime_only_dirs,
813);
814
815fn check_runtime_only_dirs(root: &Dir, config: &LintExecutionConfig) -> LintResult {
816    let mut found_content = BTreeSet::new();
817
818    for dirname in RUNTIME_ONLY_DIRS {
819        let Some(d) = root.open_dir_optional(dirname)? else {
820            continue;
821        };
822
823        d.walk(
824            &walk_configuration().path_base(Path::new(dirname)),
825            |entry| -> std::io::Result<_> {
826                // Skip mount points (bind mounts, tmpfs, etc.) - these are
827                // container-runtime injected content like .containerenv
828                if entry.dir.is_mountpoint(entry.filename)? == Some(true) {
829                    return Ok(ControlFlow::Continue(()));
830                }
831
832                let full_path = Utf8Path::new("/").join(entry.path.to_string_lossy().as_ref());
833                found_content.insert(full_path);
834
835                Ok(ControlFlow::Continue(()))
836            },
837        )?;
838    }
839
840    if found_content.is_empty() {
841        return lint_ok();
842    }
843
844    let header = "Found content in runtime-only directories (/run, /tmp)";
845    let items = found_content.iter().map(PathQuotedDisplay::new);
846    format_lint_err_from_items(config, header, items)
847}
848
849#[cfg(test)]
850mod tests {
851    use std::sync::LazyLock;
852
853    use super::*;
854
855    static ALTROOT_LINTS: LazyLock<usize> = LazyLock::new(|| {
856        LINTS
857            .iter()
858            .filter(|lint| lint.root_type != Some(RootType::Running))
859            .count()
860    });
861
862    fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
863        // Create a new temporary directory for test fixtures.
864        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
865        Ok(tempdir)
866    }
867
868    fn passing_fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
869        // Create a temporary directory fixture that is expected to pass most lints.
870        let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
871        for d in API_DIRS {
872            root.create_dir(d)?;
873        }
874        root.create_dir_all("usr/lib/modules/5.7.2")?;
875        root.write("usr/lib/modules/5.7.2/vmlinuz", "vmlinuz")?;
876
877        root.create_dir("boot")?;
878        root.create_dir("sysroot")?;
879        root.symlink_contents("sysroot/ostree", "ostree")?;
880
881        const PREPAREROOT_PATH: &str = "usr/lib/ostree/prepare-root.conf";
882        const PREPAREROOT: &str =
883            include_str!("../../../baseimage/base/usr/lib/ostree/prepare-root.conf");
884        root.create_dir_all(Utf8Path::new(PREPAREROOT_PATH).parent().unwrap())?;
885        root.atomic_write(PREPAREROOT_PATH, PREPAREROOT)?;
886
887        Ok(root)
888    }
889
890    #[test]
891    fn test_var_run() -> Result<()> {
892        let root = &fixture()?;
893        let config = &LintExecutionConfig::default();
894        // This one should pass
895        check_var_run(root, config).unwrap().unwrap();
896        root.create_dir_all("var/run/foo")?;
897        assert!(check_var_run(root, config).unwrap().is_err());
898        root.remove_dir_all("var/run")?;
899        // Now we should pass again
900        check_var_run(root, config).unwrap().unwrap();
901        Ok(())
902    }
903
904    #[test]
905    fn test_api() -> Result<()> {
906        let root = &passing_fixture()?;
907        let config = &LintExecutionConfig::default();
908        // This one should pass
909        check_api_dirs(root, config).unwrap().unwrap();
910        root.remove_dir("var")?;
911        assert!(check_api_dirs(root, config).unwrap().is_err());
912        root.write("var", "a file for var")?;
913        assert!(check_api_dirs(root, config).unwrap().is_err());
914        Ok(())
915    }
916
917    #[test]
918    fn test_lint_main() -> Result<()> {
919        let root = &passing_fixture()?;
920        let config = &LintExecutionConfig::default();
921        let mut out = Vec::new();
922        let warnings = WarningDisposition::FatalWarnings;
923        let root_type = RootType::Alternative;
924        lint(root, warnings, root_type, [], &mut out, config.no_truncate).unwrap();
925        root.create_dir_all("var/run/foo")?;
926        let mut out = Vec::new();
927        assert!(lint(root, warnings, root_type, [], &mut out, config.no_truncate).is_err());
928        Ok(())
929    }
930
931    #[test]
932    fn test_lint_inner() -> Result<()> {
933        let root = &passing_fixture()?;
934        let config = &LintExecutionConfig::default();
935
936        // Verify that all lints run
937        let mut out = Vec::new();
938        let root_type = RootType::Alternative;
939        let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
940        let running_only_lints = LINTS.len().checked_sub(*ALTROOT_LINTS).unwrap();
941        assert_eq!(r.warnings, 0);
942        assert_eq!(r.fatal, 0);
943        assert_eq!(r.skipped, running_only_lints);
944        assert_eq!(r.passed, *ALTROOT_LINTS);
945
946        let r = lint_inner(root, root_type, config, ["var-log"], &mut out).unwrap();
947        // Trigger a failure in var-log by creating a non-empty log file.
948        root.create_dir_all("var/log/dnf")?;
949        root.write("var/log/dnf/dnf.log", b"dummy dnf log")?;
950        assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
951        assert_eq!(r.fatal, 0);
952        assert_eq!(r.skipped, running_only_lints + 1);
953        assert_eq!(r.warnings, 0);
954
955        // But verify that not skipping it results in a warning
956        let mut out = Vec::new();
957        let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
958        assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
959        assert_eq!(r.fatal, 0);
960        assert_eq!(r.skipped, running_only_lints);
961        assert_eq!(r.warnings, 1);
962        Ok(())
963    }
964
965    #[test]
966    fn test_kernel_lint() -> Result<()> {
967        let root = &fixture()?;
968        let config = &LintExecutionConfig::default();
969        // This one should pass
970        check_kernel(root, config).unwrap().unwrap();
971        root.create_dir_all("usr/lib/modules/5.7.2")?;
972        root.write("usr/lib/modules/5.7.2/vmlinuz", "old vmlinuz")?;
973        root.create_dir_all("usr/lib/modules/6.3.1")?;
974        root.write("usr/lib/modules/6.3.1/vmlinuz", "new vmlinuz")?;
975        assert!(check_kernel(root, config).is_err());
976        root.remove_dir_all("usr/lib/modules/5.7.2")?;
977        // Now we should pass again
978        check_kernel(root, config).unwrap().unwrap();
979        Ok(())
980    }
981
982    #[test]
983    fn test_kargs() -> Result<()> {
984        let root = &fixture()?;
985        let config = &LintExecutionConfig::default();
986        check_parse_kargs(root, config).unwrap().unwrap();
987        root.create_dir_all("usr/lib/bootc")?;
988        root.write("usr/lib/bootc/kargs.d", "not a directory")?;
989        assert!(check_parse_kargs(root, config).is_err());
990        Ok(())
991    }
992
993    #[test]
994    fn test_usr_etc() -> Result<()> {
995        let root = &fixture()?;
996        let config = &LintExecutionConfig::default();
997        // This one should pass
998        check_usretc(root, config).unwrap().unwrap();
999        root.create_dir_all("etc")?;
1000        root.create_dir_all("usr/etc")?;
1001        assert!(check_usretc(root, config).unwrap().is_err());
1002        root.remove_dir_all("etc")?;
1003        // Now we should pass again
1004        check_usretc(root, config).unwrap().unwrap();
1005        Ok(())
1006    }
1007
1008    #[test]
1009    fn test_varlog() -> Result<()> {
1010        let root = &fixture()?;
1011        let config = &LintExecutionConfig::default();
1012        check_varlog(root, config).unwrap().unwrap();
1013        root.create_dir_all("var/log")?;
1014        check_varlog(root, config).unwrap().unwrap();
1015        root.symlink_contents("../../usr/share/doc/systemd/README.logs", "var/log/README")?;
1016        check_varlog(root, config).unwrap().unwrap();
1017
1018        root.atomic_write("var/log/somefile.log", "log contents")?;
1019        let Err(e) = check_varlog(root, config).unwrap() else {
1020            unreachable!()
1021        };
1022        similar_asserts::assert_eq!(
1023            e.to_string(),
1024            "Found non-empty logfiles:\n  /var/log/somefile.log\n"
1025        );
1026        root.create_dir_all("var/log/someproject")?;
1027        root.atomic_write("var/log/someproject/audit.log", "audit log")?;
1028        root.atomic_write("var/log/someproject/info.log", "info")?;
1029        let Err(e) = check_varlog(root, config).unwrap() else {
1030            unreachable!()
1031        };
1032        similar_asserts::assert_eq!(
1033            e.to_string(),
1034            indoc! { r#"
1035                Found non-empty logfiles:
1036                  /var/log/somefile.log
1037                  /var/log/someproject/audit.log
1038                  /var/log/someproject/info.log
1039                "# }
1040        );
1041
1042        Ok(())
1043    }
1044
1045    #[test]
1046    fn test_boot() -> Result<()> {
1047        let root = &passing_fixture()?;
1048        let config = &LintExecutionConfig::default();
1049        check_boot(&root, config).unwrap().unwrap();
1050
1051        // Verify creating EFI doesn't error
1052        root.create_dir_all("EFI/Linux")?;
1053        root.write("EFI/Linux/foo.efi", b"some dummy efi")?;
1054        check_boot(&root, config).unwrap().unwrap();
1055
1056        root.create_dir("boot/somesubdir")?;
1057        let Err(e) = check_boot(&root, config).unwrap() else {
1058            unreachable!()
1059        };
1060        assert!(e.to_string().contains("somesubdir"));
1061
1062        Ok(())
1063    }
1064
1065    #[test]
1066    fn test_runtime_only_dirs() -> Result<()> {
1067        let root = &fixture()?;
1068        let config = &LintExecutionConfig::default();
1069
1070        // Empty directories should pass
1071        root.create_dir_all("run")?;
1072        root.create_dir_all("tmp")?;
1073        check_runtime_only_dirs(root, config).unwrap().unwrap();
1074
1075        // Content in /run should fail
1076        root.create_dir("run/some-mount-stub")?;
1077        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1078            unreachable!()
1079        };
1080        assert!(e.to_string().contains("/run/some-mount-stub"));
1081        root.remove_dir("run/some-mount-stub")?;
1082        check_runtime_only_dirs(root, config).unwrap().unwrap();
1083
1084        // Content in /tmp should fail
1085        root.write("tmp/build-artifact", "some data")?;
1086        let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1087            unreachable!()
1088        };
1089        assert!(e.to_string().contains("/tmp/build-artifact"));
1090        root.remove_file("tmp/build-artifact")?;
1091        check_runtime_only_dirs(root, config).unwrap().unwrap();
1092
1093        Ok(())
1094    }
1095
1096    fn run_recursive_lint(
1097        root: &Dir,
1098        f: LintRecursiveFn,
1099        config: &LintExecutionConfig,
1100    ) -> LintResult {
1101        // Helper function to execute a recursive lint function over a directory.
1102        let mut result = lint_ok();
1103        root.walk(
1104            &walk_configuration().path_base(Path::new("/")),
1105            |e| -> Result<_> {
1106                let r = f(e, config)?;
1107                match r {
1108                    Ok(()) => Ok(ControlFlow::Continue(())),
1109                    Err(e) => {
1110                        result = Ok(Err(e));
1111                        Ok(ControlFlow::Break(()))
1112                    }
1113                }
1114            },
1115        )?;
1116        result
1117    }
1118
1119    #[test]
1120    fn test_non_utf8() {
1121        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
1122
1123        let root = &fixture().unwrap();
1124        let config = &LintExecutionConfig::default();
1125
1126        // Try to create some adversarial symlink situations to ensure the walk doesn't crash
1127        root.create_dir("subdir").unwrap();
1128        // Self-referential symlinks
1129        root.symlink("self", "self").unwrap();
1130        // Infinitely looping dir symlinks
1131        root.symlink("..", "subdir/parent").unwrap();
1132        // Broken symlinks
1133        root.symlink("does-not-exist", "broken").unwrap();
1134        // Out-of-scope symlinks
1135        root.symlink("../../x", "escape").unwrap();
1136        // Should be fine
1137        run_recursive_lint(root, check_utf8, config)
1138            .unwrap()
1139            .unwrap();
1140
1141        // But this will cause an issue
1142        let baddir = OsStr::from_bytes(b"subdir/2/bad\xffdir");
1143        root.create_dir("subdir/2").unwrap();
1144        root.create_dir(baddir).unwrap();
1145        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1146            unreachable!("Didn't fail");
1147        };
1148        assert_eq!(
1149            err.to_string(),
1150            r#"/subdir/2: Found non-utf8 filename "bad\xFFdir""#
1151        );
1152        root.remove_dir(baddir).unwrap(); // Get rid of the problem
1153        run_recursive_lint(root, check_utf8, config)
1154            .unwrap()
1155            .unwrap(); // Check it
1156
1157        // Create a new problem in the form of a regular file
1158        let badfile = OsStr::from_bytes(b"regular\xff");
1159        root.write(badfile, b"Hello, world!\n").unwrap();
1160        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1161            unreachable!("Didn't fail");
1162        };
1163        assert_eq!(
1164            err.to_string(),
1165            r#"/: Found non-utf8 filename "regular\xFF""#
1166        );
1167        root.remove_file(badfile).unwrap(); // Get rid of the problem
1168        run_recursive_lint(root, check_utf8, config)
1169            .unwrap()
1170            .unwrap(); // Check it
1171
1172        // And now test invalid symlink targets
1173        root.symlink(badfile, "subdir/good-name").unwrap();
1174        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1175            unreachable!("Didn't fail");
1176        };
1177        assert_eq!(
1178            err.to_string(),
1179            r#"/subdir/good-name: Found non-utf8 symlink target"#
1180        );
1181        root.remove_file("subdir/good-name").unwrap(); // Get rid of the problem
1182        run_recursive_lint(root, check_utf8, config)
1183            .unwrap()
1184            .unwrap(); // Check it
1185
1186        // Finally, test a self-referential symlink with an invalid name.
1187        // We should spot the invalid name before we check the target.
1188        root.symlink(badfile, badfile).unwrap();
1189        let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1190            unreachable!("Didn't fail");
1191        };
1192        assert_eq!(
1193            err.to_string(),
1194            r#"/: Found non-utf8 filename "regular\xFF""#
1195        );
1196        root.remove_file(badfile).unwrap(); // Get rid of the problem
1197        run_recursive_lint(root, check_utf8, config)
1198            .unwrap()
1199            .unwrap(); // Check it
1200    }
1201
1202    #[test]
1203    fn test_baseimage_root() -> Result<()> {
1204        let td = fixture()?;
1205        let config = &LintExecutionConfig::default();
1206
1207        // An empty root should fail our test
1208        assert!(check_baseimage_root(&td, config).unwrap().is_err());
1209
1210        drop(td);
1211        let td = passing_fixture()?;
1212        check_baseimage_root(&td, config).unwrap().unwrap();
1213        Ok(())
1214    }
1215
1216    #[test]
1217    fn test_composefs() -> Result<()> {
1218        let td = fixture()?;
1219        let config = &LintExecutionConfig::default();
1220
1221        // An empty root should fail our test
1222        assert!(check_composefs(&td, config).unwrap().is_err());
1223
1224        drop(td);
1225        let td = passing_fixture()?;
1226        // This should pass as the fixture includes a valid composefs config.
1227        check_composefs(&td, config).unwrap().unwrap();
1228
1229        td.write(
1230            "usr/lib/ostree/prepare-root.conf",
1231            b"[composefs]\nenabled = false",
1232        )?;
1233        // Now it should fail because composefs is explicitly disabled.
1234        assert!(check_composefs(&td, config).unwrap().is_err());
1235
1236        Ok(())
1237    }
1238
1239    #[test]
1240    fn test_buildah_injected() -> Result<()> {
1241        let td = fixture()?;
1242        let config = &LintExecutionConfig::default();
1243        td.create_dir("etc")?;
1244        assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1245        td.write("etc/hostname", b"")?;
1246        assert!(check_buildah_injected(&td, config).unwrap().is_err());
1247        td.write("etc/hostname", b"some static hostname")?;
1248        assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1249        Ok(())
1250    }
1251
1252    #[test]
1253    fn test_list() {
1254        let mut r = Vec::new();
1255        lint_list(&mut r).unwrap();
1256        let lints: Vec<serde_yaml::Value> = serde_yaml::from_slice(&r).unwrap();
1257        assert_eq!(lints.len(), LINTS.len());
1258    }
1259
1260    #[test]
1261    fn test_format_items_no_truncate() -> Result<()> {
1262        let config = LintExecutionConfig { no_truncate: true };
1263        let header = "Test Header";
1264        let mut output_str = String::new();
1265
1266        // Test case 1: Empty iterator
1267        let items_empty: Vec<String> = vec![];
1268        format_items(&config, header, items_empty.iter(), &mut output_str)?;
1269        assert_eq!(output_str, "");
1270        output_str.clear();
1271
1272        // Test case 2: Iterator with one item
1273        let items_one = ["item1"];
1274        format_items(&config, header, items_one.iter(), &mut output_str)?;
1275        assert_eq!(output_str, "Test Header:\n  item1\n");
1276        output_str.clear();
1277
1278        // Test case 3: Iterator with multiple items
1279        let items_multiple = (1..=3).map(|v| format!("item{v}")).collect::<Vec<_>>();
1280        format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1281        assert_eq!(output_str, "Test Header:\n  item1\n  item2\n  item3\n");
1282        output_str.clear();
1283
1284        // Test case 4: Iterator with items > DEFAULT_TRUNCATED_OUTPUT
1285        let items_multiple = (1..=8).map(|v| format!("item{v}")).collect::<Vec<_>>();
1286        format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1287        assert_eq!(
1288            output_str,
1289            "Test Header:\n  item1\n  item2\n  item3\n  item4\n  item5\n  item6\n  item7\n  item8\n"
1290        );
1291        output_str.clear();
1292
1293        Ok(())
1294    }
1295
1296    #[test]
1297    fn test_format_items_truncate() -> Result<()> {
1298        let config = LintExecutionConfig::default();
1299        let header = "Test Header";
1300        let mut output_str = String::new();
1301
1302        // Test case 1: Empty iterator
1303        let items_empty: Vec<String> = vec![];
1304        format_items(&config, header, items_empty.iter(), &mut output_str)?;
1305        assert_eq!(output_str, "");
1306        output_str.clear();
1307
1308        // Test case 2: Iterator with fewer items than DEFAULT_TRUNCATED_OUTPUT
1309        let items_few = ["item1", "item2"];
1310        format_items(&config, header, items_few.iter(), &mut output_str)?;
1311        assert_eq!(output_str, "Test Header:\n  item1\n  item2\n");
1312        output_str.clear();
1313
1314        // Test case 3: Iterator with exactly DEFAULT_TRUNCATED_OUTPUT items
1315        let items_exact: Vec<_> = (0..DEFAULT_TRUNCATED_OUTPUT.get())
1316            .map(|i| format!("item{}", i + 1))
1317            .collect();
1318        format_items(&config, header, items_exact.iter(), &mut output_str)?;
1319        let mut expected_output = String::from("Test Header:\n");
1320        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1321            writeln!(expected_output, "  item{}", i + 1)?;
1322        }
1323        assert_eq!(output_str, expected_output);
1324        output_str.clear();
1325
1326        // Test case 4: Iterator with more items than DEFAULT_TRUNCATED_OUTPUT
1327        let items_many: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 2))
1328            .map(|i| format!("item{}", i + 1))
1329            .collect();
1330        format_items(&config, header, items_many.iter(), &mut output_str)?;
1331        let mut expected_output = String::from("Test Header:\n");
1332        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1333            writeln!(expected_output, "  item{}", i + 1)?;
1334        }
1335        writeln!(expected_output, "  ...and 2 more")?;
1336        assert_eq!(output_str, expected_output);
1337        output_str.clear();
1338
1339        // Test case 5: Iterator with one more item than DEFAULT_TRUNCATED_OUTPUT
1340        let items_one_more: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 1))
1341            .map(|i| format!("item{}", i + 1))
1342            .collect();
1343        format_items(&config, header, items_one_more.iter(), &mut output_str)?;
1344        let mut expected_output = String::from("Test Header:\n");
1345        for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1346            writeln!(expected_output, "  item{}", i + 1)?;
1347        }
1348        writeln!(expected_output, "  ...and 1 more")?;
1349        assert_eq!(output_str, expected_output);
1350        output_str.clear();
1351
1352        Ok(())
1353    }
1354
1355    #[test]
1356    fn test_format_items_display_impl() -> Result<()> {
1357        let config = LintExecutionConfig::default();
1358        let header = "Numbers";
1359        let mut output_str = String::new();
1360
1361        let items_numbers = [1, 2, 3];
1362        format_items(&config, header, items_numbers.iter(), &mut output_str)?;
1363        similar_asserts::assert_eq!(output_str, "Numbers:\n  1\n  2\n  3\n");
1364
1365        Ok(())
1366    }
1367}