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