8d4818b94b
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.
265 lines
11 KiB
Python
Executable File
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())
|