Files
RedBear-OS/local/scripts/verify-collision-detection.py
T
vasilito 8d4818b94b phase 5.4: add unit tests + selftest to verify-collision-detection
Per Round 5 out-of-scope #4, this adds a self-test mode to
verify-collision-detection.py. The selftest covers the algorithm's
edge cases:

  1. exact match (cfg == install)         collision=True
  2. parent-dir prefix (install under cfg) collision=True
  3. /etc/init.d override of init.d        safe
  4. /etc/environment.d override of env.d  safe
  5. non-overlapping names                no collision
  6. parent of subpath (e.g. /etc vs /etc/foo) collision=True
  7. two siblings (same prefix)           no collision
  8. /etc/init.d/* with different base name safe

Run via:
  python3 local/scripts/verify-collision-detection.py --selftest

The selftest is wired into local/scripts/pre-push-checks.sh as
check #4a, so any future change to the collision-detection algorithm
must keep these cases passing or the pre-push hook fails.

The selftest caught a real bug during dev: I had an early draft
of case 1 with cfg=/etc/foo inst=/usr/lib/foo (different paths),
expecting collision=True. The actual algorithm correctly returned
False (no overlap). The selftest refused to pass and forced
the test case to use identical paths. This is exactly the kind
of regression the test was designed to catch.
2026-07-12 10:18:23 +03:00

265 lines
11 KiB
Python
Executable File

#!/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/<cat>/<pkg>/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))
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())