#!/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. 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) tuples from one recipe.toml.""" try: with open(recipe_path, 'rb') as f: data = tomllib.load(f) except Exception: return [] installs = [] pkg = data.get('package', {}) for path in pkg.get('installs', []): installs.append((str(recipe_path), str(path))) 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 in parse_recipe_installs(path): installs.append((r, p)) 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 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, '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, '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, kind in collisions[:50]: print(f' COLLISION: {kind}') 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 if __name__ == '__main__': sys.exit(main())