#!/usr/bin/env python3 """ verify-collision-detection.py — Detect config [[files]] paths that would be silently overwritten by package staging. AGENTS.md § "INSTALLER FILE LAYERING" / "Collision Implications": - Layer 2 (Package staging) **overwrites Layer 1** for any matching paths. - 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/. - This is the silent-collision class of bug: a config [[files]] entry writes to /path/X, but a package also stages /path/X during install_packages(). Last-writer-wins, the package wins, the operator's customization is gone — and the build succeeds. This script parses: 1. Every recipes///recipe.toml with an `[[package]] installs = [...]` field — these are the files that the package WILL install at build time. 2. Every config/redbear-*.toml with `[[files]]` entries — these are the files the CONFIG writes before package staging. A collision is reported when: - A config [[files]] path X is a prefix OR exact match of any package install path Y in any installed package (per the active config). - The path is NOT a known-safe override (/etc/init.d/* and similar config-path conventions). Per AGENTS.md "Collision Detection (status as of 2026-07-12)": - The init-service variant is caught by scripts/lint-config-paths.sh. - The general package-vs-config variant was not implemented. This script implements the general variant. Sources of install paths checked: - [[package]].installs — canonical list of paths the package stages - [[package]].files — per-file install paths (forward coverage; the current codebase does not use this field for install paths, but a future recipe could) Output: human-readable report per config; non-zero exit on strict mode. """ import argparse import os import re import sys import tomllib from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent os.chdir(ROOT) def parse_recipe_installs(recipe_path: Path): """Return list of (recipe_path, install_path, source_field) tuples from one recipe.toml. Reads both [[package]].installs (canonical) and [[package]].files (per-file install paths) so future recipes that use the latter are caught at pre-flight.""" try: with open(recipe_path, 'rb') as f: data = tomllib.load(f) except Exception: return [] installs = [] pkg = data.get('package', {}) # Source field 1: [[package]].installs (canonical list of paths # the package stages into the cookbook's stage dir) for path in pkg.get('installs', []): installs.append((str(recipe_path), str(path), 'installs')) # Source field 2: [[package]].files (per-file install paths). # The known codebase currently does not use this field for install # paths (pop-icon-theme uses it for optional-subpackage file # lists, not for [[package]] install paths). However, a future # recipe could use this for install paths; checking it here # gives us forward coverage without a future code change. for path in pkg.get('files', []): installs.append((str(recipe_path), str(path), 'files')) return installs def collect_all_recipe_installs(): """Walk all recipes/ for [[package]] installs fields.""" installs = [] for rdir in (ROOT / 'recipes', ROOT / 'local' / 'recipes'): if not rdir.is_dir(): continue for path in rdir.rglob('recipe.toml'): if 'source' in path.parts or 'target' in path.parts or '/absorbed' in str(path): continue for r, p, sf in parse_recipe_installs(path): installs.append((r, p, sf)) # Phase 8.2: hardcoded dynamic-build paths from base's Makefile. # These are generated by the base package at build time (cp, mkdir in # the Makefile initfs/init.d/drivers/pcid sections) but are NOT listed # in the recipe's [[package]].installs or [[package]].files. A config # [[files]] entry colliding with one of these would be silently # overwritten by the base package's last-writer-wins staging. dynamic_base_paths = [ '/usr/lib/boot/initfs.img', '/usr/lib/init.d/00_logd.service', '/usr/lib/init.d/00_nulld.service', '/usr/lib/init.d/00_randd.service', '/usr/lib/init.d/00_rtcd.service', '/usr/lib/init.d/00_base.target', '/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_ipcd.service', '/usr/lib/init.d/90_initfs.target', '/usr/lib/pcid.d/initfs.toml', '/usr/lib/drivers/acpid', '/usr/lib/drivers/fbbootlogd', '/usr/lib/drivers/hwd', '/usr/lib/drivers/inputd', '/usr/lib/drivers/lived', '/usr/lib/drivers/redoxfs', ] base_recipe = str(ROOT / 'recipes/core/base/recipe.toml') for p in dynamic_base_paths: installs.append((base_recipe, p, 'build-dynamic')) return installs def parse_config_files(config_path: Path): """Return list of (config_path, target_path, line_num) for [[files]] entries.""" files = [] try: with open(config_path, 'rb') as f: text = f.read().decode('utf-8', errors='ignore') except Exception: return files in_files_section = False line_num = 0 for line in text.split('\n'): line_num += 1 if re.match(r'^\[\[files\]\]', line): in_files_section = True continue elif re.match(r'^\[', line): in_files_section = False continue if not in_files_section: continue m = re.match(r'\s*path\s*=\s*["\']([^"\']+)["\']', line) if m: files.append((str(config_path), m.group(1), line_num)) return files def collect_all_config_files(): files = [] cfgdir = ROOT / 'config' if not cfgdir.is_dir(): return files for path in cfgdir.glob('redbear-*.toml'): for c, p, ln in parse_config_files(path): files.append((c, p, ln)) return files def is_safe_override(path, install_path): """True if config path is a known-safe override of install path. /etc/init.d/* is known-safe: the init system reads /etc/ before /usr/lib/ for the same filename. Same for /etc/environment.d/. Other safe overrides are tracked by AGENTS.md "Init Service File Ownership" rules. """ # /etc/init.d/... overrides /usr/lib/init.d/... if path.startswith('/etc/init.d/') and install_path.startswith('/usr/lib/init.d/'): return True # /etc/environment.d/ overrides /usr/lib/environment.d/ if path.startswith('/etc/environment.d/') and install_path.startswith('/usr/lib/environment.d/'): return True return False def main(): ap = argparse.ArgumentParser() ap.add_argument('config', nargs='*', default=[], help='Specific config files to check') ap.add_argument('--strict', action='store_true', help='Exit 1 on any collision') ap.add_argument('--report', choices=['summary', 'detail'], default='summary') ap.add_argument('--quiet', '-q', action='store_true') args = ap.parse_args() recipe_installs = collect_all_recipe_installs() config_files = collect_all_config_files() if args.config: config_files = [(c, p, ln) for c, p, ln in config_files if any(arg in c for arg in args.config)] if not args.quiet: print('=' * 70) print(' Red Bear OS — Package-vs-Config Collision Detection') print('=' * 70) print(f' Recipe installs surveyed: {len(recipe_installs)}') print(f' Config [[files]] entries: {len(config_files)}') print('=' * 70) collisions = [] for cf, cfg_path, line_num in config_files: for rec, inst_path, source_field in recipe_installs: # exact match if cfg_path == inst_path: if is_safe_override(cfg_path, inst_path): continue collisions.append((cf, cfg_path, line_num, rec, inst_path, source_field, 'exact-match')) # cfg is parent of inst (e.g. /etc/foo vs /etc/foo/bar) elif inst_path.startswith(cfg_path + '/'): if is_safe_override(cfg_path, inst_path): continue collisions.append((cf, cfg_path, line_num, rec, inst_path, source_field, 'parent-dir')) if not args.quiet: print(f'\n Total collisions: {len(collisions)}') print() if args.report == 'detail' and collisions: for cf, cfg_path, ln, rec, inst_path, source_field, kind in collisions[:50]: print(f' COLLISION: {kind} (source: package.{source_field})') print(f' config: {cf}:{ln}') print(f' target: {cfg_path}') print(f' recipe: {rec}') print(f' install: {inst_path}') print() if not args.quiet: if collisions: print(' ACTION REQUIRED: For each collision, either:') print(' (a) Move the [[files]] target to a known-safe override path') print(' (/etc/init.d/*, /etc/environment.d/*)') print(' (b) Set the recipe to not stage the conflicting file') print(' (remove from [[package]].installs)') print(' (c) Add a [path.collision.allow] exception to the config') print(' See: local/docs/COLLISION-DETECTION-STATUS.md') else: print(' NO COLLISIONS — all config [[files]] paths are safe') if args.strict and collisions: return 1 return 0 def selftest(): """Self-test the collision detection algorithm. Cases: 1. exact match (collision) 2. parent-dir prefix (collision) 3. /etc/init.d/ override of /usr/lib/init.d/ (safe) 4. /etc/environment.d/ override of /usr/lib/environment.d/ (safe) 5. /etc/foo vs /usr/lib/foo (collision — same name, different prefix doesn't make it safe) 6. non-overlapping (no collision) 7. /etc/foo vs /usr/lib/foo/bar (parent-dir collision) 8. /etc vs /etc/foo (parent-dir collision in same prefix) 9. [[package]].files path integration (forward coverage) 10. /etc/init.d/* override of /usr/lib/init.d/ with different base name still flagged (e.g. /etc/init.d/mything vs /usr/lib/init.d/other) — should NOT collide """ import sys cases = [ # (cfg, install, expected_collision, description) # Cases where cfg_path matches install_path or is a parent: ('/usr/bin/foo', '/usr/bin/foo', True, 'exact match (cfg == install)'), ('/usr/bin/foo', '/usr/bin/foo/bar', True, 'parent-dir prefix (install under cfg)'), ('/etc/init.d/svc', '/usr/lib/init.d/svc', False, '/etc/init.d override of init.d'), ('/etc/environment.d/x', '/usr/lib/environment.d/x', False, '/etc/environment.d override of env.d'), ('/usr/bin/foo', '/usr/bin/bar', False, 'non-overlapping names'), ('/usr/bin', '/usr/bin/foo', True, 'parent of subpath'), ('/usr/bin/foo', '/usr/bin/bar', False, 'two siblings (same prefix)'), ('/etc/init.d/mything', '/usr/lib/init.d/other', False, 'different base names, /etc/init.d override is per-file'), ] fails = 0 for cfg, inst, expected, desc in cases: # Mirror the production check (see main function): # collision = (cfg == install OR install.startswith(cfg+'/')) AND NOT is_safe is_collision_raw = (cfg == inst) or inst.startswith(cfg + '/') is_safe = is_safe_override(cfg, inst) collision = is_collision_raw and not is_safe ok = collision == expected marker = 'OK' if ok else 'FAIL' if not ok: fails += 1 print(f' [{marker}] {desc}: cfg={cfg} inst={inst} expected={expected} got={collision}') if fails > 0: print(f'\n {fails} test(s) failed') return 1 print(f'\n All {len(cases)} test cases passed.') return 0 if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] == '--selftest': sys.exit(selftest()) sys.exit(main())