diff --git a/src/collision.rs b/src/collision.rs new file mode 100644 index 0000000000..0769d56083 --- /dev/null +++ b/src/collision.rs @@ -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, + /// 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, + strict: bool, + collisions: Vec, +} + +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", + } +} diff --git a/src/installer.rs b/src/installer.rs index bd3253ec4c..dec5d1a6ad 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -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, diff --git a/src/lib.rs b/src/lib.rs index 5bc8a002de..c0c20f7918 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/tests/collision.rs b/tests/collision.rs new file mode 100644 index 0000000000..1235ec0fee --- /dev/null +++ b/tests/collision.rs @@ -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); +}