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
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 root.create_dir_all("run")?;
1072 root.create_dir_all("tmp")?;
1073 check_runtime_only_dirs(root, config).unwrap().unwrap();
1074
1075 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 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 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 root.create_dir("subdir").unwrap();
1128 root.symlink("self", "self").unwrap();
1130 root.symlink("..", "subdir/parent").unwrap();
1132 root.symlink("does-not-exist", "broken").unwrap();
1134 root.symlink("../../x", "escape").unwrap();
1136 run_recursive_lint(root, check_utf8, config)
1138 .unwrap()
1139 .unwrap();
1140
1141 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(); run_recursive_lint(root, check_utf8, config)
1154 .unwrap()
1155 .unwrap(); 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(); run_recursive_lint(root, check_utf8, config)
1169 .unwrap()
1170 .unwrap(); 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(); run_recursive_lint(root, check_utf8, config)
1183 .unwrap()
1184 .unwrap(); 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(); run_recursive_lint(root, check_utf8, config)
1198 .unwrap()
1199 .unwrap(); }
1201
1202 #[test]
1203 fn test_baseimage_root() -> Result<()> {
1204 let td = fixture()?;
1205 let config = &LintExecutionConfig::default();
1206
1207 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 assert!(check_composefs(&td, config).unwrap().is_err());
1223
1224 drop(td);
1225 let td = passing_fixture()?;
1226 check_composefs(&td, config).unwrap().unwrap();
1228
1229 td.write(
1230 "usr/lib/ostree/prepare-root.conf",
1231 b"[composefs]\nenabled = false",
1232 )?;
1233 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 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 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 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 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 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 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 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 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 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}