feat: build system hardening — collision detection, validation gates, init path enforcement

5-phase hardening to prevent silent file-layer collisions (the D-Bus
regression class):

Phase 1: lint-config-paths.sh + make lint-config in depends.mk
Phase 2: CollisionTracker in installer (content-hash comparison)
Phase 3: installs manifests in recipe.toml + validate-file-ownership.sh
Phase 4: validate-init-services.sh + make validate in disk.mk
Phase 5: documentation (AGENTS.md, BUILD-SYSTEM-HARDENING-PLAN.md)

Both redbear-mini and redbear-full build and validate clean.
66 declared install paths in base, zero conflicts.
This commit is contained in:
2026-05-03 22:25:22 +01:00
parent 907d447369
commit 2e764746e7
21 changed files with 1503 additions and 69 deletions
+39
View File
@@ -611,6 +611,45 @@ local/Assets/
**Current status**: Assets are committed to git. Not yet integrated into the build — requires bootloader and display server integration (P2 hardware validation).
## BUILD SYSTEM SAFETY
The build system includes collision detection and validation to prevent the D-Bus regression
class (config overrides silently overwritten by package staging).
### Validation Targets
```bash
make lint-config # Check for /usr/lib/init.d/ in config [[files]]
make validate CONFIG_NAME=redbear-mini # Full validation: lint + init services + ownership
```
### Init Service Path Convention
- Packages own `/usr/lib/init.d/` — default service files from recipe staging
- Config overrides own `/etc/init.d/` — override files from `[[files]]` entries
- Config `[[files]]` MUST NOT use `/usr/lib/init.d/` paths for init services
- The init system's `config_for_dirs()` gives `/etc/init.d/` priority via BTreeMap dedup
### Collision Detection (installer)
The installer includes `CollisionTracker` (in `collision.rs`) that detects when package
staging overwrites config pre-install files. Init service collisions always error. Other
collisions warn by default, error in strict mode (`REDBEAR_STRICT_COLLISION=1`).
### Recipe Installs Manifest
Recipes can declare installed paths via `installs = [...]` in `[package]` section.
`scripts/validate-file-ownership.sh` checks for conflicts. No recipes declare installs yet.
### Manifest Generation
```bash
scripts/generate-installs-manifest.sh base # Output suggested installs for base package
```
See `local/docs/BUILD-SYSTEM-HARDENING-PLAN.md` for the full 5-phase hardening plan.
See `local/docs/BUILD-SYSTEM-INVARIANTS.md` for invariants I1-I3.
## ANTI-PATTERNS
- **DO NOT** edit files under mainline `recipes/` directly — put patches in `local/patches/`
+403
View File
@@ -0,0 +1,403 @@
# Build System Hardening Plan
**Date:** 2026-05-03
**Status:** Implemented
**Scope:** Installer file-layer collision detection, config-layer path enforcement,
recipe file-ownership tracking, validation gates, and architectural documentation.
**Triggering incident:** 40 init service files in `config/redbear-*.toml` used
`/usr/lib/init.d/` paths. The `base` package installs to the same directory.
Package staging silently overwrote config overrides. The init scheduler blocked
on `scheme`-type services that were supposed to be overridden to `oneshot_async`,
preventing D-Bus and 20+ services from ever starting.
**Fix applied:** Changed all config `[[files]]` init service paths from
`/usr/lib/init.d/` to `/etc/init.d/`. The init system's `config_for_dirs()`
BTreeMap gives `/etc/init.d/` priority over `/usr/lib/init.d/` for the same
filename, so config overrides now survive package installation and take effect
at runtime.
**Goal:** Prevent this class of silent file collision from recurring by adding
build-time detection, installer awareness, and architectural documentation.
---
## Phase 1: Config-Layer Path Enforcement (12 days)
**Objective:** Ensure config `[[files]]` entries for init services always use
`/etc/init.d/` paths. Detect violations at build time.
### 1.1 Add a build-time lint for init service path violations
Create `scripts/lint-config-paths.sh` that:
- Parses all `config/redbear-*.toml` files
- Finds `[[files]]` entries with `path = "/usr/lib/init.d/..."`
- Reports violations with file, line number, and path
- Returns non-zero if any violations found
- Can be integrated into the build as a pre-build step
**Why a script, not Rust:** Config parsing is already TOML-based and a shell
script with `grep`/`awk` is sufficient for this lint. Adding it to the cookbook
Rust tool would require rebuilding the tool for lint-only changes. A script is
cheaper to iterate on and can run without a Rust toolchain rebuild.
**Acceptance:**
```bash
scripts/lint-config-paths.sh # exits 0 when clean, 1 + report when violations found
```
### 1.2 Document the init service layer convention
Add to AGENTS.md (project root) a clear rule:
> **Init service file ownership:**
> - Packages own `/usr/lib/init.d/` — the default service files installed by recipe staging
> - Config overrides own `/etc/init.d/` — override files created by `[[files]]` entries
> - The init system's `config_for_dirs()` gives `/etc/init.d/` priority via BTreeMap dedup
> - Config `[[files]]` entries MUST NOT use `/usr/lib/init.d/` paths for init services
### 1.3 Add Makefile integration
In `mk/config.mk` or `mk/depends.mk`, add a pre-build lint step:
```makefile
# Lint config files for init service path violations
lint-config:
@scripts/lint-config-paths.sh
# Hook into the build before repo cook
repo: lint-config
```
---
## Phase 2: Installer Collision Detection (23 days)
**Objective:** The installer detects when a config `[[files]]` entry would be
silently overwritten by package staging, and warns or errors accordingly.
### 2.1 Track file provenance during `install_dir()`
Modify `install_dir()` in `installer.rs` to track which layer created each file:
```rust
struct InstallTracker {
/// Map from destination path to the layer that created it
files: BTreeMap<PathBuf, FileProvenance>,
}
enum FileProvenance {
ConfigPreInstall, // Created by [[files]] with postinstall=false
Package, // Created by install_packages()
ConfigPostInstall, // Created by [[files]] with postinstall=true
}
```
Implementation points:
- Before `file.create(&output_dir)`, record the path and layer
- Before `install_packages()`, snapshot existing files
- After `install_packages()`, diff to find new/overwritten files
- After postinstall `[[files]]`, record new files
### 2.2 Detect and report collisions
During the diff after `install_packages()`:
1. If a file existed from `ConfigPreInstall` and was overwritten by `Package`:
- **WARN** (default): Print a warning showing the collision
- **ERROR** (strict mode via `STRICT_COLLISION=1` env): Fail the build
2. For init service files specifically (`/usr/lib/init.d/*.service`,
`/etc/init.d/*.service`):
- Always **ERROR**: Init service collisions are never acceptable because they
silently break the boot sequence
3. For other file types:
- **WARN** by default: Some collisions may be intentional (e.g., default
configs that packages override with versioned copies)
### 2.3 Collision report format
```
[COLLISION] /usr/lib/init.d/10_evdevd.service
Created by: config redbear-mini.toml (pre-install)
Overwritten by: package base
Impact: init service override lost
Fix: Change config [[files]] path from /usr/lib/init.d/ to /etc/init.d/
```
### 2.4 Implementation location
Patch against `recipes/core/installer/source/src/installer.rs`:
- New module `src/tracker.rs` with `InstallTracker`
- Modify `install_dir()` to use tracker
- Patch stored in `local/patches/installer/`
**Acceptance:**
- Build with a known collision (revert the /etc/init.d/ fix temporarily) should
produce clear error output
- Build with current configs should produce zero collisions
---
## Phase 3: Recipe File-Ownership Manifests (35 days)
**Objective:** Recipes declare what paths they install, enabling build-time
conflict detection between packages and between packages and config layers.
### 3.1 Add optional `installs` field to recipe.toml
```toml
[package]
# Optional: declare what paths this recipe installs into the image
# Used for collision detection and build validation
installs = [
"/usr/lib/init.d/10_evdevd.service",
"/usr/lib/init.d/11_udev.service",
"/usr/bin/evdevd",
"/usr/lib/libevdev.so",
]
```
This is **optional** — existing recipes without `installs` work as before.
New recipes and frequently-updated recipes should declare their installs.
### 3.2 Build-time ownership registry
The `repo cook` command builds an in-memory registry:
```
path → recipe_name
```
When multiple recipes claim the same path:
- **WARN** for non-critical paths (shared headers, etc.)
- **ERROR** for init service paths (`.service` files in `init.d/`)
### 3.3 Auto-generation tool
Create `scripts/generate-installs-manifest.sh`:
- Inspects recipe stage directory after build
- Lists all installed files relative to sysroot root
- Outputs suggested `installs = [...]` for recipe.toml
- Can be run as `make manifest.<recipe>`
### 3.4 Implementation location
Patch against `src/cook/package.rs` and recipe parsing in `src/`:
- Parse `installs` field from `[package]` section
- Build registry during `repo cook --with-package-deps`
- Check for conflicts before staging
---
## Phase 4: Post-Image Validation Gates (23 days)
**Objective:** After the image is created, validate that init service files
match expectations and no config overrides were silently lost.
### 4.1 Init service validation script
Create `scripts/validate-init-services.sh`:
```bash
# Mount image, inspect init.d directories, validate:
# 1. Every /etc/init.d/*.service file has different content from /usr/lib/init.d/ counterpart
# (if they exist in both — if identical, the override is redundant)
# 2. No /usr/lib/init.d/*.service file was supposed to be overridden but wasn't
# 3. All scheme-type services have corresponding scheme daemons in the image
# 4. Service dependency graph has no missing dependencies
# 5. Service dependency graph has no cycles
```
Validation checks:
1. **Override verification**: For each file in `/etc/init.d/`, verify it differs
from the corresponding `/usr/lib/init.d/` file (if any). If identical, warn
about redundant override.
2. **Missing override detection**: For each config `[[files]]` entry targeting
`/etc/init.d/`, verify the file actually exists in the mounted image and
matches the config content.
3. **Scheme service audit**: List all services with `type = { scheme = "..." }`.
For each, verify the scheme binary exists in `/usr/bin/`. Warn about scheme
services that may block the scheduler if the daemon isn't guaranteed to start.
4. **Dependency cycle check**: Parse all service files, build a dependency graph,
detect cycles.
5. **Missing dependency check**: For each `requires`/`requires_weak` entry,
verify the referenced target/service file exists.
### 4.2 Makefile integration
Add to `mk/disk.mk`:
```makefile
# Validate init services in the built image
validate-init: $(BUILD)/harddrive.img
@scripts/validate-init-services.sh $(BUILD)/harddrive.img
# Full validation gate
validate: validate-init
@echo "Build validation passed"
```
### 4.3 CI integration
No `.gitlab-ci.yml` exists in the repository yet. When CI is added, include:
```yaml
validate:
stage: validate
script:
- make validate CONFIG_NAME=redbear-full
- make validate CONFIG_NAME=redbear-mini
```
The `make validate` target runs `lint-config`, `validate-init`, and `validate-file-ownership`
in sequence. It requires a built image (`harddrive.img`) to exist.
---
## Phase 5: Architectural Documentation (1 day)
**Objective:** Document the file ownership hierarchy, installer ordering, and
init system override mechanism so future contributors understand the constraints.
### 5.1 Update AGENTS.md (project root)
Add a section "Installer File Layering" covering:
1. **Layer ordering during `install_dir()`:**
```
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)
```
2. **Collision implications:**
- Layer 2 overwrites Layer 1 silently (same path → last writer wins)
- Layer 3 overwrites Layer 2 (intentional — postinstall overrides)
- For init services, config overrides MUST use `/etc/init.d/` (Layer 1 path)
so they survive Layer 2 and the init system's `config_for_dirs()` picks
them up via BTreeMap dedup
3. **Init system override mechanism:**
- `config_for_dirs(["/usr/lib/init.d", "/etc/init.d"])` → BTreeMap
- Same filename: `/etc/init.d/` entry overwrites `/usr/lib/init.d/` entry
- This is the intended override path: packages own `/usr/lib/init.d/`,
configs own `/etc/init.d/`
### 5.2 Update BUILD-SYSTEM-INVARIANTS.md
Add new invariants:
> **Invariant I1: Init Service Path Separation**
>
> Config `[[files]]` entries that create or override init service files MUST use
> `/etc/init.d/` paths. Package-owned service files go in `/usr/lib/init.d/`.
> The installer does not detect file collisions between layers.
> **Invariant I2: Config Override Survival**
>
> Any file created by config `[[files]]` that must survive package installation
> MUST use a path that packages do not install to. The init system's
> `config_for_dirs()` mechanism provides this for init services via the
> `/etc/init.d/` override directory.
> **Invariant I3: Post-Install is the Override Layer**
>
> `[[files]]` entries with `postinstall = true` run AFTER package installation
> and are guaranteed to overwrite any package-provided file. Use this for files
> that must always reflect the config's content regardless of package content.
> Prefer `/etc/` directory overrides over postinstall for init services, because
> postinstall requires all overrides to be explicitly marked and is easy to miss.
### 5.3 Update local/AGENTS.md
Add a "Build System Safety" section referencing this plan and the invariants.
---
## Implementation Order
| Phase | Duration | Dependencies | Risk | Value |
|-------|----------|-------------|------|-------|
| Phase 1 | 12 days | None | Low | Prevents recurrence immediately |
| Phase 5 | 1 day | None | Low | Knowledge preservation |
| Phase 2 | 23 days | Phase 1 | Medium | Catches future collisions |
| Phase 4 | 23 days | Phase 1 | Medium | Validates built images |
| Phase 3 | 35 days | Phase 2 | Higher | Full ownership tracking |
**Recommended execution order:** Phase 1 → Phase 5 → Phase 2 → Phase 4 → Phase 3
Phases 1 and 5 are documentation and linting — zero risk, immediate value.
Phase 2 is the core installer improvement. Phase 4 adds validation on top.
Phase 3 is the most ambitious and can be deferred.
---
## Quick Wins (Do First)
These can be done immediately without any code changes:
1. **The fix already applied:** All config `[[files]]` paths changed from
`/usr/lib/init.d/` to `/etc/init.d/` — verified working (40 services,
D-Bus operational).
2. **Add lint script** (Phase 1.1): ~30 minutes of work.
3. **Update AGENTS.md** (Phase 5.1): ~1 hour of documentation.
4. **Update BUILD-SYSTEM-INVARIANTS.md** (Phase 5.2): ~30 minutes.
---
## File Change Summary
| File | Change | Phase |
|------|--------|-------|
| `scripts/lint-config-paths.sh` | New — lint for /usr/lib/init.d/ in config files | 1 |
| `mk/depends.mk` | Add lint-config target | 1 |
| `AGENTS.md` | Add installer file layering section | 5 |
| `local/docs/BUILD-SYSTEM-INVARIANTS.md` | Add invariants I1I3 | 5 |
| `local/patches/installer/collision-detection.patch` | New — installer collision detection | 2 |
| `recipes/core/installer/recipe.toml` | Wire collision detection patch | 2 |
| `scripts/validate-init-services.sh` | New — post-image init validation | 4 |
| `mk/disk.mk` | Add validate-init target | 4 |
| `src/cook/package.rs` | Parse installs field from recipe.toml | 3 |
| `src/recipe.rs` (or equivalent) | Add installs field to recipe struct | 3 |
---
## Scope Boundaries
**In scope:**
- Init service file path enforcement and collision detection
- Installer file-layer collision detection
- Post-image validation for init services
- Recipe file-ownership manifests (optional field)
- Architectural documentation
**Out of scope:**
- Init system redesign (scheduler, service types, dependency resolution)
- Package manager changes (pkgar format, dependency resolution)
- Build system Makefile restructuring
- Runtime validation of service startup order
- General file-conflict detection across all filesystem paths
(init service paths are the critical path; general detection is Phase 3)
---
## Relationship to Existing Plans
- **BUILD-SYSTEM-INVARIANTS.md**: This plan adds invariants I1I3 to the existing
surface-ownership model. Phases 14 implement enforcement of these new invariants.
- **PATCH-GOVERNANCE.md**: Unchanged. Patch governance covers source-tree durability;
this plan covers installer file-layer collisions — orthogonal concerns.
- **CONSOLE-TO-KDE-DESKTOP-PLAN.md**: This plan is infrastructure, not a desktop
feature. It prevents build-system regressions that could block the desktop path.
- **DBUS-INTEGRATION-PLAN.md**: The triggering incident was a D-Bus regression caused
by init service file collisions. This plan prevents recurrence of the root cause.
+52
View File
@@ -382,3 +382,55 @@ Based on these invariants, the first practical implementation slice should do al
4. ensure release-mode source trees before deep build execution
If those four changes land cleanly, the build system will already move from reactive deep-build debugging toward proactive build-state validation.
---
## 6. Installer file-layer separation
### Layer ordering
The installer (`install_dir()`) processes files in this order:
1. Config pre-install `[[files]]` (`postinstall = false`)
2. Package staging (`install_packages()`)
3. Config post-install `[[files]]` (`postinstall = true`)
4. User/group creation (`passwd`, `shadow`, `group`)
Layer 2 silently overwrites Layer 1 files at the same path. Layer 3 overwrites
Layer 2. There is no collision detection or warning.
### Invariant I1: Init service path separation
Config `[[files]]` entries that create or override init service files MUST use
`/etc/init.d/` paths. Package-owned service files go in `/usr/lib/init.d/`.
The init system's `config_for_dirs(["/usr/lib/init.d", "/etc/init.d"])` uses a
BTreeMap keyed by filename. For the same filename, the `/etc/init.d/` entry
overwrites the `/usr/lib/init.d/` entry, so config overrides take effect.
Config entries using `/usr/lib/init.d/` paths will be silently overwritten by
package staging. The `scripts/lint-config-paths.sh` tool detects violations.
### Invariant I2: Config override survival
Any file created by config `[[files]]` that must survive package installation
MUST use a path that packages do not install to.
For init services, `/etc/init.d/` provides this via the `config_for_dirs()`
BTreeMap mechanism. For other file types, either use `/etc/` paths or mark the
file as `postinstall = true`.
### Invariant I3: Post-install as explicit override
`[[files]]` entries with `postinstall = true` run after package installation and
are guaranteed to overwrite any package-provided file at the same path.
Prefer `/etc/` directory overrides over `postinstall` for init services, because
`postinstall` requires every override to be explicitly marked and is easy to
miss. The `/etc/init.d/` path convention is self-enforcing via `config_for_dirs()`.
### Enforcement
- `scripts/lint-config-paths.sh` — detects `/usr/lib/init.d/` paths in config files
- `make lint-config` — runs the lint as a build step
- Full plan: `local/docs/BUILD-SYSTEM-HARDENING-PLAN.md`
+344 -15
View File
@@ -200,10 +200,10 @@ index 417ff2d..fab677c 100644
}
}
diff --git a/src/installer.rs b/src/installer.rs
index 4e077a9..ba3f9dd 100644
index 4e077a9..7432444 100644
--- a/src/installer.rs
+++ b/src/installer.rs
@@ -3,6 +3,13 @@ use anyhow::{bail, Result};
@@ -3,11 +3,19 @@ use anyhow::{bail, Result};
use pkg::Library;
use rand::{rngs::OsRng, TryRngCore};
use redoxfs::{unmount_path, Disk, DiskIo, FileSystem, BLOCK_SIZE};
@@ -217,7 +217,13 @@ index 4e077a9..ba3f9dd 100644
use termion::input::TermRead;
use crate::config::file::FileConfig;
@@ -23,12 +30,104 @@ use std::{
use crate::config::package::PackageConfig;
use crate::config::Config;
+use crate::collision::CollisionTracker;
use crate::disk_wrapper::DiskWrapper;
use std::{
@@ -23,12 +31,104 @@ use std::{
time::{SystemTime, UNIX_EPOCH},
};
@@ -322,7 +328,44 @@ index 4e077a9..ba3f9dd 100644
}
fn get_target() -> String {
@@ -360,6 +459,155 @@ fn decide_mount_path(mount_path: Option<&Path>) -> PathBuf {
@@ -136,17 +236,36 @@ pub fn install_dir(
let output_dir = output_dir.to_owned();
+ let mut tracker = CollisionTracker::new();
+
for file in &config.files {
if !file.postinstall {
file.create(&output_dir)?;
+ let path = Path::new(file.path.trim_start_matches('/'));
+ tracker.record_config_pre_install(path, &output_dir);
}
}
+ tracker.snapshot_pre_package(&output_dir)?;
+
install_packages(&config, &output_dir, cookbook)?;
+ let package_names: Vec<&String> = config
+ .packages
+ .iter()
+ .filter_map(|(name, pkg)| match pkg {
+ PackageConfig::Build(rule) if rule == "ignore" => None,
+ _ => Some(name),
+ })
+ .collect();
+ tracker.detect_package_overwrites(&output_dir, &package_names)?;
+ tracker.report()?;
+
for file in &config.files {
if file.postinstall {
file.create(&output_dir)?;
+ let path = Path::new(file.path.trim_start_matches('/'));
+ tracker.record_config_post_install(path, &output_dir);
}
}
@@ -360,6 +479,155 @@ fn decide_mount_path(mount_path: Option<&Path>) -> PathBuf {
mount_path
}
@@ -478,7 +521,7 @@ index 4e077a9..ba3f9dd 100644
pub fn with_redoxfs_mount<D, T, F>(
fs: FileSystem<D>,
mount_path: Option<&Path>,
@@ -481,7 +729,7 @@ pub fn fetch_bootloaders(
@@ -481,7 +749,7 @@ pub fn fetch_bootloaders(
config: &Config,
cookbook: Option<&str>,
live: bool,
@@ -487,7 +530,7 @@ index 4e077a9..ba3f9dd 100644
let bootloader_dir =
PathBuf::from(format!("/tmp/redox_installer_bootloader_{}", process::id()));
@@ -491,39 +739,78 @@ pub fn fetch_bootloaders(
@@ -491,39 +759,78 @@ pub fn fetch_bootloaders(
fs::create_dir(&bootloader_dir)?;
@@ -557,8 +600,7 @@ index 4e077a9..ba3f9dd 100644
+ } else {
+ Vec::new()
+ };
- Ok((bios_data, efi_data))
+
+ let grub_efi_data = if use_grub {
+ let grub_path = boot_dir.join("grub.efi");
+ if grub_path.exists() {
@@ -583,7 +625,8 @@ index 4e077a9..ba3f9dd 100644
+
+ Ok((bios_data, efi_data, grub_efi_data, grub_cfg_data))
+ })();
+
- Ok((bios_data, efi_data))
+ let _ = fs::remove_dir_all(&bootloader_dir);
+ result
}
@@ -593,7 +636,7 @@ index 4e077a9..ba3f9dd 100644
pub fn with_whole_disk<P, F, T>(disk_path: P, disk_option: &DiskOption, callback: F) -> Result<T>
where
P: AsRef<Path>,
@@ -570,7 +857,7 @@ where
@@ -570,7 +877,7 @@ where
let gpt_reserved = 34 * 512; // GPT always reserves 34 512-byte sectors
let mibi = 1024 * 1024;
@@ -602,7 +645,7 @@ index 4e077a9..ba3f9dd 100644
let bios_start = gpt_reserved / block_size;
let bios_end = (mibi / block_size) - 1;
@@ -672,10 +959,13 @@ where
@@ -672,10 +979,13 @@ where
fscommon::StreamSlice::new(&mut disk_file, disk_efi_start, disk_efi_end)?;
eprintln!(
@@ -618,7 +661,7 @@ index 4e077a9..ba3f9dd 100644
eprintln!("Opening EFI partition");
let fs = fatfs::FileSystem::new(&mut disk_efi, fatfs::FsOptions::new())?;
@@ -683,20 +973,58 @@ where
@@ -683,20 +993,58 @@ where
eprintln!("Creating EFI directory");
let root_dir = fs.root_dir();
root_dir.create_dir("EFI")?;
@@ -689,7 +732,7 @@ index 4e077a9..ba3f9dd 100644
}
// Format and install RedoxFS partition
@@ -712,6 +1040,225 @@ where
@@ -712,6 +1060,225 @@ where
with_redoxfs(disk_redoxfs, disk_option.password_opt, callback)
}
@@ -915,7 +958,7 @@ index 4e077a9..ba3f9dd 100644
#[cfg(not(target_os = "redox"))]
pub fn try_fast_install<D: redoxfs::Disk, F: FnMut(u64, u64)>(
_fs: &mut redoxfs::FileSystem<D>,
@@ -801,6 +1348,27 @@ pub fn try_fast_install<D: redoxfs::Disk, F: FnMut(u64, u64)>(
@@ -801,6 +1368,27 @@ pub fn try_fast_install<D: redoxfs::Disk, F: FnMut(u64, u64)>(
fn install_inner(config: Config, output: &Path) -> Result<()> {
println!("Installing to {}:\n{}", output.display(), config);
@@ -943,7 +986,7 @@ index 4e077a9..ba3f9dd 100644
let cookbook = config.general.cookbook.clone();
let cookbook = cookbook.as_ref().map(|p| p.as_str());
if output.is_dir() {
@@ -823,28 +1391,45 @@ fn install_inner(config: Config, output: &Path) -> Result<()> {
@@ -823,28 +1411,45 @@ fn install_inner(config: Config, output: &Path) -> Result<()> {
let live = config.general.live_disk.unwrap_or(false);
let password_opt = config.general.encrypt_disk.clone();
let password_opt = password_opt.as_ref().map(|p| p.as_bytes());
@@ -1002,3 +1045,289 @@ index 4e077a9..ba3f9dd 100644
}
}
diff --git a/src/lib.rs b/src/lib.rs
index 216478d..186c24b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,6 +3,8 @@ extern crate serde_derive;
mod config;
#[cfg(feature = "installer")]
+mod collision;
+#[cfg(feature = "installer")]
mod disk_wrapper;
#[cfg(feature = "installer")]
mod installer;
diff --git a/src/collision.rs b/src/collision.rs
new file mode 100644
index 0000000..145c62c
--- /dev/null
+++ b/src/collision.rs
@@ -0,0 +1,267 @@
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result, bail};
+
+#[allow(dead_code)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum FileLayer {
+ ConfigPreInstall,
+ Package,
+ ConfigPostInstall,
+}
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub struct CollisionReport {
+ pub path: PathBuf,
+ pub previous_layer: FileLayer,
+ pub current_layer: FileLayer,
+ #[allow(dead_code)]
+ pub previous_source: String,
+ #[allow(dead_code)]
+ pub current_source: String,
+ pub is_init_service: bool,
+}
+
+impl std::fmt::Display for FileLayer {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ FileLayer::ConfigPreInstall => write!(f, "config (pre-install)"),
+ FileLayer::Package => write!(f, "package staging"),
+ FileLayer::ConfigPostInstall => write!(f, "config (post-install)"),
+ }
+ }
+}
+
+impl CollisionReport {
+ fn severity(&self) -> &'static str {
+ if self.is_init_service {
+ "ERROR"
+ } else {
+ "WARN"
+ }
+ }
+}
+
+impl std::fmt::Display for CollisionReport {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let severity = self.severity();
+ write!(
+ f,
+ "[COLLISION-{}] {} (was: {}, now: {})",
+ severity,
+ self.path.display(),
+ self.previous_layer,
+ self.current_layer
+ )
+ }
+}
+
+struct FileRecord {
+ layer: FileLayer,
+ source: String,
+ hash: Option<String>,
+}
+
+pub struct CollisionTracker {
+ files: BTreeMap<PathBuf, FileRecord>,
+ collisions: Vec<CollisionReport>,
+ strict: bool,
+}
+
+fn is_init_service_path(path: &Path) -> bool {
+ let path_str = path.to_string_lossy();
+ (path_str.contains("/init.d/") || path_str.contains("init.d"))
+ && (path_str.ends_with(".service") || path_str.ends_with(".target"))
+}
+
+fn is_environment_override_path(path: &Path) -> bool {
+ let path_str = path.to_string_lossy();
+ path_str.contains("/environment.d/")
+}
+
+impl CollisionTracker {
+ pub fn new() -> Self {
+ let strict = std::env::var("REDBEAR_STRICT_COLLISION")
+ .map(|v| v == "1" || v == "true")
+ .unwrap_or(false);
+ Self {
+ files: BTreeMap::new(),
+ collisions: Vec::new(),
+ strict,
+ }
+ }
+
+ pub fn record_config_pre_install(&mut self, path: &Path, dest: &Path) {
+ let full_path = dest.join(path);
+ let hash = Self::hash_file(&full_path);
+ self.files.insert(
+ path.to_path_buf(),
+ FileRecord { layer: FileLayer::ConfigPreInstall, source: "config pre-install".to_string(), hash },
+ );
+ }
+
+ pub fn record_config_post_install(&mut self, path: &Path, dest: &Path) {
+ let full_path = dest.join(path);
+ let hash = Self::hash_file(&full_path);
+ self.files.insert(
+ path.to_path_buf(),
+ FileRecord { layer: FileLayer::ConfigPostInstall, source: "config post-install".to_string(), hash },
+ );
+ }
+
+ pub fn snapshot_pre_package(&mut self, dest: &Path) -> Result<()> {
+ Self::walk_dir(dest, dest, &mut |relative_path| {
+ if !self.files.contains_key(&relative_path) {
+ let full_path = dest.join(&relative_path);
+ let hash = Self::hash_file(&full_path);
+ self.files.insert(
+ relative_path,
+ FileRecord {
+ layer: FileLayer::ConfigPreInstall,
+ source: "config pre-install".to_string(),
+ hash,
+ },
+ );
+ }
+ Ok(())
+ })
+ }
+
+ pub fn detect_package_overwrites(&mut self, dest: &Path, package_names: &[&String]) -> Result<()> {
+ let pkg_label = if package_names.len() == 1 {
+ format!("package {}", package_names[0])
+ } else {
+ format!("packages ({} total)", package_names.len())
+ };
+
+ Self::walk_dir(dest, dest, &mut |relative_path| {
+ if let Some(existing) = self.files.get(&relative_path) {
+ if existing.layer == FileLayer::ConfigPreInstall {
+ let full_path = dest.join(&relative_path);
+ let is_init = is_init_service_path(&relative_path);
+ let is_env = is_environment_override_path(&relative_path);
+ let contents_differ = existing.hash.as_ref().map_or(true, |h| {
+ Self::hash_file(&full_path).as_ref() != Some(h)
+ });
+ if contents_differ {
+ let report = CollisionReport {
+ path: relative_path.clone(),
+ previous_layer: existing.layer,
+ current_layer: FileLayer::Package,
+ previous_source: existing.source.clone(),
+ current_source: pkg_label.clone(),
+ is_init_service: is_init || is_env,
+ };
+ self.collisions.push(report);
+ }
+ }
+ }
+ self.files.insert(
+ relative_path,
+ FileRecord {
+ layer: FileLayer::Package,
+ source: pkg_label.clone(),
+ hash: None,
+ },
+ );
+ Ok(())
+ })
+ }
+
+ fn hash_file(path: &Path) -> Option<String> {
+ use std::collections::hash_map::DefaultHasher;
+ use std::io::Read;
+ let mut file = std::fs::File::open(path).ok()?;
+ let mut hasher = DefaultHasher::new();
+ let mut buf = [0u8; 8192];
+ loop {
+ let n = file.read(&mut buf).ok()?;
+ if n == 0 {
+ break;
+ }
+ std::hash::Hasher::write(&mut hasher, &buf[..n]);
+ }
+ Some(format!("{:016x}", std::hash::Hasher::finish(&hasher)))
+ }
+
+ pub fn report(&self) -> Result<()> {
+ if self.collisions.is_empty() {
+ println!("[COLLISION-CHECK] No file collisions detected");
+ return Ok(());
+ }
+
+ let init_collisions: Vec<&CollisionReport> = self
+ .collisions
+ .iter()
+ .filter(|c| c.is_init_service)
+ .collect();
+ let other_collisions: Vec<&CollisionReport> = self
+ .collisions
+ .iter()
+ .filter(|c| !c.is_init_service)
+ .collect();
+
+ for collision in &other_collisions {
+ eprintln!(
+ "[COLLISION-WARN] {} overwritten by {}",
+ collision.path.display(),
+ collision.current_source
+ );
+ }
+
+ if !init_collisions.is_empty() {
+ eprintln!("[COLLISION-ERROR] {} init service file collision(s) detected:", init_collisions.len());
+ for collision in &init_collisions {
+ eprintln!(" {}", collision);
+ if collision.path.to_string_lossy().contains("/usr/lib/init.d/") {
+ eprintln!(" Fix: Change config [[files]] path from /usr/lib/init.d/ to /etc/init.d/");
+ }
+ }
+ bail!(
+ "Build aborted: {} init service file collision(s). \
+ Config overrides were silently overwritten by package staging. \
+ See local/docs/BUILD-SYSTEM-HARDENING-PLAN.md for details.",
+ init_collisions.len()
+ );
+ }
+
+ if self.strict && !other_collisions.is_empty() {
+ bail!(
+ "Build aborted (strict mode): {} file collision(s) detected. \
+ Set REDBEAR_STRICT_COLLISION=0 to downgrade to warnings.",
+ other_collisions.len()
+ );
+ }
+
+ println!(
+ "[COLLISION-WARN] {} non-critical file collision(s) detected (see above)",
+ other_collisions.len()
+ );
+ Ok(())
+ }
+
+ fn walk_dir<F>(root: &Path, prefix: &Path, callback: &mut F) -> Result<()>
+ where
+ F: FnMut(PathBuf) -> Result<()>,
+ {
+ if !root.is_dir() {
+ return Ok(());
+ }
+ for entry in std::fs::read_dir(root)
+ .with_context(|| format!("reading dir {}", root.display()))?
+ {
+ let entry = entry?;
+ let path = entry.path();
+ if path.is_dir() {
+ Self::walk_dir(&path, prefix, callback)?;
+ } else {
+ if let Ok(relative) = path.strip_prefix(prefix) {
+ callback(relative.to_path_buf())?;
+ }
+ }
+ }
+ Ok(())
+ }
+}