From 2e764746e738eafeb1921bb300134584465441de Mon Sep 17 00:00:00 2001 From: Vasilito Date: Sun, 3 May 2026 22:25:22 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20build=20system=20hardening=20=E2=80=94?= =?UTF-8?q?=20collision=20detection,=20validation=20gates,=20init=20path?= =?UTF-8?q?=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 45 +++ Makefile | 4 +- config/redbear-device-services.toml | 20 +- config/redbear-full.toml | 39 ++- config/redbear-greeter-services.toml | 14 +- config/redbear-legacy-base.toml | 2 +- config/redbear-legacy-desktop.toml | 8 +- config/redbear-mini.toml | 26 +- config/redbear-netctl.toml | 2 +- local/AGENTS.md | 39 +++ local/docs/BUILD-SYSTEM-HARDENING-PLAN.md | 403 ++++++++++++++++++++++ local/docs/BUILD-SYSTEM-INVARIANTS.md | 52 +++ local/patches/installer/redox.patch | 359 ++++++++++++++++++- mk/depends.mk | 4 + mk/disk.mk | 7 + recipes/core/base/recipe.toml | 70 ++++ scripts/generate-installs-manifest.sh | 74 ++++ scripts/lint-config-paths.sh | 81 +++++ scripts/validate-file-ownership.sh | 128 +++++++ scripts/validate-init-services.sh | 193 +++++++++++ src/recipe.rs | 2 + 21 files changed, 1503 insertions(+), 69 deletions(-) create mode 100644 local/docs/BUILD-SYSTEM-HARDENING-PLAN.md create mode 100755 scripts/generate-installs-manifest.sh create mode 100755 scripts/lint-config-paths.sh create mode 100755 scripts/validate-file-ownership.sh create mode 100755 scripts/validate-init-services.sh diff --git a/AGENTS.md b/AGENTS.md index e80067cc..2c0dad19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,6 +144,51 @@ make all - **Syscall ABI**: Unstable intentionally. Stability via `libredox` and `relibc` - **Drivers**: ALL userspace daemons via scheme system. No kernel-space drivers (except serio) +## INSTALLER FILE LAYERING + +The installer creates filesystem images in four layers. Understanding this ordering is critical +to avoid silent file overwrites. + +### 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) +``` + +### Collision Implications + +- **Layer 2 overwrites Layer 1** silently (same path → last writer wins). This is the bug class + that caused the D-Bus regression: config overrides at `/usr/lib/init.d/` were overwritten by + the `base` package staging the same paths. +- **Layer 3 overwrites Layer 2** (intentional — postinstall overrides). +- For init services, config overrides **MUST** use `/etc/init.d/` so they survive Layer 2. + +### Init Service File Ownership + +- **Packages own `/usr/lib/init.d/`** — 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** +- Run `make lint-config` to detect violations + +### Collision Detection + +The installer now includes a `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`). + +### Validation Gates + +After building an image, run `make validate` to verify: +- Init service path violations (via `lint-config`) +- Override effectiveness and scheme binary existence (via `validate-init-services.sh`) +- File ownership conflicts (via `validate-file-ownership.sh`) + +See `local/docs/BUILD-SYSTEM-HARDENING-PLAN.md` for the full plan. + ## ANTI-PATTERNS (THIS PROJECT) - **DO NOT** suppress errors with `as any` / `@ts-ignore` — use proper `Result` handling diff --git a/Makefile b/Makefile index f7b1961d..5290bdb8 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ include mk/config.mk # Build system dependencies include mk/depends.mk -all: $(BUILD)/harddrive.img +all: lint-config $(BUILD)/harddrive.img # ── Red Bear OS Build Cache (OBLIGATORY) ───────────────────────────────── # Cache sync is a mandatory part of every successful build. @@ -229,3 +229,5 @@ wireshark: FORCE wireshark $(BUILD)/network.pcap packages-sync: ; @bash local/scripts/sync-packages.sh packages-list: ; @ls -la Packages/*.pkgar 2>/dev/null | wc -l && echo "pkgar files in Packages/" +validate-patches: + @bash local/scripts/validate-patches.sh diff --git a/config/redbear-device-services.toml b/config/redbear-device-services.toml index 63d9271f..54127576 100644 --- a/config/redbear-device-services.toml +++ b/config/redbear-device-services.toml @@ -36,7 +36,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/12_boot-late.target" +path = "/etc/init.d/12_boot-late.target" data = """ [unit] description = "Late boot services target" @@ -508,7 +508,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/00_driver-manager.service" +path = "/etc/init.d/00_driver-manager.service" data = """ [unit] description = "PCI driver spawner" @@ -584,7 +584,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/10_evdevd.service" +path = "/etc/init.d/10_evdevd.service" data = """ [unit] description = "Evdev input daemon" @@ -661,7 +661,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/15_cpufreqd.service" +path = "/etc/init.d/15_cpufreqd.service" data = """ [unit] description = "CPU frequency scaling daemon" @@ -701,7 +701,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/15_thermald.service" +path = "/etc/init.d/15_thermald.service" data = """ [unit] description = "Thermal management daemon" @@ -741,7 +741,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/15_hwrngd.service" +path = "/etc/init.d/15_hwrngd.service" data = """ [unit] description = "Hardware RNG entropy daemon" @@ -781,7 +781,7 @@ pattern = "i915/adlp_dmc_ver2_16.bin" chain = ["i915/adlp_dmc_ver2_14.bin", "i915/adlp_dmc_ver2_12.bin"] """ [[files]] -path = "/usr/lib/init.d/13_driver-params.service" +path = "/etc/init.d/13_driver-params.service" data = """ [unit] description = "Driver parameter scheme" @@ -793,7 +793,7 @@ type = { scheme = "driver-params" } """ [[files]] -path = "/usr/lib/init.d/16_redbear-acmd.service" +path = "/etc/init.d/16_redbear-acmd.service" data = """ [unit] description = "USB CDC ACM serial daemon" @@ -805,7 +805,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/16_redbear-ecmd.service" +path = "/etc/init.d/16_redbear-ecmd.service" data = """ [unit] description = "USB CDC ECM/NCM ethernet daemon" @@ -817,7 +817,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/16_redbear-usbaudiod.service" +path = "/etc/init.d/16_redbear-usbaudiod.service" data = """ [unit] description = "USB Audio Class daemon" diff --git a/config/redbear-full.toml b/config/redbear-full.toml index a93ed831..d000bce7 100644 --- a/config/redbear-full.toml +++ b/config/redbear-full.toml @@ -159,7 +159,7 @@ data = "/usr/share/fonts" symlink = true [[files]] -path = "/usr/lib/init.d/05_boot-essential.target" +path = "/etc/init.d/05_boot-essential.target" data = """ [unit] description = "Boot essential services target" @@ -169,7 +169,7 @@ requires_weak = [ """ [[files]] -path = "/usr/lib/init.d/13_iommu.service" +path = "/etc/init.d/13_iommu.service" data = """ [unit] description = "IOMMU DMA remapping daemon" @@ -184,7 +184,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/12_dbus.service" +path = "/etc/init.d/12_dbus.service" data = """ [unit] description = "D-Bus system bus" @@ -200,7 +200,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_redbear-sessiond.service" +path = "/etc/init.d/13_redbear-sessiond.service" data = """ [unit] description = "Red Bear session broker (org.freedesktop.login1)" @@ -214,7 +214,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_seatd.service" +path = "/etc/init.d/13_seatd.service" data = """ [unit] description = "seatd seat management daemon" @@ -230,7 +230,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_redbear-keymapd.service" +path = "/etc/init.d/13_redbear-keymapd.service" data = """ [unit] description = "Runtime keymap daemon" @@ -244,7 +244,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_redbear-ime.service" +path = "/etc/init.d/13_redbear-ime.service" data = """ [unit] description = "Input method engine daemon" @@ -258,7 +258,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_redbear-accessibility.service" +path = "/etc/init.d/13_redbear-accessibility.service" data = """ [unit] description = "Accessibility input filter daemon (sticky/slow/bounce keys)" @@ -272,7 +272,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-upower.service" +path = "/etc/init.d/14_redbear-upower.service" data = """ [unit] description = "UPower D-Bus service (org.freedesktop.UPower)" @@ -286,7 +286,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-udisks.service" +path = "/etc/init.d/14_redbear-udisks.service" data = """ [unit] description = "UDisks2 D-Bus service (org.freedesktop.UDisks2)" @@ -300,7 +300,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-polkit.service" +path = "/etc/init.d/14_redbear-polkit.service" data = """ [unit] description = "PolicyKit1 D-Bus service (org.freedesktop.PolicyKit1)" @@ -314,7 +314,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/19_redbear-authd.service" +path = "/etc/init.d/19_redbear-authd.service" data = """ [unit] description = "Red Bear authentication daemon" @@ -329,7 +329,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/20_display.service" +path = "/etc/init.d/20_display.service" data = """ [unit] description = "KDE session assembly helper" @@ -349,7 +349,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/20_greeter.service" +path = "/etc/init.d/20_greeter.service" data = """ [unit] description = "Red Bear greeter service" @@ -369,7 +369,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/29_activate_console.service" +path = "/etc/init.d/29_activate_console.service" data = """ [unit] description = "Activate fallback console VT" @@ -384,7 +384,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/30_console.service" +path = "/etc/init.d/30_console.service" data = """ [unit] description = "Console terminals" @@ -399,7 +399,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/31_debug_console.service" +path = "/etc/init.d/31_debug_console.service" data = """ [unit] description = "Debug console on serial port" @@ -409,12 +409,13 @@ requires_weak = [ [service] cmd = "getty" -args = ["/scheme/debug", "-J"] +args = ["/scheme/debug/no-preserve", "-J"] type = "oneshot_async" +respawn = true """ [[files]] -path = "/usr/lib/init.d/99_diag_serial.service" +path = "/etc/init.d/99_diag_serial.service" data = """ [unit] description = "Serial diagnostic marker" diff --git a/config/redbear-greeter-services.toml b/config/redbear-greeter-services.toml index 130056ec..76f32d1f 100644 --- a/config/redbear-greeter-services.toml +++ b/config/redbear-greeter-services.toml @@ -3,7 +3,7 @@ # This fragment is intended to be included by the active desktop/graphics target. [[files]] -path = "/usr/lib/init.d/05_boot-essential.target" +path = "/etc/init.d/05_boot-essential.target" data = """ [unit] description = "Boot essential services target" @@ -30,7 +30,7 @@ redbear-session-launch = {} redbear-greeter = {} [[files]] -path = "/usr/lib/init.d/19_redbear-authd.service" +path = "/etc/init.d/19_redbear-authd.service" data = """ [unit] description = "Red Bear authentication daemon" @@ -44,7 +44,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/20_display.service" +path = "/etc/init.d/20_display.service" data = """ [unit] description = "Compositor proof (Phase 2: KWin virtual + Qt6 smoke + 60s survival)" @@ -61,7 +61,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/20_greeter.service" +path = "/etc/init.d/20_greeter.service" data = """ [unit] description = "Red Bear greeter service (experimental — Phase 3 user session bring-up)" @@ -80,7 +80,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/30_console.service" +path = "/etc/init.d/30_console.service" data = """ [unit] description = "Console terminals" @@ -96,7 +96,7 @@ respawn = true """ [[files]] -path = "/usr/lib/init.d/29_activate_console.service" +path = "/etc/init.d/29_activate_console.service" data = """ [unit] description = "Activate fallback console VT" @@ -111,7 +111,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/31_debug_console.service" +path = "/etc/init.d/31_debug_console.service" data = """ [unit] description = "Debug console" diff --git a/config/redbear-legacy-base.toml b/config/redbear-legacy-base.toml index 225ad88c..3a47819c 100644 --- a/config/redbear-legacy-base.toml +++ b/config/redbear-legacy-base.toml @@ -16,7 +16,7 @@ zsh = {} [[files]] -path = "/usr/lib/init.d/00_base.service" +path = "/etc/init.d/00_base.service" data = """ [unit] description = "Base environment setup (tmpdir)" diff --git a/config/redbear-legacy-desktop.toml b/config/redbear-legacy-desktop.toml index 59d962ae..32f79fd3 100644 --- a/config/redbear-legacy-desktop.toml +++ b/config/redbear-legacy-desktop.toml @@ -4,17 +4,17 @@ # the active redbear-full config provides its own display/console/greeter services. [[files]] -path = "/usr/lib/init.d/20_display.service" +path = "/etc/init.d/20_display.service" data = "" [[files]] -path = "/usr/lib/init.d/29_activate_console.service" +path = "/etc/init.d/29_activate_console.service" data = "" [[files]] -path = "/usr/lib/init.d/30_console.service" +path = "/etc/init.d/30_console.service" data = "" [[files]] -path = "/usr/lib/init.d/31_debug_console.service" +path = "/etc/init.d/31_debug_console.service" data = "" diff --git a/config/redbear-mini.toml b/config/redbear-mini.toml index 8611431f..f8dae2b6 100644 --- a/config/redbear-mini.toml +++ b/config/redbear-mini.toml @@ -296,7 +296,7 @@ type = { scheme = "ucsi" } """ [[files]] -path = "/usr/lib/init.d/12_boot-late.target" +path = "/etc/init.d/12_boot-late.target" data = """ [unit] description = "Late boot services target" @@ -306,7 +306,7 @@ requires_weak = [ """ [[files]] -path = "/usr/lib/init.d/11_udev.service" +path = "/etc/init.d/11_udev.service" data = """ [unit] description = "udev compatibility shim" @@ -317,11 +317,11 @@ requires_weak = [ [service] cmd = "udev-shim" -type = { scheme = "udev" } +type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/10_evdevd.service" +path = "/etc/init.d/10_evdevd.service" data = """ [unit] description = "Evdev input daemon" @@ -336,7 +336,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/11_wifictl.service" +path = "/etc/init.d/11_wifictl.service" data = """ [unit] description = "Wi-Fi control daemon" @@ -351,7 +351,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/12_dbus.service" +path = "/etc/init.d/12_dbus.service" data = """ [unit] description = "D-Bus system bus" @@ -366,7 +366,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_redbear-sessiond.service" +path = "/etc/init.d/13_redbear-sessiond.service" data = """ [unit] description = "Red Bear session broker (org.freedesktop.login1)" @@ -380,7 +380,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/13_iommu.service" +path = "/etc/init.d/13_iommu.service" data = """ [unit] description = "IOMMU DMA remapping daemon" @@ -395,7 +395,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-upower.service" +path = "/etc/init.d/14_redbear-upower.service" data = """ [unit] description = "UPower D-Bus service (org.freedesktop.UPower)" @@ -409,7 +409,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-udisks.service" +path = "/etc/init.d/14_redbear-udisks.service" data = """ [unit] description = "UDisks2 D-Bus service (org.freedesktop.UDisks2)" @@ -423,7 +423,7 @@ type = "oneshot_async" """ [[files]] -path = "/usr/lib/init.d/14_redbear-polkit.service" +path = "/etc/init.d/14_redbear-polkit.service" data = """ [unit] description = "PolicyKit1 D-Bus service (org.freedesktop.PolicyKit1)" @@ -465,3 +465,7 @@ path = "/etc/pcid.d/00_text_mode_gpu_mask.toml" data = """ # redbear-live-mini: no display driver matched; class 0x03 devices are skipped """ + +[[files]] +path = "/etc/environment.d/10-debug.conf" +data = "INIT_LOG_LEVEL=DEBUG" diff --git a/config/redbear-netctl.toml b/config/redbear-netctl.toml index b50a0613..b2196d19 100644 --- a/config/redbear-netctl.toml +++ b/config/redbear-netctl.toml @@ -90,7 +90,7 @@ IP=bounded """ [[files]] -path = "/usr/lib/init.d/12_netctl.service" +path = "/etc/init.d/12_netctl.service" data = """ [unit] description = "Network profile application" diff --git a/local/AGENTS.md b/local/AGENTS.md index 22363bf6..6f96e029 100644 --- a/local/AGENTS.md +++ b/local/AGENTS.md @@ -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/` diff --git a/local/docs/BUILD-SYSTEM-HARDENING-PLAN.md b/local/docs/BUILD-SYSTEM-HARDENING-PLAN.md new file mode 100644 index 00000000..19406e88 --- /dev/null +++ b/local/docs/BUILD-SYSTEM-HARDENING-PLAN.md @@ -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 (1–2 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 (2–3 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, +} + +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 (3–5 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.` + +### 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 (2–3 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 | 1–2 days | None | Low | Prevents recurrence immediately | +| Phase 5 | 1 day | None | Low | Knowledge preservation | +| Phase 2 | 2–3 days | Phase 1 | Medium | Catches future collisions | +| Phase 4 | 2–3 days | Phase 1 | Medium | Validates built images | +| Phase 3 | 3–5 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 I1–I3 | 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 I1–I3 to the existing + surface-ownership model. Phases 1–4 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. diff --git a/local/docs/BUILD-SYSTEM-INVARIANTS.md b/local/docs/BUILD-SYSTEM-INVARIANTS.md index 6f3cdf3c..546bde31 100644 --- a/local/docs/BUILD-SYSTEM-INVARIANTS.md +++ b/local/docs/BUILD-SYSTEM-INVARIANTS.md @@ -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` diff --git a/local/patches/installer/redox.patch b/local/patches/installer/redox.patch index 250fcd87..bd162af5 100644 --- a/local/patches/installer/redox.patch +++ b/local/patches/installer/redox.patch @@ -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( fs: FileSystem, 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(disk_path: P, disk_option: &DiskOption, callback: F) -> Result where P: AsRef, -@@ -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( _fs: &mut redoxfs::FileSystem, -@@ -801,6 +1348,27 @@ pub fn try_fast_install( +@@ -801,6 +1368,27 @@ pub fn try_fast_install( 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, ++} ++ ++pub struct CollisionTracker { ++ files: BTreeMap, ++ collisions: Vec, ++ 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 { ++ 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(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(()) ++ } ++} diff --git a/mk/depends.mk b/mk/depends.mk index 67c04d01..82dba8d3 100644 --- a/mk/depends.mk +++ b/mk/depends.mk @@ -26,4 +26,8 @@ endif endif endif + endif + +lint-config: + @scripts/lint-config-paths.sh diff --git a/mk/disk.mk b/mk/disk.mk index 5302bc7c..e46d4f10 100644 --- a/mk/disk.mk +++ b/mk/disk.mk @@ -109,3 +109,10 @@ else @$(FUMOUNT) /tmp/redox_installer 2>/dev/null || echo "Warning: failed to unmount /tmp/redox_installer" @echo "\033[1;36;49mFilesystem unmounted\033[0m" endif + +validate-init: $(BUILD)/harddrive.img + @scripts/validate-init-services.sh $(BUILD)/harddrive.img + +validate: lint-config validate-init + @scripts/validate-file-ownership.sh + @echo "\033[1;36;49mBuild validation passed\033[0m" diff --git a/recipes/core/base/recipe.toml b/recipes/core/base/recipe.toml index 64fa654f..5f4ca147 100644 --- a/recipes/core/base/recipe.toml +++ b/recipes/core/base/recipe.toml @@ -39,6 +39,76 @@ patches = [ "P4-thermald-workspace.patch", ] +[package] +installs = [ + "/lib/pcid.d/ac97d.toml", + "/lib/pcid.d/e1000d.toml", + "/lib/pcid.d/ihdad.toml", + "/lib/pcid.d/ihdgd.toml", + "/lib/pcid.d/ixgbed.toml", + "/lib/pcid.d/rtl8139d.toml", + "/lib/pcid.d/rtl8168d.toml", + "/lib/pcid.d/vboxd.toml", + "/lib/pcid.d/virtio-netd.toml", + "/lib/pcid.d/xhcid.toml", + "/usr/bin/audiod", + "/usr/bin/dhcpd", + "/usr/bin/dw-acpi-i2cd", + "/usr/bin/gpiod", + "/usr/bin/i2cd", + "/usr/bin/i2c-gpio-expanderd", + "/usr/bin/i2c-hidd", + "/usr/bin/inputd", + "/usr/bin/intel-gpiod", + "/usr/bin/ipcd", + "/usr/bin/netstack", + "/usr/bin/pcid", + "/usr/bin/pcid-spawner", + "/usr/bin/ptyd", + "/usr/bin/redoxerd", + "/usr/bin/smolnetd", + "/usr/bin/ucsid", + "/usr/lib/drivers/ac97d", + "/usr/lib/drivers/amd-mp2-i2cd", + "/usr/lib/drivers/e1000d", + "/usr/lib/drivers/ihdad", + "/usr/lib/drivers/ihdgd", + "/usr/lib/drivers/intel-lpss-i2cd", + "/usr/lib/drivers/intel-thc-hidd", + "/usr/lib/drivers/ixgbed", + "/usr/lib/drivers/rtl8139d", + "/usr/lib/drivers/rtl8168d", + "/usr/lib/drivers/sb16d", + "/usr/lib/drivers/thermald", + "/usr/lib/drivers/usbctl", + "/usr/lib/drivers/usbhidd", + "/usr/lib/drivers/usbhubd", + "/usr/lib/drivers/usbscsid", + "/usr/lib/drivers/vboxd", + "/usr/lib/drivers/virtio-gpud", + "/usr/lib/drivers/virtio-netd", + "/usr/lib/drivers/xhcid", + "/usr/lib/init.d/00_base.target", + "/usr/lib/init.d/00_ipcd.service", + "/usr/lib/init.d/00_pcid-spawner.service", + "/usr/lib/init.d/00_ptyd.service", + "/usr/lib/init.d/00_sudo.service", + "/usr/lib/init.d/00_tmp", + "/usr/lib/init.d/05_boot_essential.target", + "/usr/lib/init.d/10_dhcpd.service", + "/usr/lib/init.d/10_net.target", + "/usr/lib/init.d/10_smolnetd.service", + "/usr/lib/init.d/12_boot_late.target", + "/usr/lib/init.d/12_dbus.service", + "/usr/lib/init.d/13_seatd.service", + "/usr/lib/init.d/13_sessiond.service", + "/usr/lib/init.d/20_audiod.service", + "/usr/lib/init.d/29_activate_console.service", + "/usr/lib/init.d/30_console.service", + "/usr/lib/init.d/30_thermald.service", + "/usr/lib/init.d/31_debug_console.service", +] + [build] template = "custom" script = """ diff --git a/scripts/generate-installs-manifest.sh b/scripts/generate-installs-manifest.sh new file mode 100755 index 00000000..25434ca2 --- /dev/null +++ b/scripts/generate-installs-manifest.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# generate-installs-manifest.sh — Inspect recipe stage directory and output +# suggested `installs = [...]` declarations for recipe.toml +# +# Usage: +# scripts/generate-installs-manifest.sh +# scripts/generate-installs-manifest.sh base +# scripts/generate-installs-manifest.sh evdevd +# +# The script examines the recipe's stage directory after a successful build +# and lists all installed files relative to the sysroot root. + +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 " >&2 + echo " Inspects the recipe's stage directory and outputs suggested installs = [...]" >&2 + exit 1 +fi + +RECIPE_NAME="$1" + +# Find the recipe directory +RECIPE_DIR="" +for category_dir in recipes/*/; do + if [ -d "${category_dir}${RECIPE_NAME}" ]; then + RECIPE_DIR="${category_dir}${RECIPE_NAME}" + break + fi +done + +if [ -z "$RECIPE_DIR" ]; then + echo "ERROR: Recipe '$RECIPE_NAME' not found in recipes/*/" >&2 + exit 1 +fi + +# Determine target architecture +source mk/config.mk 2>/dev/null || true +TARGET_ARCH="${ARCH:-x86_64}" +STAGE_DIR="${RECIPE_DIR}/target/${TARGET_ARCH}-unknown-redox/stage" + +if [ ! -d "$STAGE_DIR" ]; then + STAGE_DIR="${RECIPE_DIR}/target/${TARGET_ARCH}-unknown-redox/stage.tmp" +fi + +if [ ! -d "$STAGE_DIR" ]; then + echo "ERROR: Stage directory not found: $STAGE_DIR" >&2 + echo " Build the recipe first: ./target/release/repo cook $RECIPE_DIR" >&2 + exit 1 +fi + +echo "# Generated installs manifest for $RECIPE_NAME" +echo "# Recipe: $RECIPE_DIR" +echo "# Stage: $STAGE_DIR" +echo "" +echo "[package]" + +# Collect all files, sorted +FILES=$(cd "$STAGE_DIR" && find . -type f -o -type l | sed 's|^\./||' | sort) + +if [ -z "$FILES" ]; then + echo "# No files found in stage directory" + exit 0 +fi + +echo "installs = [" +while IFS= read -r file; do + echo " \"/${file}\"," +done <<< "$FILES" +echo "]" + +echo "" +echo "# Total: $(echo "$FILES" | wc -l) files" +echo "# To apply: copy the installs = [...] block into $RECIPE_DIR/recipe.toml" diff --git a/scripts/lint-config-paths.sh b/scripts/lint-config-paths.sh new file mode 100755 index 00000000..1262179e --- /dev/null +++ b/scripts/lint-config-paths.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# lint-config-paths.sh — Detect init service file path violations in config files +# +# Init service files in config [[files]] entries MUST use /etc/init.d/ paths, +# NOT /usr/lib/init.d/. The base package installs to /usr/lib/init.d/ and +# silently overwrites any config files placed there during install_dir(). +# +# The init system's config_for_dirs() gives /etc/init.d/ priority over +# /usr/lib/init.d/ for the same filename, so config overrides must use /etc/. +# +# Usage: +# scripts/lint-config-paths.sh # Check all redbear configs +# scripts/lint-config-paths.sh config/*.toml # Check specific files +# +# Exit codes: +# 0 — No violations found +# 1 — Violations found (printed to stderr) + +set -euo pipefail + +# Default to all redbear configs if no arguments +if [ $# -eq 0 ]; then + set -- config/redbear-*.toml +fi + +violations=0 + +for config_file in "$@"; do + if [ ! -f "$config_file" ]; then + echo "WARN: $config_file not found, skipping" >&2 + continue + fi + + # Find [[files]] entries with /usr/lib/init.d/ paths + # We look for path = "/usr/lib/init.d/..." lines + line_num=0 + in_files_section=false + + while IFS= read -r line; do + line_num=$((line_num + 1)) + + # Track TOML structure to only check [[files]] sections + if [[ "$line" =~ ^\[\[files\]\] ]]; then + in_files_section=true + continue + elif [[ "$line" =~ ^\[ ]]; then + in_files_section=false + continue + fi + + if $in_files_section; then + # Check for /usr/lib/init.d/ paths + if [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"/usr/lib/init\.d/ ]]; then + echo "VIOLATION: $config_file:$line_num" >&2 + echo " Line: $line" >&2 + echo " Fix: Change /usr/lib/init.d/ to /etc/init.d/" >&2 + echo "" >&2 + violations=$((violations + 1)) + fi + + # Also check for /usr/lib/environment.d/ (similar override pattern) + if [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"/usr/lib/environment\.d/ ]]; then + echo "VIOLATION: $config_file:$line_num" >&2 + echo " Line: $line" >&2 + echo " Fix: Change /usr/lib/environment.d/ to /etc/environment.d/" >&2 + echo "" >&2 + violations=$((violations + 1)) + fi + fi + done < "$config_file" +done + +if [ $violations -gt 0 ]; then + echo "FAILED: $violations init service path violation(s) found." >&2 + echo "Config [[files]] entries must use /etc/init.d/ not /usr/lib/init.d/" >&2 + echo "See: local/docs/BUILD-SYSTEM-HARDENING-PLAN.md Phase 1" >&2 + exit 1 +else + echo "OK: No init service path violations in config files." + exit 0 +fi diff --git a/scripts/validate-file-ownership.sh b/scripts/validate-file-ownership.sh new file mode 100755 index 00000000..4da188aa --- /dev/null +++ b/scripts/validate-file-ownership.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# validate-file-ownership.sh — Check recipe file ownership conflicts +# +# Reads the optional 'installs' field from recipe.toml [package] sections +# and detects conflicts where multiple recipes claim the same path. +# +# Also cross-references config [[files]] paths against recipe installs +# to detect config-layer / package-layer collisions. +# +# Usage: +# scripts/validate-file-ownership.sh +# +# Exit codes: +# 0 — No conflicts or violations found +# 1 — Conflicts detected + +set -euo pipefail + +failures=0 + +# Registry: path -> recipe_name +declare -A PATH_REGISTRY + +echo "=== Scanning recipes for installs declarations ===" + +recipe_count=0 +declared_count=0 + +for recipe_toml in recipes/*/recipe.toml recipes/*/*/recipe.toml; do + [ -f "$recipe_toml" ] || continue + recipe_count=$((recipe_count + 1)) + + recipe_dir=$(dirname "$recipe_toml") + recipe_name=$(basename "$recipe_dir") + + # Parse installs field from [package] section + # Format: installs = ["/usr/bin/foo", "/usr/lib/init.d/10_bar.service"] + in_package=false + in_installs=false + while IFS= read -r line; do + if [[ "$line" =~ ^\[package\] ]]; then + in_package=true + in_installs=false + continue + elif [[ "$line" =~ ^\[ ]]; then + in_package=false + in_installs=false + continue + fi + + if $in_package; then + if [[ "$line" =~ ^installs ]]; then + in_installs=true + fi + if $in_installs; then + paths=$(echo "$line" | grep -oP '"[^"]+"' | tr -d '"' || true) + for path in $paths; do + declared_count=$((declared_count + 1)) + if [ -n "${PATH_REGISTRY[$path]+x}" ]; then + existing="${PATH_REGISTRY[$path]}" + echo "CONFLICT: '$path' claimed by both '$existing' and '$recipe_name'" + if [[ "$path" == *"/init.d/"* ]]; then + echo " SEVERITY: init service conflict (critical)" + failures=$((failures + 1)) + else + echo " SEVERITY: non-critical path overlap" + fi + else + PATH_REGISTRY["$path"]="$recipe_name" + fi + done + if [[ "$line" =~ \] ]]; then + in_installs=false + fi + fi + fi + done < "$recipe_toml" +done + +echo " Scanned $recipe_count recipes, found $declared_count declared install paths" + +echo "" +echo "=== Cross-referencing config [[files]] against recipe installs ===" + +config_conflicts=0 +for config_file in config/redbear-*.toml; do + [ -f "$config_file" ] || continue + config_name=$(basename "$config_file") + + in_files=false + while IFS= read -r line; do + if [[ "$line" =~ ^\[\[files\]\] ]]; then + in_files=true + continue + elif [[ "$line" =~ ^\[ ]]; then + in_files=false + continue + fi + + if $in_files && [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then + config_path="${BASH_REMATCH[1]}" + for registered_path in "${!PATH_REGISTRY[@]}"; do + if [ "$config_path" = "$registered_path" ]; then + echo "COLLISION: config '$config_name' creates '$config_path' but recipe '${PATH_REGISTRY[$registered_path]}' also installs it" + config_conflicts=$((config_conflicts + 1)) + if [[ "$config_path" == *"/usr/lib/init.d/"* ]]; then + echo " SEVERITY: init service in /usr/lib/init.d/ (will be overwritten by package)" + failures=$((failures + 1)) + fi + fi + done + fi + done < "$config_file" +done + +echo " Found $config_conflicts config/recipe path collision(s)" + +echo "" +echo "=== Validation complete ===" +if [ $declared_count -eq 0 ]; then + echo " NOTE: No recipes declare 'installs' yet. Add installs = [...] to [package] sections for full validation." +fi +if [ $failures -gt 0 ]; then + echo "FAILED: $failures conflict(s) found" >&2 + exit 1 +else + echo "PASSED: No conflicts found" +fi diff --git a/scripts/validate-init-services.sh b/scripts/validate-init-services.sh new file mode 100755 index 00000000..125b10be --- /dev/null +++ b/scripts/validate-init-services.sh @@ -0,0 +1,193 @@ +#!/bin/bash +# validate-init-services.sh — Post-image init service validation +# +# Validates that init service files in a built image match expectations: +# 1. Config overrides in /etc/init.d/ differ from /usr/lib/init.d/ counterparts +# 2. No missing init service files that configs expected to create +# 3. Scheme-type services have corresponding binaries +# 4. No dependency cycles in service graph +# +# Requires: redoxfs FUSE mount (or ext4 mount) +# +# Usage: +# scripts/validate-init-services.sh build/x86_64/redbear-full/harddrive.img +# scripts/validate-init-services.sh build/x86_64/redbear-mini/harddrive.img +# +# Exit codes: +# 0 — All validations passed +# 1 — Validation failures found + +set -euo pipefail + +IMAGE_PATH="${1:?Usage: $0 }" + +if [ ! -f "$IMAGE_PATH" ]; then + echo "ERROR: Image not found: $IMAGE_PATH" >&2 + exit 1 +fi + +MOUNT_DIR=$(mktemp -d /tmp/redbear-validate-XXXXXX) +failures=0 + +cleanup() { + fusermount -u "$MOUNT_DIR" 2>/dev/null || true + rmdir "$MOUNT_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +echo "=== Mounting image ===" +redoxfs "$IMAGE_PATH" "$MOUNT_DIR" +sleep 1 + +echo "" +echo "=== 1. Checking /etc/init.d/ override effectiveness ===" + +if [ -d "$MOUNT_DIR/usr/lib/init.d" ] && [ -d "$MOUNT_DIR/etc/init.d" ]; then + etc_count=$(find "$MOUNT_DIR/etc/init.d" -type f 2>/dev/null | wc -l) + usr_count=$(find "$MOUNT_DIR/usr/lib/init.d" -type f 2>/dev/null | wc -l) + echo " /usr/lib/init.d/: $usr_count files" + echo " /etc/init.d/: $etc_count files" + + for etc_file in "$MOUNT_DIR"/etc/init.d/*; do + [ -f "$etc_file" ] || continue + basename=$(basename "$etc_file") + usr_file="$MOUNT_DIR/usr/lib/init.d/$basename" + + if [ -f "$usr_file" ]; then + if diff -q "$etc_file" "$usr_file" > /dev/null 2>&1; then + echo " WARN: /etc/init.d/$basename identical to /usr/lib/init.d/$basename (redundant override)" + else + echo " OK: /etc/init.d/$basename differs from /usr/lib/init.d/$basename (override active)" + fi + else + echo " OK: /etc/init.d/$basename (new service, no package counterpart)" + fi + done +else + echo " One or both init.d directories missing — skipping override check" +fi + +echo "" +echo "=== 2. Checking scheme-type services have binaries ===" + +if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then + for svc_file in "$MOUNT_DIR"/usr/lib/init.d/*.service "$MOUNT_DIR"/etc/init.d/*.service; do + [ -f "$svc_file" ] || continue + + scheme_name=$(grep -oP 'type\s*=\s*\{\s*scheme\s*=\s*"([^"]+)"' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true) + if [ -n "$scheme_name" ]; then + # Scheme daemon binaries use d convention (e.g., scheme "evdev" -> binary "evdevd") + found_binary=false + for candidate in "$scheme_name" "${scheme_name}d" "redbear-$scheme_name"; do + if [ -x "$MOUNT_DIR/usr/bin/$candidate" ]; then + echo " OK: scheme '$scheme_name' has binary /usr/bin/$candidate" + found_binary=true + break + fi + done + if [ "$found_binary" = "false" ]; then + echo " FAIL: scheme '$scheme_name' has NO binary at /usr/bin/$scheme_name, /usr/bin/${scheme_name}d, or /usr/bin/redbear-$scheme_name" + failures=$((failures + 1)) + fi + fi + done +fi + +echo "" +echo "=== 3. Checking service dependency graph for cycles ===" + +if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then + # Collect all services + services_dir=$(mktemp -d) + for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do + [ -f "$svc_file" ] || continue + basename=$(basename "$svc_file") + cp "$svc_file" "$services_dir/$basename" 2>/dev/null || true + done + + # Simple cycle detection: build adjacency list, do DFS + python3 -c " +import os, re, sys +from collections import defaultdict + +svc_dir = '$services_dir' +graph = defaultdict(list) +all_nodes = set() + +for f in os.listdir(svc_dir): + all_nodes.add(f) + content = open(os.path.join(svc_dir, f)).read() + for dep in re.findall(r'requires_weak\s*=\s*\[([^\]]*)\]', content) + re.findall(r'requires\s*=\s*\[([^\]]*)\]', content): + for d in re.findall(r'\"([^\"]+)\"', dep): + graph[f].append(d) + +# DFS cycle detection +WHITE, GRAY, BLACK = 0, 1, 2 +color = {n: WHITE for n in all_nodes} +has_cycle = False + +def dfs(node): + global has_cycle + color[node] = GRAY + for neighbor in graph.get(node, []): + if neighbor not in color: + continue + if color[neighbor] == GRAY: + print(f' CYCLE: {node} -> {neighbor}') + has_cycle = True + elif color[neighbor] == WHITE: + dfs(neighbor) + color[node] = BLACK + +for node in sorted(all_nodes): + if color[node] == WHITE: + dfs(node) + +if not has_cycle: + print(' OK: No dependency cycles detected') +else: + sys.exit(1) +" 2>&1 || { + echo " FAIL: Dependency cycle detected!" + failures=$((failures + 1)) + } + + rm -rf "$services_dir" +fi + +echo "" +echo "=== 4. Checking for missing dependencies ===" + +if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then + all_files=$(mktemp) + for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do + [ -f "$svc_file" ] || continue + basename "$svc_file" >> "$all_files" + done + + for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do + [ -f "$svc_file" ] || continue + svc_name=$(basename "$svc_file") + for dep in $(grep -oP 'requires_weak\s*=\s*\[([^\]]*)\]' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true); do + if ! grep -q "^${dep}$" "$all_files" 2>/dev/null; then + echo " WARN: $svc_name requires '$dep' but file not found in any init.d directory" + fi + done + for dep in $(grep -oP 'requires\s*=\s*\[([^\]]*)\]' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true); do + if ! grep -q "^${dep}$" "$all_files" 2>/dev/null; then + echo " FAIL: $svc_name requires '$dep' (hard dep) but file not found" + failures=$((failures + 1)) + fi + done + done + rm -f "$all_files" +fi + +echo "" +echo "=== Validation complete ===" +if [ $failures -gt 0 ]; then + echo "FAILED: $failures validation failure(s) found" >&2 + exit 1 +else + echo "PASSED: All validations passed" +fi diff --git a/src/recipe.rs b/src/recipe.rs index 49da9e22..41c79758 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -130,6 +130,8 @@ pub struct PackageRecipe { pub dependencies: Vec, pub version: Option, pub description: Option, + #[serde(rename = "installs")] + pub installs: Vec, } #[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize)]