Files
RedBear-OS/local/scripts/verify-collision-detection.py
T
vasilito 7132f9e5f1 phase 5.1: extend verify-collision-detection to [[package]].files
Per Round 5 out-of-scope #4: collision detection now also reads
[[package]].files in addition to [[package]].installs.

The codebase actually uses [[package]].files in 39 recipes (mostly
the recently-added Round 3 system drivers like redbear-power,
redbear-acmd, evdevd). The format is an inline table mapping
install path -> source basename:

  [[package.files]]
  "/usr/bin/redbear-power" = "redbear-power"

In the previous version of the script, this field was not
checked, so any future recipe using this pattern for install
paths would silently have its config [[files]] override ignored
by package staging. After this change, both fields are
checked with the same exact-match + parent-dir logic.

The script's collision-printing format is updated to show the
source field (installs or files) so operators can see which
field triggered the collision.

Tested with synthetic collision:
  - Created local/recipes/_test/ with [[package]].files=['/lib/test/special.toml']
  - Created config/redbear-test-files.toml with [[files]] path=/lib/test/special.toml
  - Verify-collision-detection correctly reported exact-match collision
  - Cleanup removed both test files

Production state (after cleanup):
  - 68 recipe installs surveyed
  - 76 [[package.files]] dict entries surveyed
  - 165 config [[files]] entries surveyed
  - 0 collisions

Per AGENTS.md, fork supremacy policy is unchanged: the script
still respects /etc/init.d/* and /etc/environment.d/* as known-safe
overrides.
2026-07-12 09:50:59 +03:00

210 lines
8.3 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
if __name__ == '__main__':
sys.exit(main())