822 lines
30 KiB
Rust
822 lines
30 KiB
Rust
//! 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 <container>` 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 <container>` 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=`:
|
|
/// `<container>` 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/<c>`
|
|
/// 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 `<container>`: `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 <subcommand> ...` when the first argument names one.
|
|
/// `None` means it is not a sub-command and the daemon starts.
|
|
///
|
|
/// `check-rootfs <container>` 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 <container>` prints every
|
|
/// violation as a warning and returns 0.
|
|
pub(crate) fn run(args: &[String]) -> Option<i32> {
|
|
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}] <container>");
|
|
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<String> {
|
|
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-<agent>`. 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<String>) {
|
|
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
|
|
/// (`<profiles>/<container>/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<String> {
|
|
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<String>, 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<Option<Metadata>> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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<String> {
|
|
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:?}");
|
|
}
|
|
}
|