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:
@@ -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(())
|
||||
+ }
|
||||
+}
|
||||
|
||||
Reference in New Issue
Block a user