1#![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
32fn walk_configuration() -> WalkConfiguration<'static> {
39 WalkConfiguration::default().noxdev()
40}
41
42const BASEIMAGE_REF: &str = "usr/share/doc/bootc/baseimage/base";
44const API_DIRS: &[&str] = &["dev", "proc", "sys", "run", "tmp", "var"];
46
47const DEFAULT_TRUNCATED_OUTPUT: NonZeroUsize = const { NonZeroUsize::new(5).unwrap() };
49
50#[derive(thiserror::Error, Debug)]
52struct LintError(String);
53
54type LintResult = Result<std::result::Result<(), LintError>>;
57
58fn lint_ok() -> LintResult {
61 Ok(Ok(()))
62}
63
64fn 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#[derive(Debug)]
92enum LintFnTy {
93 Regular(LintFn),
95 Recursive(LintRecursiveFn),
97}
98#[distributed_slice]
99pub(crate) static LINTS: [Lint];
100
101#[derive(Debug, Serialize)]
103#[serde(rename_all = "kebab-case")]
104enum LintType {
105 Fatal,
108 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 #[serde(skip_serializing_if = "Option::is_none")]
135 root_type: Option<RootType>,
136}
137
138impl 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 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
212fn 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
249fn 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 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 let skipped = skipped_lints.len();
290 applicable_lints.sort_by(|a, b| a.name.cmp(b.name));
292 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 recursive_lints.is_empty() {
312 return Ok(ControlFlow::Break(()));
313 }
314 let mut this_iteration_errors = Vec::new();
317 for &lint in recursive_lints.iter() {
319 let f = match &lint.f {
320 LintFnTy::Regular(_) => unreachable!(),
322 LintFnTy::Recursive(f) => f,
323 };
324 match f(e, &config) {
326 Ok(Ok(())) => {}
327 o => this_iteration_errors.push((lint, o)),
328 }
329 }
330 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 results.extend(recursive_errors);
341 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 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#[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.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 if !etc_exists {
461 return lint_ok();
462 }
463 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#[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#[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 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 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
595fn check_baseimage_root_norecurse(dir: &Dir, _config: &LintExecutionConfig) -> LintResult {
597 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 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#[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 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 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 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
799const RUNTIME_ONLY_DIRS: &[&str] = &["run", "tmp"];
802
803const CONTAINER_RUNTIME_FILES: &[&str] = &["/run/systemd/resolve/stub-resolv.conf"];
810
811#[distributed_slice(LINTS)]
812static LINT_RUNTIME_ONLY_DIRS: Lint = Lint::new_warning(
813 "nonempty-run-tmp",
814 indoc! { r#"
815The `/run` and `/tmp` directories should be empty in container images.
816These directories are normally mounted as `tmpfs` at runtime
817(masking any content in the underlying image) and any content here is typically build-time
818artifacts that serve no purpose in the final image.
819"#},
820 check_runtime_only_dirs,
821);
822
823fn check_runtime_only_dirs(root: &Dir, config: &LintExecutionConfig) -> LintResult {
824 let mut found_content = BTreeSet::new();
825
826 for dirname in RUNTIME_ONLY_DIRS {
827 let Some(d) = root.open_dir_noxdev(dirname)? else {
832 continue;
833 };
834
835 d.walk(
836 &walk_configuration().path_base(Path::new(dirname)),
837 |entry| -> std::io::Result<_> {
838 if entry.dir.is_mountpoint(entry.filename)? == Some(true) {
841 return Ok(ControlFlow::Continue(()));
842 }
843
844 let full_path = Utf8Path::new("/").join(entry.path.to_string_lossy().as_ref());
845 found_content.insert(full_path);
846
847 Ok(ControlFlow::Continue(()))
848 },
849 )?;
850 }
851
852 prune_known_run_paths(&mut found_content);
856
857 if found_content.is_empty() {
858 return lint_ok();
859 }
860
861 let header = "Found content in runtime-only directories (/run, /tmp)";
862 let items = found_content.iter().map(PathQuotedDisplay::new);
863 format_lint_err_from_items(config, header, items)
864}
865
866fn prune_known_run_paths(paths: &mut BTreeSet<Utf8PathBuf>) {
877 let run_prefix = Utf8Path::new("/run");
878 for known in CONTAINER_RUNTIME_FILES {
879 let known = Utf8Path::new(known);
880 paths.remove(known);
881 let mut dir = known.parent();
884 while let Some(d) = dir {
885 if !d.starts_with(run_prefix) || d == run_prefix {
886 break;
887 }
888 let has_other_children = paths.iter().any(|p| p != d && p.starts_with(d));
889 if has_other_children {
890 break;
891 }
892 paths.remove(d);
893 dir = d.parent();
894 }
895 }
896}
897
898#[cfg(test)]
899mod tests {
900 use std::sync::LazyLock;
901
902 use super::*;
903
904 static ALTROOT_LINTS: LazyLock<usize> = LazyLock::new(|| {
905 LINTS
906 .iter()
907 .filter(|lint| lint.root_type != Some(RootType::Running))
908 .count()
909 });
910
911 fn fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
912 let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
914 Ok(tempdir)
915 }
916
917 fn passing_fixture() -> Result<cap_std_ext::cap_tempfile::TempDir> {
918 let root = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
920 for d in API_DIRS {
921 root.create_dir(d)?;
922 }
923 root.create_dir_all("usr/lib/modules/5.7.2")?;
924 root.write("usr/lib/modules/5.7.2/vmlinuz", "vmlinuz")?;
925
926 root.create_dir("boot")?;
927 root.create_dir("sysroot")?;
928 root.symlink_contents("sysroot/ostree", "ostree")?;
929
930 const PREPAREROOT_PATH: &str = "usr/lib/ostree/prepare-root.conf";
931 const PREPAREROOT: &str =
932 include_str!("../../../baseimage/base/usr/lib/ostree/prepare-root.conf");
933 root.create_dir_all(Utf8Path::new(PREPAREROOT_PATH).parent().unwrap())?;
934 root.atomic_write(PREPAREROOT_PATH, PREPAREROOT)?;
935
936 Ok(root)
937 }
938
939 #[test]
940 fn test_var_run() -> Result<()> {
941 let root = &fixture()?;
942 let config = &LintExecutionConfig::default();
943 check_var_run(root, config).unwrap().unwrap();
945 root.create_dir_all("var/run/foo")?;
946 assert!(check_var_run(root, config).unwrap().is_err());
947 root.remove_dir_all("var/run")?;
948 check_var_run(root, config).unwrap().unwrap();
950 Ok(())
951 }
952
953 #[test]
954 fn test_api() -> Result<()> {
955 let root = &passing_fixture()?;
956 let config = &LintExecutionConfig::default();
957 check_api_dirs(root, config).unwrap().unwrap();
959 root.remove_dir("var")?;
960 assert!(check_api_dirs(root, config).unwrap().is_err());
961 root.write("var", "a file for var")?;
962 assert!(check_api_dirs(root, config).unwrap().is_err());
963 Ok(())
964 }
965
966 #[test]
967 fn test_lint_main() -> Result<()> {
968 let root = &passing_fixture()?;
969 let config = &LintExecutionConfig::default();
970 let mut out = Vec::new();
971 let warnings = WarningDisposition::FatalWarnings;
972 let root_type = RootType::Alternative;
973 lint(root, warnings, root_type, [], &mut out, config.no_truncate).unwrap();
974 root.create_dir_all("var/run/foo")?;
975 let mut out = Vec::new();
976 assert!(lint(root, warnings, root_type, [], &mut out, config.no_truncate).is_err());
977 Ok(())
978 }
979
980 #[test]
981 fn test_lint_inner() -> Result<()> {
982 let root = &passing_fixture()?;
983 let config = &LintExecutionConfig::default();
984
985 let mut out = Vec::new();
987 let root_type = RootType::Alternative;
988 let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
989 let running_only_lints = LINTS.len().checked_sub(*ALTROOT_LINTS).unwrap();
990 assert_eq!(r.warnings, 0);
991 assert_eq!(r.fatal, 0);
992 assert_eq!(r.skipped, running_only_lints);
993 assert_eq!(r.passed, *ALTROOT_LINTS);
994
995 let r = lint_inner(root, root_type, config, ["var-log"], &mut out).unwrap();
996 root.create_dir_all("var/log/dnf")?;
998 root.write("var/log/dnf/dnf.log", b"dummy dnf log")?;
999 assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
1000 assert_eq!(r.fatal, 0);
1001 assert_eq!(r.skipped, running_only_lints + 1);
1002 assert_eq!(r.warnings, 0);
1003
1004 let mut out = Vec::new();
1006 let r = lint_inner(root, root_type, config, [], &mut out).unwrap();
1007 assert_eq!(r.passed, ALTROOT_LINTS.checked_sub(1).unwrap());
1008 assert_eq!(r.fatal, 0);
1009 assert_eq!(r.skipped, running_only_lints);
1010 assert_eq!(r.warnings, 1);
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn test_kernel_lint() -> Result<()> {
1016 let root = &fixture()?;
1017 let config = &LintExecutionConfig::default();
1018 check_kernel(root, config).unwrap().unwrap();
1020 root.create_dir_all("usr/lib/modules/5.7.2")?;
1021 root.write("usr/lib/modules/5.7.2/vmlinuz", "old vmlinuz")?;
1022 root.create_dir_all("usr/lib/modules/6.3.1")?;
1023 root.write("usr/lib/modules/6.3.1/vmlinuz", "new vmlinuz")?;
1024 assert!(check_kernel(root, config).is_err());
1025 root.remove_dir_all("usr/lib/modules/5.7.2")?;
1026 check_kernel(root, config).unwrap().unwrap();
1028 Ok(())
1029 }
1030
1031 #[test]
1032 fn test_kargs() -> Result<()> {
1033 let root = &fixture()?;
1034 let config = &LintExecutionConfig::default();
1035 check_parse_kargs(root, config).unwrap().unwrap();
1036 root.create_dir_all("usr/lib/bootc")?;
1037 root.write("usr/lib/bootc/kargs.d", "not a directory")?;
1038 assert!(check_parse_kargs(root, config).is_err());
1039 Ok(())
1040 }
1041
1042 #[test]
1043 fn test_usr_etc() -> Result<()> {
1044 let root = &fixture()?;
1045 let config = &LintExecutionConfig::default();
1046 check_usretc(root, config).unwrap().unwrap();
1048 root.create_dir_all("etc")?;
1049 root.create_dir_all("usr/etc")?;
1050 assert!(check_usretc(root, config).unwrap().is_err());
1051 root.remove_dir_all("etc")?;
1052 check_usretc(root, config).unwrap().unwrap();
1054 Ok(())
1055 }
1056
1057 #[test]
1058 fn test_varlog() -> Result<()> {
1059 let root = &fixture()?;
1060 let config = &LintExecutionConfig::default();
1061 check_varlog(root, config).unwrap().unwrap();
1062 root.create_dir_all("var/log")?;
1063 check_varlog(root, config).unwrap().unwrap();
1064 root.symlink_contents("../../usr/share/doc/systemd/README.logs", "var/log/README")?;
1065 check_varlog(root, config).unwrap().unwrap();
1066
1067 root.atomic_write("var/log/somefile.log", "log contents")?;
1068 let Err(e) = check_varlog(root, config).unwrap() else {
1069 unreachable!()
1070 };
1071 similar_asserts::assert_eq!(
1072 e.to_string(),
1073 "Found non-empty logfiles:\n /var/log/somefile.log\n"
1074 );
1075 root.create_dir_all("var/log/someproject")?;
1076 root.atomic_write("var/log/someproject/audit.log", "audit log")?;
1077 root.atomic_write("var/log/someproject/info.log", "info")?;
1078 let Err(e) = check_varlog(root, config).unwrap() else {
1079 unreachable!()
1080 };
1081 similar_asserts::assert_eq!(
1082 e.to_string(),
1083 indoc! { r#"
1084 Found non-empty logfiles:
1085 /var/log/somefile.log
1086 /var/log/someproject/audit.log
1087 /var/log/someproject/info.log
1088 "# }
1089 );
1090
1091 Ok(())
1092 }
1093
1094 #[test]
1095 fn test_boot() -> Result<()> {
1096 let root = &passing_fixture()?;
1097 let config = &LintExecutionConfig::default();
1098 check_boot(&root, config).unwrap().unwrap();
1099
1100 root.create_dir_all("EFI/Linux")?;
1102 root.write("EFI/Linux/foo.efi", b"some dummy efi")?;
1103 check_boot(&root, config).unwrap().unwrap();
1104
1105 root.create_dir("boot/somesubdir")?;
1106 let Err(e) = check_boot(&root, config).unwrap() else {
1107 unreachable!()
1108 };
1109 assert!(e.to_string().contains("somesubdir"));
1110
1111 Ok(())
1112 }
1113
1114 #[test]
1115 fn test_runtime_only_dirs() -> Result<()> {
1116 let root = &fixture()?;
1117 let config = &LintExecutionConfig::default();
1118
1119 root.create_dir_all("run")?;
1120 root.create_dir_all("tmp")?;
1121
1122 root.create_dir_all("run/systemd/resolve")?;
1128 check_runtime_only_dirs(root, config).unwrap().unwrap();
1129
1130 root.write("run/systemd/resolve/stub-resolv.conf", "data")?;
1132 check_runtime_only_dirs(root, config).unwrap().unwrap();
1133 root.remove_file("run/systemd/resolve/stub-resolv.conf")?;
1134
1135 root.write("run/systemd/resolve/something-else", "data")?;
1139 let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1140 unreachable!("expected warning for unknown file under /run/systemd")
1141 };
1142 let msg = e.to_string();
1143 assert!(
1144 msg.contains("/run/systemd/resolve/something-else"),
1145 "should warn about the unknown file: {msg}"
1146 );
1147 assert!(
1149 msg.contains("/run/systemd"),
1150 "parent dirs with real children should appear: {msg}"
1151 );
1152 root.remove_file("run/systemd/resolve/something-else")?;
1153
1154 root.create_dir("run/dnf")?;
1156 let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1157 unreachable!("expected warning for /run/dnf")
1158 };
1159 let msg = e.to_string();
1160 assert!(
1161 msg.contains("/run/dnf"),
1162 "should warn about /run/dnf: {msg}"
1163 );
1164 assert!(
1165 !msg.contains("/run/systemd"),
1166 "should not mention /run/systemd: {msg}"
1167 );
1168 root.remove_dir("run/dnf")?;
1169
1170 root.write("run/leaked-file", "data")?;
1172 let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1173 unreachable!("expected warning for /run/leaked-file")
1174 };
1175 assert!(e.to_string().contains("/run/leaked-file"));
1176 root.remove_file("run/leaked-file")?;
1177
1178 root.write("tmp/build-artifact", "some data")?;
1180 let Err(e) = check_runtime_only_dirs(root, config).unwrap() else {
1181 unreachable!("expected warning for /tmp/build-artifact")
1182 };
1183 assert!(e.to_string().contains("/tmp/build-artifact"));
1184 root.remove_file("tmp/build-artifact")?;
1185
1186 check_runtime_only_dirs(root, config).unwrap().unwrap();
1188
1189 Ok(())
1190 }
1191
1192 fn run_recursive_lint(
1193 root: &Dir,
1194 f: LintRecursiveFn,
1195 config: &LintExecutionConfig,
1196 ) -> LintResult {
1197 let mut result = lint_ok();
1199 root.walk(
1200 &walk_configuration().path_base(Path::new("/")),
1201 |e| -> Result<_> {
1202 let r = f(e, config)?;
1203 match r {
1204 Ok(()) => Ok(ControlFlow::Continue(())),
1205 Err(e) => {
1206 result = Ok(Err(e));
1207 Ok(ControlFlow::Break(()))
1208 }
1209 }
1210 },
1211 )?;
1212 result
1213 }
1214
1215 #[test]
1216 fn test_non_utf8() {
1217 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
1218
1219 let root = &fixture().unwrap();
1220 let config = &LintExecutionConfig::default();
1221
1222 root.create_dir("subdir").unwrap();
1224 root.symlink("self", "self").unwrap();
1226 root.symlink("..", "subdir/parent").unwrap();
1228 root.symlink("does-not-exist", "broken").unwrap();
1230 root.symlink("../../x", "escape").unwrap();
1232 run_recursive_lint(root, check_utf8, config)
1234 .unwrap()
1235 .unwrap();
1236
1237 let baddir = OsStr::from_bytes(b"subdir/2/bad\xffdir");
1239 root.create_dir("subdir/2").unwrap();
1240 root.create_dir(baddir).unwrap();
1241 let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1242 unreachable!("Didn't fail");
1243 };
1244 assert_eq!(
1245 err.to_string(),
1246 r#"/subdir/2: Found non-utf8 filename "bad\xFFdir""#
1247 );
1248 root.remove_dir(baddir).unwrap(); run_recursive_lint(root, check_utf8, config)
1250 .unwrap()
1251 .unwrap(); let badfile = OsStr::from_bytes(b"regular\xff");
1255 root.write(badfile, b"Hello, world!\n").unwrap();
1256 let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1257 unreachable!("Didn't fail");
1258 };
1259 assert_eq!(
1260 err.to_string(),
1261 r#"/: Found non-utf8 filename "regular\xFF""#
1262 );
1263 root.remove_file(badfile).unwrap(); run_recursive_lint(root, check_utf8, config)
1265 .unwrap()
1266 .unwrap(); root.symlink(badfile, "subdir/good-name").unwrap();
1270 let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1271 unreachable!("Didn't fail");
1272 };
1273 assert_eq!(
1274 err.to_string(),
1275 r#"/subdir/good-name: Found non-utf8 symlink target"#
1276 );
1277 root.remove_file("subdir/good-name").unwrap(); run_recursive_lint(root, check_utf8, config)
1279 .unwrap()
1280 .unwrap(); root.symlink(badfile, badfile).unwrap();
1285 let Err(err) = run_recursive_lint(root, check_utf8, config).unwrap() else {
1286 unreachable!("Didn't fail");
1287 };
1288 assert_eq!(
1289 err.to_string(),
1290 r#"/: Found non-utf8 filename "regular\xFF""#
1291 );
1292 root.remove_file(badfile).unwrap(); run_recursive_lint(root, check_utf8, config)
1294 .unwrap()
1295 .unwrap(); }
1297
1298 #[test]
1299 fn test_baseimage_root() -> Result<()> {
1300 let td = fixture()?;
1301 let config = &LintExecutionConfig::default();
1302
1303 assert!(check_baseimage_root(&td, config).unwrap().is_err());
1305
1306 drop(td);
1307 let td = passing_fixture()?;
1308 check_baseimage_root(&td, config).unwrap().unwrap();
1309 Ok(())
1310 }
1311
1312 #[test]
1313 fn test_composefs() -> Result<()> {
1314 let td = fixture()?;
1315 let config = &LintExecutionConfig::default();
1316
1317 assert!(check_composefs(&td, config).unwrap().is_err());
1319
1320 drop(td);
1321 let td = passing_fixture()?;
1322 check_composefs(&td, config).unwrap().unwrap();
1324
1325 td.write(
1326 "usr/lib/ostree/prepare-root.conf",
1327 b"[composefs]\nenabled = false",
1328 )?;
1329 assert!(check_composefs(&td, config).unwrap().is_err());
1331
1332 Ok(())
1333 }
1334
1335 #[test]
1336 fn test_buildah_injected() -> Result<()> {
1337 let td = fixture()?;
1338 let config = &LintExecutionConfig::default();
1339 td.create_dir("etc")?;
1340 assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1341 td.write("etc/hostname", b"")?;
1342 assert!(check_buildah_injected(&td, config).unwrap().is_err());
1343 td.write("etc/hostname", b"some static hostname")?;
1344 assert!(check_buildah_injected(&td, config).unwrap().is_ok());
1345 Ok(())
1346 }
1347
1348 #[test]
1349 fn test_list() {
1350 let mut r = Vec::new();
1351 lint_list(&mut r).unwrap();
1352 let lints: Vec<serde_yaml::Value> = serde_yaml::from_slice(&r).unwrap();
1353 assert_eq!(lints.len(), LINTS.len());
1354 }
1355
1356 #[test]
1357 fn test_format_items_no_truncate() -> Result<()> {
1358 let config = LintExecutionConfig { no_truncate: true };
1359 let header = "Test Header";
1360 let mut output_str = String::new();
1361
1362 let items_empty: Vec<String> = vec![];
1364 format_items(&config, header, items_empty.iter(), &mut output_str)?;
1365 assert_eq!(output_str, "");
1366 output_str.clear();
1367
1368 let items_one = ["item1"];
1370 format_items(&config, header, items_one.iter(), &mut output_str)?;
1371 assert_eq!(output_str, "Test Header:\n item1\n");
1372 output_str.clear();
1373
1374 let items_multiple = (1..=3).map(|v| format!("item{v}")).collect::<Vec<_>>();
1376 format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1377 assert_eq!(output_str, "Test Header:\n item1\n item2\n item3\n");
1378 output_str.clear();
1379
1380 let items_multiple = (1..=8).map(|v| format!("item{v}")).collect::<Vec<_>>();
1382 format_items(&config, header, items_multiple.iter(), &mut output_str)?;
1383 assert_eq!(
1384 output_str,
1385 "Test Header:\n item1\n item2\n item3\n item4\n item5\n item6\n item7\n item8\n"
1386 );
1387 output_str.clear();
1388
1389 Ok(())
1390 }
1391
1392 #[test]
1393 fn test_format_items_truncate() -> Result<()> {
1394 let config = LintExecutionConfig::default();
1395 let header = "Test Header";
1396 let mut output_str = String::new();
1397
1398 let items_empty: Vec<String> = vec![];
1400 format_items(&config, header, items_empty.iter(), &mut output_str)?;
1401 assert_eq!(output_str, "");
1402 output_str.clear();
1403
1404 let items_few = ["item1", "item2"];
1406 format_items(&config, header, items_few.iter(), &mut output_str)?;
1407 assert_eq!(output_str, "Test Header:\n item1\n item2\n");
1408 output_str.clear();
1409
1410 let items_exact: Vec<_> = (0..DEFAULT_TRUNCATED_OUTPUT.get())
1412 .map(|i| format!("item{}", i + 1))
1413 .collect();
1414 format_items(&config, header, items_exact.iter(), &mut output_str)?;
1415 let mut expected_output = String::from("Test Header:\n");
1416 for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1417 writeln!(expected_output, " item{}", i + 1)?;
1418 }
1419 assert_eq!(output_str, expected_output);
1420 output_str.clear();
1421
1422 let items_many: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 2))
1424 .map(|i| format!("item{}", i + 1))
1425 .collect();
1426 format_items(&config, header, items_many.iter(), &mut output_str)?;
1427 let mut expected_output = String::from("Test Header:\n");
1428 for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1429 writeln!(expected_output, " item{}", i + 1)?;
1430 }
1431 writeln!(expected_output, " ...and 2 more")?;
1432 assert_eq!(output_str, expected_output);
1433 output_str.clear();
1434
1435 let items_one_more: Vec<_> = (0..(DEFAULT_TRUNCATED_OUTPUT.get() + 1))
1437 .map(|i| format!("item{}", i + 1))
1438 .collect();
1439 format_items(&config, header, items_one_more.iter(), &mut output_str)?;
1440 let mut expected_output = String::from("Test Header:\n");
1441 for i in 0..DEFAULT_TRUNCATED_OUTPUT.get() {
1442 writeln!(expected_output, " item{}", i + 1)?;
1443 }
1444 writeln!(expected_output, " ...and 1 more")?;
1445 assert_eq!(output_str, expected_output);
1446 output_str.clear();
1447
1448 Ok(())
1449 }
1450
1451 #[test]
1452 fn test_format_items_display_impl() -> Result<()> {
1453 let config = LintExecutionConfig::default();
1454 let header = "Numbers";
1455 let mut output_str = String::new();
1456
1457 let items_numbers = [1, 2, 3];
1458 format_items(&config, header, items_numbers.iter(), &mut output_str)?;
1459 similar_asserts::assert_eq!(output_str, "Numbers:\n 1\n 2\n 3\n");
1460
1461 Ok(())
1462 }
1463}