//! The pre-start check of a container's root, run by systemd as root. //! //! nixpkgs' `container@.service` start script runs as host root against the //! container's own root, which is persistent and writable by the container. //! `mkdir -p`, `chmod`, `touch` and `cp --remove-destination` there follow a //! link the container planted at any of the names the script touches. An agent //! container's drop-in ([`crate::container_config`]) puts //! `hive-priv check-rootfs ` ahead of that script as an //! `ExecStartPre=`; a non-zero exit fails the start. The container is stopped //! while this runs, so nothing changes under the walk. //! //! Hyperhive's own declarative containers (the forge, the matrix server, the //! swarm services) run it as `check-rootfs --warn-only ` from their //! own unit (`nix/host-modules/lib/container-rootfs-check.nix`): each violation //! is logged at warning level and the start goes on. //! //! Every component is looked at with `symlink_metadata`: a link is reported, //! never followed. Nothing is deleted or repaired, so whatever is there stays //! for the operator. //! //! The drop-in names the running hive-priv binary, which nothing else roots //! once the system has moved on to a newer build. [`pin_binary`] keeps a GC //! root on it for as long as the drop-in exists, and [`unpin_binary`] drops //! the root with the drop-in. //! //! The entry point is [`run`]; [`check`] holds the rules. use std::fs::{FileType, Metadata}; use std::os::unix::fs::{DirBuilderExt as _, symlink}; use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result, bail}; use crate::container_config::{container_rootfs, validate_container_system_name}; use crate::shared::{partial_name, validate_name_chars}; /// The sub-command the drop-in's `ExecStartPre=` names. pub(crate) const SUBCOMMAND: &str = "check-rootfs"; /// The flag that turns a violation into a warning: the check runs in full and /// exits 0. const WARN_ONLY: &str = "--warn-only"; /// The per-container nix profile directories on the host. const PROFILES_ROOT: &str = "/nix/var/nix/profiles/per-container"; /// The nix store: where the profile's `system` link has to end up. const STORE: &str = "/nix/store"; /// Directories the start script creates or `chmod`s under the root. Each, /// with all its parents, has to be a real directory when it exists. const DIRS: [&str; 7] = [ "usr", "usr/bin", "etc", "var", "var/lib", "var/lib/private", "root", ]; /// Files under the root the start script `touch`es or replaces. /// /// `machine-id` and `resolv.conf` have to be regular files. A link at /// `machine-id` that dangles makes `touch` create its target, and no guest /// writes a link there. The script's `cp --remove-destination` replaces a link /// at `resolv.conf` rather than following it, but the guest keeps a regular /// file there too (`nix/agent-modules/network.nix`), so a link is not expected. const REGULAR_ONLY: [&str; 2] = ["etc/machine-id", "etc/resolv.conf"]; /// The one file allowed to be a link as well: the script skips it when /// `-h` says it is one, and a NixOS guest has `etc/os-release` as a link into /// `/etc/static` once it has booted. const OS_RELEASE: &str = "etc/os-release"; /// Where hive-priv roots the build named by each container's `ExecStartPre=`: /// `` is a symlink to that build's store path. The directory is /// under the host's `/nix/var/nix/gcroots`, not `per-container`, which is the /// only part of it a container is given (`--bind=.../gcroots/per-container/` /// in the nixpkgs start script), so a container can't reach it. const GCROOTS: &str = "/nix/var/nix/gcroots/hyperhive-check-rootfs"; /// Root the store path of `exe` for `container`, replacing the root a previous /// write left, so the build a drop-in names outlives a garbage collection for /// as long as the drop-in does. One link per container, so they don't pile up. pub(crate) fn pin_binary(container: &str, exe: &Path) -> Result<()> { pin_binary_in(Path::new(GCROOTS), Path::new(STORE), container, exe) } /// Drop the root [`pin_binary`] left for `container`, once its drop-in is gone. /// A name that holds anything but a link is left alone and reported. pub(crate) fn unpin_binary(container: &str) -> Result<()> { unpin_binary_in(Path::new(GCROOTS), container) } /// [`pin_binary`] with the roots directory and the store as parameters. /// /// A binary outside `store` can't be collected, so it gets no root. The link /// is made under a temporary name and renamed over ``: `rename` /// replaces whatever link is there without following it. fn pin_binary_in(roots: &Path, store: &Path, container: &str, exe: &Path) -> Result<()> { let Some(entry) = exe .strip_prefix(store) .ok() .and_then(|rest| rest.components().next()) else { return Ok(()); }; let target = store.join(entry); std::fs::DirBuilder::new() .recursive(true) .mode(0o755) .create(roots) .with_context(|| format!("create {}", roots.display()))?; if !lstat(roots)?.is_some_and(|m| m.file_type().is_dir()) { bail!("{} is not a real directory", roots.display()); } let tmp = roots.join(partial_name(container)); symlink(&target, &tmp).with_context(|| format!("create {}", tmp.display()))?; let root = roots.join(container); std::fs::rename(&tmp, &root).map_err(|e| { // Best effort: a leftover temp is a root too, but a harmless one. let _ = std::fs::remove_file(&tmp); anyhow::Error::new(e).context(format!("replace {}", root.display())) }) } /// [`unpin_binary`] with the roots directory as a parameter. fn unpin_binary_in(roots: &Path, container: &str) -> Result<()> { let root = roots.join(container); match lstat(&root)? { None => Ok(()), Some(meta) if meta.file_type().is_symlink() => { std::fs::remove_file(&root).with_context(|| format!("remove {}", root.display())) } Some(meta) => bail!( "{} is {}, not a link: left alone", root.display(), kind(meta.file_type()) ), } } /// Handle `hive-priv ...` when the first argument names one. /// `None` means it is not a sub-command and the daemon starts. /// /// `check-rootfs ` is strict: it prints one line to stderr and /// returns exit code 1 on a violation, so the reason lands in the journal next /// to the failed unit. `check-rootfs --warn-only ` prints every /// violation as a warning and returns 0. pub(crate) fn run(args: &[String]) -> Option { if args.first().map(String::as_str) != Some(SUBCOMMAND) { return None; } let (container, warn_only) = match args { [_, container] => (container, false), [_, flag, container] if flag == WARN_ONLY => (container, true), _ => { eprintln!("usage: hive-priv {SUBCOMMAND} [{WARN_ONLY}] "); return Some(1); } }; let found = found_in_container(container, warn_only); let (code, lines) = report(container, &found, warn_only); for line in lines { eprintln!("{line}"); } Some(code) } /// What a start would be refused for, on the host paths of `container`. /// /// Strict mode stops at the first violation, as the start would. Warn-only /// lists them all, and counts a name it can't use as one more: it never fails /// the start over its own input. fn found_in_container(container: &str, warn_only: bool) -> Vec { let paths = container_paths(container, warn_only); match (paths, warn_only) { (Ok((root, system)), true) => violations(&root, &system, Path::new(STORE)), (Ok((root, system)), false) => check(&root, &system, Path::new(STORE)) .err() .map(|e| format!("{e:#}")) .into_iter() .collect(), (Err(e), _) => vec![format!("{e:#}")], } } /// The container's root and its profile's `system` link on the host. /// /// An agent container is named `h-`. A warn-only check also takes the /// platform containers' names, which are lowercase ascii, digits and hyphens. /// Either way the name holds no `/` and is never `.` or `..`. fn container_paths(container: &str, warn_only: bool) -> Result<(PathBuf, PathBuf)> { if warn_only { validate_name_chars(container)?; } else { validate_container_system_name(container)?; } Ok(( container_rootfs(container), Path::new(PROFILES_ROOT).join(container).join("system"), )) } /// The exit code and the stderr lines for the violations `found`. /// /// Warn-only lines carry the `<4>` prefix, which journald reads as the /// warning priority, so a log query can select them by severity. fn report(container: &str, found: &[String], warn_only: bool) -> (i32, Vec) { if warn_only { let lines = found .iter() .map(|v| format!("<4>rootfs check (warn-only): {container}: {v}")) .collect(); return (0, lines); } match found.first() { Some(v) => ( 1, vec![format!("hive-priv {SUBCOMMAND}: refusing to start: {v}")], ), None => (0, Vec::new()), } } /// Check the paths the start script touches as root, returning an error that /// names the first path to hold something it must not. /// /// `root` is the container's root, `system` its profile link /// (`//system`) and `store` the nix store. /// /// A path that does not exist passes: a first start has nothing yet, and the /// script creates it. fn check(root: &Path, system: &Path, store: &Path) -> Result<()> { match violations(root, system, store).into_iter().next() { None => Ok(()), Some(first) => bail!("{first}"), } } /// Every path under the rules of [`check`] that holds something it must not, /// in the order [`check`] looks at them, each message once. fn violations(root: &Path, system: &Path, store: &Path) -> Vec { let mut found = Vec::new(); for dir in DIRS { note(&mut found, check_dir(root, dir)); } for file in REGULAR_ONLY { note(&mut found, check_file(root, file, false)); } note(&mut found, check_file(root, OS_RELEASE, true)); note(&mut found, check_system(system, store)); found } /// Add the message of an error to `found`, unless it is already there: a link /// at `usr` is the finding for `usr/bin` too. fn note(found: &mut Vec, result: Result<()>) { if let Err(e) = result { let message = format!("{e:#}"); if !found.contains(&message) { found.push(message); } } } /// `symlink_metadata`, with `None` for a path that does not exist. The one /// place the walk touches the file system, so nothing in it can follow a link. fn lstat(path: &Path) -> Result> { match std::fs::symlink_metadata(path) { Ok(meta) => Ok(Some(meta)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e).with_context(|| format!("stat {}", path.display())), } } /// What a file type is, for the error line. fn kind(ty: FileType) -> &'static str { if ty.is_symlink() { "a symlink" } else if ty.is_dir() { "a directory" } else if ty.is_file() { "a regular file" } else { "not a regular file or directory" } } /// `rel` under `root` and every parent below `root` must be a real directory /// or absent. A link or a file in the way is an error. fn check_dir(root: &Path, rel: &str) -> Result<()> { let mut path = root.to_path_buf(); for part in rel.split('/') { path.push(part); let Some(meta) = lstat(&path)? else { return Ok(()); }; if !meta.file_type().is_dir() { bail!( "{} is {}, not a directory", path.display(), kind(meta.file_type()) ); } } Ok(()) } /// `rel` under `root` must be absent or a regular file, or a link when /// `link_ok`. Its parents are covered by [`check_dir`] on `etc`. fn check_file(root: &Path, rel: &str, link_ok: bool) -> Result<()> { let path = root.join(rel); let Some(meta) = lstat(&path)? else { return Ok(()); }; let ty = meta.file_type(); if ty.is_file() || (link_ok && ty.is_symlink()) { return Ok(()); } bail!("{} is {}, not a regular file", path.display(), kind(ty)) } /// The profile's `system` must be absent, or a link that resolves to a /// directory in `store` whose `system` is a regular file (the script reads it /// as root). The link is followed on purpose here: nix points it at a /// generation link, which points at the store. fn check_system(system: &Path, store: &Path) -> Result<()> { let Some(meta) = lstat(system)? else { return Ok(()); }; if !meta.file_type().is_symlink() { bail!( "{} is {}, not a symlink into {}", system.display(), kind(meta.file_type()), store.display() ); } let target: PathBuf = std::fs::canonicalize(system) .with_context(|| format!("{} does not resolve", system.display()))?; let store = std::fs::canonicalize(store).with_context(|| format!("resolve {}", store.display()))?; if !target.starts_with(&store) || target == store { bail!( "{} resolves to {}, outside {}", system.display(), target.display(), store.display() ); } let file = target.join("system"); match lstat(&file)? { Some(meta) if meta.file_type().is_file() => Ok(()), Some(meta) => bail!( "{} is {}, not a regular file", file.display(), kind(meta.file_type()) ), None => bail!("{} is missing", file.display()), } } #[cfg(test)] mod tests { use std::os::unix::fs::symlink; use std::path::PathBuf; use super::{check, container_paths, pin_binary_in, report, run, unpin_binary_in, violations}; use crate::shared::test_support::{entries, scratch}; /// A scratch container: root, the profile link's path and a store with one /// toplevel in it. Nothing is linked yet. struct Fixture { root: PathBuf, system: PathBuf, store: PathBuf, toplevel: PathBuf, } impl Fixture { fn new() -> Self { let base = scratch(); let store = base.join("store"); let toplevel = store.join("abc-nixos-system"); std::fs::create_dir_all(&toplevel).unwrap(); std::fs::write(toplevel.join("system"), "x86_64-linux").unwrap(); let root = base.join("root"); std::fs::create_dir_all(&root).unwrap(); let profile = base.join("profile"); std::fs::create_dir_all(&profile).unwrap(); Self { root, system: profile.join("system"), store, toplevel, } } /// A root as a booted guest leaves it, and a profile that resolves /// into the store through a generation link. fn clean() -> Self { let f = Self::new(); for dir in ["usr/bin", "etc", "var/lib/private", "root"] { std::fs::create_dir_all(f.root.join(dir)).unwrap(); } std::fs::write(f.root.join("etc/machine-id"), "").unwrap(); std::fs::write(f.root.join("etc/resolv.conf"), "nameserver 1.1.1.1\n").unwrap(); symlink("/etc/static/os-release", f.root.join("etc/os-release")).unwrap(); let generation = f.system.with_file_name("system-1-link"); symlink(&f.toplevel, &generation).unwrap(); symlink("system-1-link", &f.system).unwrap(); f } fn check(&self) -> anyhow::Result<()> { check(&self.root, &self.system, &self.store) } /// The error text of a check that has to fail. fn refused(&self) -> String { format!("{:#}", self.check().unwrap_err()) } } /// A target outside every fixture dir, for planted links to point at. fn victim() -> PathBuf { let dir = scratch(); let path = dir.join("victim"); std::fs::create_dir_all(&path).unwrap(); path } #[test] fn a_clean_root_passes() { Fixture::clean().check().unwrap(); } #[test] fn a_first_start_with_nothing_there_passes() { let f = Fixture::new(); f.check().unwrap(); // The other half of the control: an empty root with the profile // missing is the first-create case, and the profile dir is empty. assert!(!f.system.exists()); } #[test] fn a_link_at_any_directory_is_refused_and_named() { for dir in [ "usr", "usr/bin", "etc", "var", "var/lib", "var/lib/private", "root", ] { let f = Fixture::clean(); let path = f.root.join(dir); // Replace the real directory by a link to an outside one. Parents // of nested ones are real, so the link sits exactly at `dir`. std::fs::remove_dir_all(&path).unwrap(); symlink(victim(), &path).unwrap(); let msg = f.refused(); assert!( msg.contains(&format!("{} is a symlink", path.display())), "{dir}: {msg}" ); } } #[test] fn a_file_where_a_directory_belongs_is_refused() { let f = Fixture::clean(); std::fs::remove_dir_all(f.root.join("root")).unwrap(); std::fs::write(f.root.join("root"), "").unwrap(); let msg = f.refused(); assert!( msg.contains("root is a regular file, not a directory"), "{msg}" ); } #[test] fn a_missing_directory_below_a_real_one_passes() { let f = Fixture::new(); std::fs::create_dir_all(f.root.join("var")).unwrap(); f.check().unwrap(); } #[test] fn a_link_at_machine_id_is_refused_even_when_dangling() { let f = Fixture::clean(); let path = f.root.join("etc/machine-id"); std::fs::remove_file(&path).unwrap(); symlink(f.root.join("nowhere"), &path).unwrap(); let msg = f.refused(); assert!(msg.contains("machine-id is a symlink"), "{msg}"); } #[test] fn a_link_at_resolv_conf_is_refused() { let f = Fixture::clean(); let path = f.root.join("etc/resolv.conf"); std::fs::remove_file(&path).unwrap(); symlink("/etc/hosts", &path).unwrap(); let msg = f.refused(); assert!(msg.contains("resolv.conf is a symlink"), "{msg}"); } #[test] fn a_directory_at_a_file_name_is_refused() { let f = Fixture::clean(); let path = f.root.join("etc/resolv.conf"); std::fs::remove_file(&path).unwrap(); std::fs::create_dir(&path).unwrap(); let msg = f.refused(); assert!(msg.contains("resolv.conf is a directory"), "{msg}"); } #[test] fn os_release_may_be_a_link_a_file_or_absent_but_not_a_directory() { // Link: what the clean fixture has. let f = Fixture::clean(); f.check().unwrap(); let path = f.root.join("etc/os-release"); std::fs::remove_file(&path).unwrap(); f.check().unwrap(); std::fs::write(&path, "").unwrap(); f.check().unwrap(); std::fs::remove_file(&path).unwrap(); std::fs::create_dir(&path).unwrap(); let msg = f.refused(); assert!(msg.contains("os-release is a directory"), "{msg}"); } #[test] fn a_profile_system_that_is_a_directory_is_refused() { let f = Fixture::clean(); std::fs::remove_file(&f.system).unwrap(); std::fs::create_dir_all(f.system.join("system")).unwrap(); let msg = f.refused(); assert!( msg.contains("system is a directory, not a symlink"), "{msg}" ); } #[test] fn a_profile_system_pointing_outside_the_store_is_refused() { let f = Fixture::clean(); let outside = victim(); std::fs::write(outside.join("system"), "x86_64-linux").unwrap(); std::fs::remove_file(&f.system).unwrap(); symlink(&outside, &f.system).unwrap(); let msg = f.refused(); assert!(msg.contains("outside"), "{msg}"); } #[test] fn a_dangling_profile_system_is_refused() { let f = Fixture::clean(); std::fs::remove_file(&f.system).unwrap(); symlink(f.store.join("gone"), &f.system).unwrap(); let msg = f.refused(); assert!(msg.contains("does not resolve"), "{msg}"); } #[test] fn a_toplevel_whose_system_is_not_a_regular_file_is_refused() { let f = Fixture::clean(); std::fs::remove_file(f.toplevel.join("system")).unwrap(); std::fs::create_dir(f.toplevel.join("system")).unwrap(); let msg = f.refused(); assert!( msg.contains("system is a directory, not a regular file"), "{msg}" ); std::fs::remove_dir(f.toplevel.join("system")).unwrap(); let msg = f.refused(); assert!(msg.contains("is missing"), "{msg}"); } #[test] fn a_check_leaves_everything_as_it_found_it() { let f = Fixture::clean(); let planted = f.root.join("root"); std::fs::remove_dir_all(&planted).unwrap(); let target = victim(); symlink(&target, &planted).unwrap(); f.check().unwrap_err(); assert!(std::fs::symlink_metadata(&planted).unwrap().is_symlink()); assert!(target.is_dir()); } /// A scratch store holding one build with a binary in it, a roots dir and /// the binary's path. fn pin_fixture() -> (PathBuf, PathBuf, PathBuf, PathBuf) { let base = scratch(); let store = base.join("store"); let build = store.join("abc-hive-priv"); std::fs::create_dir_all(build.join("bin")).unwrap(); let exe = build.join("bin/hive-priv"); std::fs::write(&exe, "").unwrap(); (base.join("roots"), store, build, exe) } #[test] fn the_root_points_at_the_store_path_the_drop_in_names() { let (roots, store, build, exe) = pin_fixture(); pin_binary_in(&roots, &store, "h-iris", &exe).unwrap(); let root = roots.join("h-iris"); assert!(std::fs::symlink_metadata(&root).unwrap().is_symlink()); // The build, not the binary inside it. assert_eq!(std::fs::read_link(&root).unwrap(), build); assert_eq!(std::fs::read_dir(&roots).unwrap().count(), 1); } #[test] fn a_rewrite_replaces_the_root_and_leaves_no_temp() { let (roots, store, build, exe) = pin_fixture(); let newer = store.join("def-hive-priv"); std::fs::create_dir_all(newer.join("bin")).unwrap(); pin_binary_in(&roots, &store, "h-iris", &exe).unwrap(); pin_binary_in(&roots, &store, "h-iris", &newer.join("bin/hive-priv")).unwrap(); pin_binary_in(&roots, &store, "h-bob", &exe).unwrap(); assert_eq!(std::fs::read_link(roots.join("h-iris")).unwrap(), newer); assert_eq!(std::fs::read_link(roots.join("h-bob")).unwrap(), build); assert_eq!(entries(&roots), ["h-bob", "h-iris"]); } #[test] fn a_link_planted_at_the_root_name_is_replaced_not_followed() { let (roots, store, build, exe) = pin_fixture(); let victim = victim(); std::fs::write(victim.join("keep"), "data").unwrap(); std::fs::create_dir_all(&roots).unwrap(); symlink(&victim, roots.join("h-iris")).unwrap(); pin_binary_in(&roots, &store, "h-iris", &exe).unwrap(); assert_eq!(std::fs::read_link(roots.join("h-iris")).unwrap(), build); assert_eq!( std::fs::read_to_string(victim.join("keep")).unwrap(), "data" ); assert_eq!(std::fs::read_dir(&victim).unwrap().count(), 1); } #[test] fn a_roots_dir_that_is_a_link_is_refused() { let (roots, store, _, exe) = pin_fixture(); let victim = victim(); symlink(&victim, &roots).unwrap(); let msg = format!( "{:#}", pin_binary_in(&roots, &store, "h-iris", &exe).unwrap_err() ); assert!(msg.contains("is not a real directory"), "{msg}"); assert_eq!(std::fs::read_dir(&victim).unwrap().count(), 0); } #[test] fn a_binary_outside_the_store_gets_no_root() { let (roots, store, _, _) = pin_fixture(); let elsewhere = scratch().join("hive-priv"); pin_binary_in(&roots, &store, "h-iris", &elsewhere).unwrap(); assert!(!roots.exists()); } #[test] fn unpinning_removes_the_container_root_and_nothing_else() { let (roots, store, build, exe) = pin_fixture(); pin_binary_in(&roots, &store, "h-iris", &exe).unwrap(); pin_binary_in(&roots, &store, "h-bob", &exe).unwrap(); unpin_binary_in(&roots, "h-iris").unwrap(); assert_eq!(entries(&roots), ["h-bob"]); // Already gone: fine. The build it named is untouched. unpin_binary_in(&roots, "h-iris").unwrap(); assert!(build.join("bin/hive-priv").is_file()); } #[test] fn unpinning_leaves_a_name_that_is_not_a_link_alone() { let (roots, _, _, _) = pin_fixture(); std::fs::create_dir_all(roots.join("h-iris")).unwrap(); std::fs::write(roots.join("h-iris/keep"), "").unwrap(); let msg = format!("{:#}", unpin_binary_in(&roots, "h-iris").unwrap_err()); assert!(msg.contains("left alone"), "{msg}"); assert!(roots.join("h-iris/keep").exists()); } #[test] fn only_the_check_rootfs_sub_command_is_taken() { let args = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::>(); assert_eq!(run(&[]), None); assert_eq!(run(&args(&["serve"])), None); // Wrong arity, an unknown flag and a name that is not an agent // container are refused, whatever is on disk. assert_eq!(run(&args(&["check-rootfs"])), Some(1)); assert_eq!(run(&args(&["check-rootfs", "not-hive"])), Some(1)); assert_eq!(run(&args(&["check-rootfs", "--strict", "h-iris"])), Some(1)); assert_eq!(run(&args(&["check-rootfs", "--warn-only"])), Some(1)); } #[test] fn a_warn_only_run_exits_zero_even_for_a_name_it_cannot_use() { let args = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::>(); assert_eq!( run(&args(&["check-rootfs", "--warn-only", "no-such-container"])), Some(0) ); assert_eq!( run(&args(&["check-rootfs", "--warn-only", "../etc"])), Some(0) ); } #[test] fn strict_takes_agent_names_and_warn_only_takes_plain_ones() { assert!(container_paths("h-iris", false).is_ok()); assert!(container_paths("hive-forge", false).is_err()); for name in ["hive-forge", "swarm-review-classifier", "h-iris"] { let (root, system) = container_paths(name, true).unwrap(); assert_eq!( root, PathBuf::from(format!("/var/lib/nixos-containers/{name}")) ); assert_eq!( system, PathBuf::from(format!("/nix/var/nix/profiles/per-container/{name}/system")) ); } for name in [ "", ".", "..", "../h-iris", "a/b", "/etc", "H-iris", "a.b", "a b", ] { assert!( container_paths(name, true).is_err(), "{name:?} was accepted" ); assert!( container_paths(name, false).is_err(), "{name:?} was accepted" ); } } /// A clean root with a link at `root` and another at `etc/resolv.conf`. fn two_violations() -> Fixture { let f = Fixture::clean(); std::fs::remove_dir_all(f.root.join("root")).unwrap(); symlink(victim(), f.root.join("root")).unwrap(); std::fs::remove_file(f.root.join("etc/resolv.conf")).unwrap(); symlink("/etc/hosts", f.root.join("etc/resolv.conf")).unwrap(); f } fn found(f: &Fixture) -> Vec { violations(&f.root, &f.system, &f.store) } #[test] fn warn_only_logs_every_violation_at_warning_level_and_exits_zero() { let f = two_violations(); let found = found(&f); assert_eq!(found.len(), 2, "{found:?}"); let (code, lines) = report("hive-forge", &found, true); assert_eq!(code, 0); assert_eq!(lines.len(), 2, "{lines:?}"); for line in &lines { assert!( line.starts_with("<4>rootfs check (warn-only): hive-forge: "), "{line}" ); } assert!(lines[0].contains("root is a symlink"), "{lines:?}"); assert!(lines[1].contains("resolv.conf is a symlink"), "{lines:?}"); } #[test] fn strict_refuses_with_the_first_violation_only() { let f = two_violations(); let found = found(&f); let (code, lines) = report("h-iris", &found, false); assert_eq!(code, 1); assert_eq!(lines.len(), 1, "{lines:?}"); assert!( lines[0].starts_with("hive-priv check-rootfs: refusing to start: "), "{lines:?}" ); assert!(lines[0].contains("root is a symlink"), "{lines:?}"); // The rules are the same: the strict refusal is `check`'s first error. assert_eq!(f.refused(), found[0]); } #[test] fn a_clean_root_prints_nothing_in_either_mode() { let f = Fixture::clean(); assert!(found(&f).is_empty()); assert_eq!(report("hive-forge", &[], true), (0, Vec::new())); assert_eq!(report("h-iris", &[], false), (0, Vec::new())); } #[test] fn one_link_is_reported_once_though_two_paths_run_through_it() { let f = Fixture::clean(); std::fs::remove_dir_all(f.root.join("usr")).unwrap(); symlink(victim(), f.root.join("usr")).unwrap(); let found = found(&f); assert_eq!(found.len(), 1, "{found:?}"); assert!(found[0].contains("usr is a symlink"), "{found:?}"); } }