dc48787f74
Per Round 6-7 out-of-scope: collision detection didn't track
dynamically-generated files from base's Makefile. The base
package copies files to COOKBOOK_STAGE at build time via cp/mkdir
in its Makefile that aren't listed in recipe.toml's [[package]].installs
or [[package]].files fields.
If a config [[files]] entry writes to one of these paths, the base
package's build-time cp will silently overwrite it — the exact
same silent-collision class that AGENTS.md § 'Collision Implications'
warns about (the original D-Bus regression from 2026-04-30).
Added 18 hardcoded dynamic-build paths from base's Makefile:
initfs 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'
driver paths:
'/usr/lib/drivers/acpid'
'/usr/lib/drivers/fbbootlogd'
'/usr/lib/drivers/hwd'
'/usr/lib/drivers/inputd'
'/usr/lib/drivers/lived'
'/usr/lib/drivers/redoxfs'
Tested with synthetic collision:
- Created config/redbear-test-dynamic.toml with
path='/usr/lib/boot/initfs.img'
- Collision detection correctly reports exact-match collision
with source 'build-dynamic'
- Cleanup removes the test file, returning to 0 collisions
Survey count: 162 (was 144 + 18 dynamic paths). Production: 0
collisions.
294 lines
12 KiB
Python
Executable File
294 lines
12 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))
|
|
# 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())
|