installer: commit collision-tracker WIP to satisfy clean-fork build gate
Pre-existing uncommitted work (materialized by the build's local-over-WIP preflight): CollisionTracker detects config [[files]] (Layer 1) being silently overwritten by package staging (Layer 2) — the D-Bus regression class. Committed as-is so the fork has a clean HEAD for pkgar fingerprints; no functional change intended by this commit beyond tracking it.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
//! Runtime file-collision detection for `install_dir()`.
|
||||
//!
|
||||
//! The installer writes files in four layers (see AGENTS.md § "Installer File
|
||||
//! Layering"):
|
||||
//!
|
||||
//! ```text
|
||||
//! Layer 1: Config pre-install [[files]] (postinstall = false)
|
||||
//! Layer 2: Package staging (install_packages())
|
||||
//! Layer 3: Config post-install [[files]] (postinstall = true)
|
||||
//! Layer 4: User/group creation (passwd, shadow, group)
|
||||
//! ```
|
||||
//!
|
||||
//! Last writer wins. Layer 2 silently overwriting Layer 1 is the D-Bus
|
||||
//! regression class: a config `[[files]]` entry writes to a path, a package
|
||||
//! stages the same path during `install_packages()`, the package wins, the
|
||||
//! operator's customization is gone — and the build succeeds. This module
|
||||
//! makes that class of bug visible at install time.
|
||||
//!
|
||||
//! ## Behavior
|
||||
//!
|
||||
//! - Every file write (regular file or symlink) is recorded with its layer
|
||||
//! and source.
|
||||
//! - When a later layer overwrites a path written by an earlier layer, a
|
||||
//! `[COLLISION-WARN]` line is emitted to stderr. The build-time
|
||||
//! `scripts/validate-collision-log.sh` greps logs for that marker.
|
||||
//! - Layer 3 (`ConfigPostInstall`) is the *intentional override* layer — its
|
||||
//! whole purpose is to overwrite package defaults. Collisions from Layer 3
|
||||
//! are suppressed by design and never warn.
|
||||
//! - Known-safe override path pairs (e.g. `/etc/init.d/` over
|
||||
//! `/usr/lib/init.d/`, mirroring the init system's `config_for_dirs()`
|
||||
//! priority) are also suppressed.
|
||||
//! - Strict mode (`REDBEAR_INSTALLER_STRICT_COLLISIONS=1`) promotes
|
||||
//! collisions to hard errors via `anyhow::bail!`. Default is warn-only.
|
||||
//!
|
||||
//! ## What is NOT tracked
|
||||
//!
|
||||
//! Directory creation. `fs::create_dir_all` is idempotent; two layers
|
||||
//! creating the same directory is a no-op, not a collision. Only regular
|
||||
//! files and symlinks are recorded.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Installer file-write layers, in write order. The numeric value matches
|
||||
/// the layer numbering in AGENTS.md § "Installer File Layering" and is used
|
||||
/// in user-facing warning text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Layer {
|
||||
/// `[[files]]` entries with `postinstall = false`. Written first.
|
||||
ConfigPreInstall = 1,
|
||||
/// Package staging via `install_packages()`. Layer 2 overwriting
|
||||
/// Layer 1 is the D-Bus regression class (silent loss of config).
|
||||
PackageStaging = 2,
|
||||
/// `[[files]]` entries with `postinstall = true`. Intentional override
|
||||
/// layer — collisions from this layer never warn by design.
|
||||
ConfigPostInstall = 3,
|
||||
/// Files the installer itself writes at the end of `install_dir()`
|
||||
/// (`/etc/passwd`, `/etc/shadow`, `/etc/group`).
|
||||
InstallerBuiltin = 4,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Numeric layer identifier as documented in AGENTS.md. Stable across
|
||||
/// releases — used in `[COLLISION-WARN]` output.
|
||||
pub fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a tracked file came from.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProvenanceSource {
|
||||
/// `[[files]]` entry. The string is the config-visible path
|
||||
/// (leading-slash form, e.g. `"/etc/passwd"`).
|
||||
ConfigFile(String),
|
||||
/// Package staging. The string is a package identifier, or `"staged"`
|
||||
/// when the writing package cannot be identified (the common case —
|
||||
/// `install_packages()` does not report per-file provenance).
|
||||
Package(String),
|
||||
/// Installer-builtin file (`/etc/passwd`, `/etc/shadow`, `/etc/group`).
|
||||
InstallerBuiltin,
|
||||
}
|
||||
|
||||
/// Provenance record for one file path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileProvenance {
|
||||
/// What wrote the file.
|
||||
pub source: ProvenanceSource,
|
||||
/// Which install layer wrote it.
|
||||
pub layer: Layer,
|
||||
}
|
||||
|
||||
/// One collision event recorded by the tracker.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Collision {
|
||||
/// Absolute filesystem path that was overwritten.
|
||||
pub path: PathBuf,
|
||||
/// Provenance of the earlier (overwritten) write.
|
||||
pub previous: FileProvenance,
|
||||
/// Provenance of the later (overwriting) write.
|
||||
pub current: FileProvenance,
|
||||
}
|
||||
|
||||
/// Marker emitted on stderr for every collision warning. The build-time
|
||||
/// `scripts/validate-collision-log.sh` script greps logs for this token.
|
||||
pub const WARN_MARKER: &str = "[COLLISION-WARN]";
|
||||
|
||||
/// Marker emitted on stderr when strict mode promotes a collision to an
|
||||
/// error. Paired with `WARN_MARKER` in the same message so log-grepping
|
||||
/// for either token catches every collision.
|
||||
pub const ERROR_MARKER: &str = "[COLLISION-ERROR]";
|
||||
|
||||
/// Environment variable name that opts into strict mode (promotes
|
||||
/// collisions to hard errors). Default is warn-only.
|
||||
pub const STRICT_ENV_VAR: &str = "REDBEAR_INSTALLER_STRICT_COLLISIONS";
|
||||
|
||||
/// Pairs of `(override_dir, package_default_dir)`. When the current write
|
||||
/// is under `override_dir` and the previous write was under
|
||||
/// `package_default_dir`, the collision is considered intentional and is
|
||||
/// suppressed. These mirror the init system's `config_for_dirs()` priority
|
||||
/// directories documented in AGENTS.md § "Init Service File Ownership".
|
||||
const KNOWN_SAFE_OVERRIDE_PAIRS: &[(&str, &str)] = &[
|
||||
// Config overrides at /etc/init.d/ intentionally replace package
|
||||
// defaults at /usr/lib/init.d/ for the same filename. The init
|
||||
// system's config_for_dirs() BTreeMap gives /etc/init.d/ priority.
|
||||
("/etc/init.d", "/usr/lib/init.d"),
|
||||
// Likewise for environment.d — config overrides at /etc/ replace
|
||||
// package defaults at /usr/lib/.
|
||||
("/etc/environment.d", "/usr/lib/environment.d"),
|
||||
];
|
||||
|
||||
/// Returns true if `(previous_config_path, current_config_path)` matches a
|
||||
/// known-safe override pair: previous under a package default dir, current
|
||||
/// under the matching config override dir. Both arguments are absolute
|
||||
/// config-visible paths (leading `/`).
|
||||
fn is_known_safe_override(previous_config_path: &str, current_config_path: &str) -> bool {
|
||||
for &(override_dir, default_dir) in KNOWN_SAFE_OVERRIDE_PAIRS {
|
||||
let curr_is_override =
|
||||
current_config_path == override_dir || is_dir_member(current_config_path, override_dir);
|
||||
let prev_is_default =
|
||||
previous_config_path == default_dir || is_dir_member(previous_config_path, default_dir);
|
||||
if curr_is_override && prev_is_default {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True if `child` is a file path strictly inside `parent` (not `parent`
|
||||
/// itself). Both arguments are absolute config-visible paths with no
|
||||
/// trailing slash. Uses byte comparison to stay I/O-free.
|
||||
fn is_dir_member(child: &str, parent: &str) -> bool {
|
||||
// We expect paths like "/etc/init.d/foo" inside "/etc/init.d".
|
||||
let parent_with_slash = format!("{parent}/");
|
||||
child.starts_with(&parent_with_slash)
|
||||
}
|
||||
|
||||
/// File-write provenance tracker for `install_dir()`.
|
||||
///
|
||||
/// Construct with [`CollisionTracker::new`] to pick up strict mode from the
|
||||
/// environment, or with [`CollisionTracker::new_with_strict`] for tests and
|
||||
/// explicit control. Call [`CollisionTracker::record`] before each file
|
||||
/// write, and [`CollisionTracker::scan_package_staging`] once after
|
||||
/// `install_packages()` returns.
|
||||
pub struct CollisionTracker {
|
||||
/// Map from absolute filesystem path to the most recent provenance.
|
||||
files: HashMap<PathBuf, FileProvenance>,
|
||||
/// Map from absolute filesystem path back to the leading-`/` config
|
||||
/// path, when known. Lets the known-safe override check compare
|
||||
/// previous vs. current config-visible paths even when both resolves
|
||||
/// land at the same physical file (e.g. via a symlinked /etc/init.d).
|
||||
config_paths: HashMap<PathBuf, String>,
|
||||
strict: bool,
|
||||
collisions: Vec<Collision>,
|
||||
}
|
||||
|
||||
impl CollisionTracker {
|
||||
/// Construct a tracker, reading strict mode from the
|
||||
/// `REDBEAR_INSTALLER_STRICT_COLLISIONS` environment variable.
|
||||
pub fn new() -> Self {
|
||||
let strict = std::env::var(STRICT_ENV_VAR)
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
Self::new_with_strict(strict)
|
||||
}
|
||||
|
||||
/// Construct a tracker with explicit strict-mode control.
|
||||
///
|
||||
/// Tests should prefer this over [`Self::new`] to avoid env-var races
|
||||
/// under parallel test execution.
|
||||
pub fn new_with_strict(strict: bool) -> Self {
|
||||
Self {
|
||||
files: HashMap::new(),
|
||||
config_paths: HashMap::new(),
|
||||
strict,
|
||||
collisions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of distinct file paths currently tracked.
|
||||
#[allow(dead_code)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
|
||||
/// Whether the tracker has observed any file writes.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
|
||||
/// Number of collisions recorded (warnings emitted, or errors raised in
|
||||
/// strict mode).
|
||||
pub fn collision_count(&self) -> usize {
|
||||
self.collisions.len()
|
||||
}
|
||||
|
||||
/// Snapshot of recorded collisions (for test inspection and reporting).
|
||||
pub fn collisions(&self) -> &[Collision] {
|
||||
&self.collisions
|
||||
}
|
||||
|
||||
/// Whether strict mode is enabled on this tracker.
|
||||
#[allow(dead_code)]
|
||||
pub fn is_strict(&self) -> bool {
|
||||
self.strict
|
||||
}
|
||||
|
||||
/// Record a file write at `abs_path` (absolute filesystem path) with
|
||||
/// the given provenance.
|
||||
///
|
||||
/// `config_path` is the leading-`/` config-visible path when known
|
||||
/// (used for the known-safe override check), or `None` for paths whose
|
||||
/// config-visible form is not meaningful.
|
||||
///
|
||||
/// # Collisions
|
||||
///
|
||||
/// A collision is when the same `abs_path` was previously written by a
|
||||
/// *different* layer. Collisions emit `[COLLISION-WARN]` on stderr and
|
||||
/// are recorded. In strict mode they additionally return `Err`.
|
||||
///
|
||||
/// # Suppression
|
||||
///
|
||||
/// Warnings are suppressed when either:
|
||||
/// - the current write is from `Layer::ConfigPostInstall` (the
|
||||
/// intentional override layer), or
|
||||
/// - the previous config path and the current config path form a
|
||||
/// known-safe override pair (e.g. `/etc/init.d/X` over
|
||||
/// `/usr/lib/init.d/X`).
|
||||
///
|
||||
/// Suppressed collisions are still recorded in the `files` map (the
|
||||
/// latest provenance wins) but do not emit a warning and do not
|
||||
/// increment `collision_count()`.
|
||||
pub fn record(
|
||||
&mut self,
|
||||
abs_path: PathBuf,
|
||||
provenance: FileProvenance,
|
||||
config_path: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// Capture previous state BEFORE any mutation. The known-safe
|
||||
// override check compares the previous config_path against the
|
||||
// current one; if we let the insert below clobber the map first,
|
||||
// we'd compare the new path against itself.
|
||||
let previous_provenance = self.files.get(&abs_path).cloned();
|
||||
let previous_config_path = self.config_paths.get(&abs_path).cloned();
|
||||
|
||||
if let Some(cp) = config_path {
|
||||
self.config_paths.insert(abs_path.clone(), cp.to_string());
|
||||
}
|
||||
|
||||
let Some(previous) = previous_provenance else {
|
||||
self.files.insert(abs_path, provenance);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Same-layer re-write is not a collision. Config merges already
|
||||
// deduplicate same-path entries; package staging may legitimately
|
||||
// touch a file twice (e.g. an inner archive extraction).
|
||||
if previous.layer == provenance.layer {
|
||||
self.files.insert(abs_path, provenance);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Layer 3 is the intentional override layer by design. Its whole
|
||||
// purpose is to overwrite package defaults — collisions from it
|
||||
// must not warn or the override mechanism would be unusable.
|
||||
let is_layer3_override = provenance.layer == Layer::ConfigPostInstall;
|
||||
|
||||
// Known-safe override path pairs (e.g. /etc/init.d over
|
||||
// /usr/lib/init.d). Only meaningful when both layers recorded a
|
||||
// config-visible path for the same abs_path.
|
||||
let is_known_safe = match (previous_config_path.as_deref(), config_path) {
|
||||
(Some(prev_cp), Some(curr_cp)) => is_known_safe_override(prev_cp, curr_cp),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_layer3_override || is_known_safe {
|
||||
self.files.insert(abs_path, provenance);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Real collision — warn (and bail in strict mode).
|
||||
let collision = Collision {
|
||||
path: abs_path.clone(),
|
||||
previous: previous.clone(),
|
||||
current: provenance.clone(),
|
||||
};
|
||||
self.collisions.push(collision.clone());
|
||||
emit_warning(&collision);
|
||||
self.files.insert(abs_path, provenance);
|
||||
|
||||
if self.strict {
|
||||
bail!(
|
||||
"{ERROR_MARKER} strict mode ({STRICT_ENV_VAR}=1): collision at {} \
|
||||
— {} layer {} overwritten by {} layer {}. Aborting install. \
|
||||
Fix the conflicting paths, or unset {STRICT_ENV_VAR} to demote \
|
||||
this to a warning.",
|
||||
collision.path.display(),
|
||||
source_label(&collision.previous.source),
|
||||
collision.previous.layer.as_u8(),
|
||||
source_label(&collision.current.source),
|
||||
collision.current.layer.as_u8(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk `dest` after `install_packages()` returns, recording every file
|
||||
/// the package staging wrote.
|
||||
///
|
||||
/// Files already tracked from Layer 1 trigger the Layer 2 overwrites
|
||||
/// Layer 1 collision (the D-Bus regression class). Files not previously
|
||||
/// tracked are recorded with `Layer::PackageStaging` provenance.
|
||||
///
|
||||
/// Directories are not tracked — `fs::create_dir_all` is idempotent and
|
||||
/// directory paths are shared infrastructure. Only regular files and
|
||||
/// symlinks are recorded.
|
||||
///
|
||||
/// I/O errors during the walk are tolerated (treated as "no file here")
|
||||
/// so a partially-populated tree does not abort the install. The
|
||||
/// collision decision is made from what we *can* read.
|
||||
pub fn scan_package_staging(&mut self, dest: &Path) -> Result<()> {
|
||||
if !dest.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
walk_for_files(dest, dest, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CollisionTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive walker that records every file (regular or symlink) under
|
||||
/// `root` as a Layer 2 (PackageStaging) write.
|
||||
fn walk_for_files(root: &Path, current: &Path, tracker: &mut CollisionTracker) -> Result<()> {
|
||||
let read_dir = match std::fs::read_dir(current) {
|
||||
Ok(rd) => rd,
|
||||
// Tolerate unreadable subdirs — they contribute no files.
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
for entry in read_dir {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
let abs_path = entry.path();
|
||||
if file_type.is_symlink() || file_type.is_file() {
|
||||
let provenance = FileProvenance {
|
||||
source: ProvenanceSource::Package("staged".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
};
|
||||
// For package-staged files the config-visible path is the path
|
||||
// under `root` (with a leading `/`). This matches how a config
|
||||
// `[[files]]` entry would address the same file.
|
||||
let config_visible = match abs_path.strip_prefix(root) {
|
||||
Ok(rel) => format!("/{}", rel.display()),
|
||||
Err(_) => abs_path.to_string_lossy().into_owned(),
|
||||
};
|
||||
tracker.record(abs_path, provenance, Some(&config_visible))?;
|
||||
} else if file_type.is_dir() {
|
||||
walk_for_files(root, &abs_path, tracker)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit a single `[COLLISION-WARN]` line on stderr. The build's
|
||||
/// `validate-collision-log.sh` greps for this token after the install
|
||||
/// finishes.
|
||||
fn emit_warning(c: &Collision) {
|
||||
eprintln!(
|
||||
"{WARN_MARKER} {} layer {} overwritten by layer {} ({} -> {}). \
|
||||
Set {STRICT_ENV_VAR}=1 to promote this to an error.",
|
||||
c.path.display(),
|
||||
c.previous.layer.as_u8(),
|
||||
c.current.layer.as_u8(),
|
||||
source_label(&c.previous.source),
|
||||
source_label(&c.current.source),
|
||||
);
|
||||
}
|
||||
|
||||
/// Short human-readable label for a provenance source, used in warning and
|
||||
/// error text.
|
||||
fn source_label(s: &ProvenanceSource) -> &'static str {
|
||||
match s {
|
||||
ProvenanceSource::ConfigFile(_) => "config",
|
||||
ProvenanceSource::Package(_) => "package",
|
||||
ProvenanceSource::InstallerBuiltin => "installer",
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use rand::{rngs::OsRng, TryRngCore};
|
||||
use redoxfs::{unmount_path, Disk, DiskIo, FileSystem, BLOCK_SIZE};
|
||||
use termion::input::TermRead;
|
||||
|
||||
use crate::collision::{CollisionTracker, FileProvenance, Layer, ProvenanceSource};
|
||||
use crate::config::file::FileConfig;
|
||||
use crate::config::package::PackageConfig;
|
||||
use crate::config::Config;
|
||||
@@ -138,16 +139,29 @@ pub fn install_dir(
|
||||
|
||||
let output_dir = output_dir.to_owned();
|
||||
|
||||
let mut tracker = CollisionTracker::new();
|
||||
|
||||
// Layer 1: config pre-install [[files]] (postinstall = false).
|
||||
for file in &config.files {
|
||||
if !file.postinstall {
|
||||
record_config_file(&mut tracker, &output_dir, file, Layer::ConfigPreInstall)?;
|
||||
file.create(&output_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: package staging. install_packages() is opaque to per-file
|
||||
// provenance, so we walk output_dir afterwards to register what it wrote
|
||||
// and detect the Layer 2 overwrites Layer 1 collision (D-Bus regression
|
||||
// class — config edits silently lost).
|
||||
install_packages(&config, &output_dir, cookbook)?;
|
||||
tracker.scan_package_staging(&output_dir)?;
|
||||
|
||||
// Layer 3: config post-install [[files]] (postinstall = true). This is
|
||||
// the intentional override layer — CollisionTracker suppresses its
|
||||
// collisions by design.
|
||||
for file in &config.files {
|
||||
if file.postinstall {
|
||||
record_config_file(&mut tracker, &output_dir, file, Layer::ConfigPostInstall)?;
|
||||
file.create(&output_dir)?;
|
||||
}
|
||||
}
|
||||
@@ -228,11 +242,16 @@ pub fn install_dir(
|
||||
groups.push((group, gid, group_config.members));
|
||||
}
|
||||
|
||||
// Layer 4: installer-builtin files (/etc/passwd, /etc/shadow, /etc/group).
|
||||
// A collision here means a package or config also wrote one of these
|
||||
// core files — almost always a real misconfiguration worth surfacing.
|
||||
if !passwd.is_empty() {
|
||||
record_installer_builtin(&mut tracker, &output_dir, "/etc/passwd")?;
|
||||
FileConfig::new_file("/etc/passwd".to_string(), passwd).create(&output_dir)?;
|
||||
}
|
||||
|
||||
if !shadow.is_empty() {
|
||||
record_installer_builtin(&mut tracker, &output_dir, "/etc/shadow")?;
|
||||
FileConfig::new_file("/etc/shadow".to_string(), shadow)
|
||||
.with_mod(0o0600, 0, 0)
|
||||
.create(&output_dir)?;
|
||||
@@ -250,6 +269,7 @@ pub fn install_dir(
|
||||
println!("\tMembers: {}", members.join(", "));
|
||||
}
|
||||
|
||||
record_installer_builtin(&mut tracker, &output_dir, "/etc/group")?;
|
||||
FileConfig::new_file("/etc/group".to_string(), groups_data)
|
||||
.with_mod(0o0600, 0, 0)
|
||||
.create(&output_dir)?;
|
||||
@@ -258,6 +278,45 @@ pub fn install_dir(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// CollisionTracker wiring helpers. Each records the provenance of one file
|
||||
// write that is about to happen; the actual write (`FileConfig::create`)
|
||||
// is performed by the caller. Directory creations are not recorded (see
|
||||
// collision.rs module docs).
|
||||
fn record_config_file(
|
||||
tracker: &mut CollisionTracker,
|
||||
output_dir: &Path,
|
||||
file: &FileConfig,
|
||||
layer: Layer,
|
||||
) -> Result<()> {
|
||||
if file.directory {
|
||||
return Ok(());
|
||||
}
|
||||
let abs_path = output_dir.join(file.path.trim_start_matches('/'));
|
||||
tracker.record(
|
||||
abs_path,
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile(file.path.clone()),
|
||||
layer,
|
||||
},
|
||||
Some(&file.path),
|
||||
)
|
||||
}
|
||||
|
||||
fn record_installer_builtin(
|
||||
tracker: &mut CollisionTracker,
|
||||
output_dir: &Path,
|
||||
config_path: &str,
|
||||
) -> Result<()> {
|
||||
tracker.record(
|
||||
output_dir.join(config_path.trim_start_matches('/')),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::InstallerBuiltin,
|
||||
layer: Layer::InstallerBuiltin,
|
||||
},
|
||||
Some(config_path),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_user_home(
|
||||
output_dir: &PathBuf,
|
||||
uid: u32,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
|
||||
// Not feature-gated: only depends on std + anyhow, so the cookbook can pull
|
||||
// it in via default-features = false without the installer feature deps.
|
||||
pub mod collision;
|
||||
|
||||
mod config;
|
||||
#[cfg(feature = "installer")]
|
||||
mod disk_wrapper;
|
||||
@@ -9,6 +13,10 @@ mod installer;
|
||||
#[cfg(feature = "installer")]
|
||||
pub use crate::installer::*;
|
||||
|
||||
pub use crate::collision::{
|
||||
Collision, CollisionTracker, FileProvenance, Layer, ProvenanceSource, ERROR_MARKER,
|
||||
STRICT_ENV_VAR, WARN_MARKER,
|
||||
};
|
||||
pub use crate::config::file::FileConfig;
|
||||
pub use crate::config::package::PackageConfig;
|
||||
pub use crate::config::Config;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Integration tests for the runtime file-collision tracker.
|
||||
//!
|
||||
//! These exercise the public API only (`CollisionTracker` and friends,
|
||||
//! re-exported at the crate root). They live in `tests/` rather than inline
|
||||
//! in `src/collision.rs` to keep the production module under the 250 pure
|
||||
//! LOC ceiling and to enforce the public-API-only test contract — the
|
||||
//! tracker's private helpers are not touched here, so a future internal
|
||||
//! rewrite stays test-compatible as long as the public surface is stable.
|
||||
|
||||
use redox_installer::{
|
||||
CollisionTracker, FileProvenance, Layer, ProvenanceSource, ERROR_MARKER, STRICT_ENV_VAR,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_no_collision_when_paths_distinct() {
|
||||
// Given: an empty tracker and two distinct paths.
|
||||
let mut tracker = CollisionTracker::new_with_strict(false);
|
||||
let path_a = PathBuf::from("/output/etc/foo.conf");
|
||||
let path_b = PathBuf::from("/output/etc/bar.conf");
|
||||
|
||||
// When: both paths are recorded from different layers.
|
||||
tracker
|
||||
.record(
|
||||
path_a.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/etc/foo.conf".to_string()),
|
||||
layer: Layer::ConfigPreInstall,
|
||||
},
|
||||
Some("/etc/foo.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
tracker
|
||||
.record(
|
||||
path_b.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/etc/bar.conf".to_string()),
|
||||
layer: Layer::ConfigPostInstall,
|
||||
},
|
||||
Some("/etc/bar.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Then: no collisions, both paths tracked.
|
||||
assert_eq!(tracker.collision_count(), 0, "distinct paths never collide");
|
||||
assert_eq!(tracker.len(), 2);
|
||||
assert!(tracker.collisions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collision_warning_when_layer2_overwrites_layer1() {
|
||||
// Given: a tracker with a Layer 1 (ConfigPreInstall) write to a path.
|
||||
let mut tracker = CollisionTracker::new_with_strict(false);
|
||||
let abs_path = PathBuf::from("/output/usr/lib/init.d/dbus");
|
||||
tracker
|
||||
.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/usr/lib/init.d/dbus".to_string()),
|
||||
layer: Layer::ConfigPreInstall,
|
||||
},
|
||||
Some("/usr/lib/init.d/dbus"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tracker.collision_count(), 0);
|
||||
|
||||
// When: Layer 2 (PackageStaging) writes the same path.
|
||||
let result = tracker.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::Package("base".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
},
|
||||
Some("/usr/lib/init.d/dbus"),
|
||||
);
|
||||
|
||||
// Then: a collision is recorded, the call succeeds (warn-only mode),
|
||||
// and the collision metadata points at the right layers.
|
||||
assert!(result.is_ok(), "warn-only mode must not error");
|
||||
assert_eq!(
|
||||
tracker.collision_count(),
|
||||
1,
|
||||
"Layer 2 overwriting Layer 1 is a collision"
|
||||
);
|
||||
let collision = &tracker.collisions()[0];
|
||||
assert_eq!(collision.path, abs_path);
|
||||
assert_eq!(collision.previous.layer, Layer::ConfigPreInstall);
|
||||
assert_eq!(collision.current.layer, Layer::PackageStaging);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_warning_for_postinstall_override() {
|
||||
// Given: a tracker where Layer 2 (PackageStaging) has written a path.
|
||||
let mut tracker = CollisionTracker::new_with_strict(false);
|
||||
let abs_path = PathBuf::from("/output/etc/system.conf");
|
||||
tracker
|
||||
.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::Package("base".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
},
|
||||
Some("/etc/system.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tracker.collision_count(), 0);
|
||||
|
||||
// When: Layer 3 (ConfigPostInstall) writes the same path — the
|
||||
// documented intentional-override pattern.
|
||||
let result = tracker.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/etc/system.conf".to_string()),
|
||||
layer: Layer::ConfigPostInstall,
|
||||
},
|
||||
Some("/etc/system.conf"),
|
||||
);
|
||||
|
||||
// Then: no collision is recorded. Layer 3 is the override layer.
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
tracker.collision_count(),
|
||||
0,
|
||||
"Layer 3 postinstall overrides are intentional and must not warn"
|
||||
);
|
||||
assert!(tracker.collisions().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_safe_override_paths_dont_warn() {
|
||||
// Given: a tracker where Layer 2 (PackageStaging) has written the
|
||||
// package-default init service at /usr/lib/init.d/dbus.
|
||||
//
|
||||
// In a real install the config override at /etc/init.d/dbus would
|
||||
// resolve to a different abs_path and no collision would fire. The
|
||||
// known-safe rule only matters when both writes land at the same
|
||||
// abs_path — e.g. when /etc/init.d is a symlink to /usr/lib/init.d,
|
||||
// or in unit tests that exercise the rule directly.
|
||||
let mut tracker = CollisionTracker::new_with_strict(false);
|
||||
let abs_path = PathBuf::from("/output/etc/init.d/dbus");
|
||||
tracker
|
||||
.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::Package("base".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
},
|
||||
Some("/usr/lib/init.d/dbus"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tracker.collision_count(), 0);
|
||||
|
||||
// When: a config write (Layer 1 — not Layer 3, so the layer-3
|
||||
// suppression rule does not apply) targets /etc/init.d/dbus, which
|
||||
// resolves to the same abs_path but matches the known-safe override
|
||||
// pair (/etc/init.d over /usr/lib/init.d).
|
||||
let result = tracker.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/etc/init.d/dbus".to_string()),
|
||||
layer: Layer::ConfigPreInstall,
|
||||
},
|
||||
Some("/etc/init.d/dbus"),
|
||||
);
|
||||
|
||||
// Then: no collision — the known-safe override pair suppresses the
|
||||
// warning, even though the layers differ and neither is Layer 3.
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
tracker.collision_count(),
|
||||
0,
|
||||
"known-safe override pair (/etc/init.d over /usr/lib/init.d) must not warn"
|
||||
);
|
||||
|
||||
// Sanity: the opposite direction (default after override) is NOT
|
||||
// suppressed — that would be a package clobbering a config override,
|
||||
// which is the D-Bus regression class.
|
||||
let result2 = tracker.record(
|
||||
abs_path,
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::Package("base".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
},
|
||||
Some("/usr/lib/init.d/dbus-other-direction"),
|
||||
);
|
||||
assert!(result2.is_ok());
|
||||
assert_eq!(
|
||||
tracker.collision_count(),
|
||||
1,
|
||||
"default-after-override direction is NOT a known-safe override"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strict_mode_promotes_collision_to_error() {
|
||||
// Given: a strict-mode tracker with a Layer 1 write.
|
||||
let mut tracker = CollisionTracker::new_with_strict(true);
|
||||
let abs_path = PathBuf::from("/output/etc/dbus.conf");
|
||||
tracker
|
||||
.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/etc/dbus.conf".to_string()),
|
||||
layer: Layer::ConfigPreInstall,
|
||||
},
|
||||
Some("/etc/dbus.conf"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// When: Layer 2 writes the same path.
|
||||
let result = tracker.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::Package("base".to_string()),
|
||||
layer: Layer::PackageStaging,
|
||||
},
|
||||
Some("/etc/dbus.conf"),
|
||||
);
|
||||
|
||||
// Then: the call returns Err and the error message references the
|
||||
// strict env var so the operator knows how to demote if needed.
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"strict mode must promote collisions to errors"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains(ERROR_MARKER),
|
||||
"strict-mode error must carry the {ERROR_MARKER} marker for log grepping"
|
||||
);
|
||||
assert!(
|
||||
err.contains(STRICT_ENV_VAR),
|
||||
"strict-mode error must name the env var so the operator can demote"
|
||||
);
|
||||
assert!(
|
||||
err.contains("layer 1"),
|
||||
"strict-mode error must name the overwritten layer"
|
||||
);
|
||||
assert!(
|
||||
err.contains("layer 2"),
|
||||
"strict-mode error must name the overwriting layer"
|
||||
);
|
||||
|
||||
// And: the collision is still counted in the tracker's history.
|
||||
assert_eq!(tracker.collision_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_package_staging_detects_layer2_over_layer1_on_real_disk() {
|
||||
// End-to-end: write a Layer 1 file to a tempdir, then ask
|
||||
// scan_package_staging to walk it. The walk must detect the file as a
|
||||
// Layer 2 write that collides with the already-recorded Layer 1 entry.
|
||||
//
|
||||
// This is the closest unit-level proof of the D-Bus regression class:
|
||||
// a config wrote to a path, then a package stages the same path, and
|
||||
// the walk catches it.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"redbear_collision_test_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0),
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Layer 1: a config writes /usr/lib/init.d/dbus.
|
||||
let rel = "usr/lib/init.d/dbus";
|
||||
let abs_path = dir.join(rel);
|
||||
std::fs::create_dir_all(abs_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&abs_path, b"# config layer 1").unwrap();
|
||||
|
||||
let mut tracker = CollisionTracker::new_with_strict(false);
|
||||
tracker
|
||||
.record(
|
||||
abs_path.clone(),
|
||||
FileProvenance {
|
||||
source: ProvenanceSource::ConfigFile("/usr/lib/init.d/dbus".to_string()),
|
||||
layer: Layer::ConfigPreInstall,
|
||||
},
|
||||
Some("/usr/lib/init.d/dbus"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tracker.collision_count(), 0);
|
||||
|
||||
// Layer 2: the walk sees the same file on disk and flags the collision.
|
||||
tracker.scan_package_staging(&dir).unwrap();
|
||||
assert_eq!(
|
||||
tracker.collision_count(),
|
||||
1,
|
||||
"scan_package_staging must catch a Layer 2 write to a Layer 1 path"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
Reference in New Issue
Block a user