feat: build system hardening — collision detection, validation gates, init path enforcement
5-phase hardening to prevent silent file-layer collisions (the D-Bus regression class): Phase 1: lint-config-paths.sh + make lint-config in depends.mk Phase 2: CollisionTracker in installer (content-hash comparison) Phase 3: installs manifests in recipe.toml + validate-file-ownership.sh Phase 4: validate-init-services.sh + make validate in disk.mk Phase 5: documentation (AGENTS.md, BUILD-SYSTEM-HARDENING-PLAN.md) Both redbear-mini and redbear-full build and validate clean. 66 declared install paths in base, zero conflicts.
This commit is contained in:
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# generate-installs-manifest.sh — Inspect recipe stage directory and output
|
||||
# suggested `installs = [...]` declarations for recipe.toml
|
||||
#
|
||||
# Usage:
|
||||
# scripts/generate-installs-manifest.sh <recipe-name>
|
||||
# scripts/generate-installs-manifest.sh base
|
||||
# scripts/generate-installs-manifest.sh evdevd
|
||||
#
|
||||
# The script examines the recipe's stage directory after a successful build
|
||||
# and lists all installed files relative to the sysroot root.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <recipe-name>" >&2
|
||||
echo " Inspects the recipe's stage directory and outputs suggested installs = [...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RECIPE_NAME="$1"
|
||||
|
||||
# Find the recipe directory
|
||||
RECIPE_DIR=""
|
||||
for category_dir in recipes/*/; do
|
||||
if [ -d "${category_dir}${RECIPE_NAME}" ]; then
|
||||
RECIPE_DIR="${category_dir}${RECIPE_NAME}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$RECIPE_DIR" ]; then
|
||||
echo "ERROR: Recipe '$RECIPE_NAME' not found in recipes/*/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine target architecture
|
||||
source mk/config.mk 2>/dev/null || true
|
||||
TARGET_ARCH="${ARCH:-x86_64}"
|
||||
STAGE_DIR="${RECIPE_DIR}/target/${TARGET_ARCH}-unknown-redox/stage"
|
||||
|
||||
if [ ! -d "$STAGE_DIR" ]; then
|
||||
STAGE_DIR="${RECIPE_DIR}/target/${TARGET_ARCH}-unknown-redox/stage.tmp"
|
||||
fi
|
||||
|
||||
if [ ! -d "$STAGE_DIR" ]; then
|
||||
echo "ERROR: Stage directory not found: $STAGE_DIR" >&2
|
||||
echo " Build the recipe first: ./target/release/repo cook $RECIPE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "# Generated installs manifest for $RECIPE_NAME"
|
||||
echo "# Recipe: $RECIPE_DIR"
|
||||
echo "# Stage: $STAGE_DIR"
|
||||
echo ""
|
||||
echo "[package]"
|
||||
|
||||
# Collect all files, sorted
|
||||
FILES=$(cd "$STAGE_DIR" && find . -type f -o -type l | sed 's|^\./||' | sort)
|
||||
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "# No files found in stage directory"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "installs = ["
|
||||
while IFS= read -r file; do
|
||||
echo " \"/${file}\","
|
||||
done <<< "$FILES"
|
||||
echo "]"
|
||||
|
||||
echo ""
|
||||
echo "# Total: $(echo "$FILES" | wc -l) files"
|
||||
echo "# To apply: copy the installs = [...] block into $RECIPE_DIR/recipe.toml"
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# lint-config-paths.sh — Detect init service file path violations in config files
|
||||
#
|
||||
# Init service files in config [[files]] entries MUST use /etc/init.d/ paths,
|
||||
# NOT /usr/lib/init.d/. The base package installs to /usr/lib/init.d/ and
|
||||
# silently overwrites any config files placed there during install_dir().
|
||||
#
|
||||
# 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/.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/lint-config-paths.sh # Check all redbear configs
|
||||
# scripts/lint-config-paths.sh config/*.toml # Check specific files
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — No violations found
|
||||
# 1 — Violations found (printed to stderr)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Default to all redbear configs if no arguments
|
||||
if [ $# -eq 0 ]; then
|
||||
set -- config/redbear-*.toml
|
||||
fi
|
||||
|
||||
violations=0
|
||||
|
||||
for config_file in "$@"; do
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo "WARN: $config_file not found, skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
# Find [[files]] entries with /usr/lib/init.d/ paths
|
||||
# We look for path = "/usr/lib/init.d/..." lines
|
||||
line_num=0
|
||||
in_files_section=false
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Track TOML structure to only check [[files]] sections
|
||||
if [[ "$line" =~ ^\[\[files\]\] ]]; then
|
||||
in_files_section=true
|
||||
continue
|
||||
elif [[ "$line" =~ ^\[ ]]; then
|
||||
in_files_section=false
|
||||
continue
|
||||
fi
|
||||
|
||||
if $in_files_section; then
|
||||
# Check for /usr/lib/init.d/ paths
|
||||
if [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"/usr/lib/init\.d/ ]]; then
|
||||
echo "VIOLATION: $config_file:$line_num" >&2
|
||||
echo " Line: $line" >&2
|
||||
echo " Fix: Change /usr/lib/init.d/ to /etc/init.d/" >&2
|
||||
echo "" >&2
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
|
||||
# Also check for /usr/lib/environment.d/ (similar override pattern)
|
||||
if [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"/usr/lib/environment\.d/ ]]; then
|
||||
echo "VIOLATION: $config_file:$line_num" >&2
|
||||
echo " Line: $line" >&2
|
||||
echo " Fix: Change /usr/lib/environment.d/ to /etc/environment.d/" >&2
|
||||
echo "" >&2
|
||||
violations=$((violations + 1))
|
||||
fi
|
||||
fi
|
||||
done < "$config_file"
|
||||
done
|
||||
|
||||
if [ $violations -gt 0 ]; then
|
||||
echo "FAILED: $violations init service path violation(s) found." >&2
|
||||
echo "Config [[files]] entries must use /etc/init.d/ not /usr/lib/init.d/" >&2
|
||||
echo "See: local/docs/BUILD-SYSTEM-HARDENING-PLAN.md Phase 1" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "OK: No init service path violations in config files."
|
||||
exit 0
|
||||
fi
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
# validate-file-ownership.sh — Check recipe file ownership conflicts
|
||||
#
|
||||
# Reads the optional 'installs' field from recipe.toml [package] sections
|
||||
# and detects conflicts where multiple recipes claim the same path.
|
||||
#
|
||||
# Also cross-references config [[files]] paths against recipe installs
|
||||
# to detect config-layer / package-layer collisions.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/validate-file-ownership.sh
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — No conflicts or violations found
|
||||
# 1 — Conflicts detected
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
failures=0
|
||||
|
||||
# Registry: path -> recipe_name
|
||||
declare -A PATH_REGISTRY
|
||||
|
||||
echo "=== Scanning recipes for installs declarations ==="
|
||||
|
||||
recipe_count=0
|
||||
declared_count=0
|
||||
|
||||
for recipe_toml in recipes/*/recipe.toml recipes/*/*/recipe.toml; do
|
||||
[ -f "$recipe_toml" ] || continue
|
||||
recipe_count=$((recipe_count + 1))
|
||||
|
||||
recipe_dir=$(dirname "$recipe_toml")
|
||||
recipe_name=$(basename "$recipe_dir")
|
||||
|
||||
# Parse installs field from [package] section
|
||||
# Format: installs = ["/usr/bin/foo", "/usr/lib/init.d/10_bar.service"]
|
||||
in_package=false
|
||||
in_installs=false
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\[package\] ]]; then
|
||||
in_package=true
|
||||
in_installs=false
|
||||
continue
|
||||
elif [[ "$line" =~ ^\[ ]]; then
|
||||
in_package=false
|
||||
in_installs=false
|
||||
continue
|
||||
fi
|
||||
|
||||
if $in_package; then
|
||||
if [[ "$line" =~ ^installs ]]; then
|
||||
in_installs=true
|
||||
fi
|
||||
if $in_installs; then
|
||||
paths=$(echo "$line" | grep -oP '"[^"]+"' | tr -d '"' || true)
|
||||
for path in $paths; do
|
||||
declared_count=$((declared_count + 1))
|
||||
if [ -n "${PATH_REGISTRY[$path]+x}" ]; then
|
||||
existing="${PATH_REGISTRY[$path]}"
|
||||
echo "CONFLICT: '$path' claimed by both '$existing' and '$recipe_name'"
|
||||
if [[ "$path" == *"/init.d/"* ]]; then
|
||||
echo " SEVERITY: init service conflict (critical)"
|
||||
failures=$((failures + 1))
|
||||
else
|
||||
echo " SEVERITY: non-critical path overlap"
|
||||
fi
|
||||
else
|
||||
PATH_REGISTRY["$path"]="$recipe_name"
|
||||
fi
|
||||
done
|
||||
if [[ "$line" =~ \] ]]; then
|
||||
in_installs=false
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done < "$recipe_toml"
|
||||
done
|
||||
|
||||
echo " Scanned $recipe_count recipes, found $declared_count declared install paths"
|
||||
|
||||
echo ""
|
||||
echo "=== Cross-referencing config [[files]] against recipe installs ==="
|
||||
|
||||
config_conflicts=0
|
||||
for config_file in config/redbear-*.toml; do
|
||||
[ -f "$config_file" ] || continue
|
||||
config_name=$(basename "$config_file")
|
||||
|
||||
in_files=false
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\[\[files\]\] ]]; then
|
||||
in_files=true
|
||||
continue
|
||||
elif [[ "$line" =~ ^\[ ]]; then
|
||||
in_files=false
|
||||
continue
|
||||
fi
|
||||
|
||||
if $in_files && [[ "$line" =~ path[[:space:]]*=[[:space:]]*\"([^\"]+)\" ]]; then
|
||||
config_path="${BASH_REMATCH[1]}"
|
||||
for registered_path in "${!PATH_REGISTRY[@]}"; do
|
||||
if [ "$config_path" = "$registered_path" ]; then
|
||||
echo "COLLISION: config '$config_name' creates '$config_path' but recipe '${PATH_REGISTRY[$registered_path]}' also installs it"
|
||||
config_conflicts=$((config_conflicts + 1))
|
||||
if [[ "$config_path" == *"/usr/lib/init.d/"* ]]; then
|
||||
echo " SEVERITY: init service in /usr/lib/init.d/ (will be overwritten by package)"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done < "$config_file"
|
||||
done
|
||||
|
||||
echo " Found $config_conflicts config/recipe path collision(s)"
|
||||
|
||||
echo ""
|
||||
echo "=== Validation complete ==="
|
||||
if [ $declared_count -eq 0 ]; then
|
||||
echo " NOTE: No recipes declare 'installs' yet. Add installs = [...] to [package] sections for full validation."
|
||||
fi
|
||||
if [ $failures -gt 0 ]; then
|
||||
echo "FAILED: $failures conflict(s) found" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "PASSED: No conflicts found"
|
||||
fi
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/bin/bash
|
||||
# validate-init-services.sh — Post-image init service validation
|
||||
#
|
||||
# Validates that init service files in a built image match expectations:
|
||||
# 1. Config overrides in /etc/init.d/ differ from /usr/lib/init.d/ counterparts
|
||||
# 2. No missing init service files that configs expected to create
|
||||
# 3. Scheme-type services have corresponding binaries
|
||||
# 4. No dependency cycles in service graph
|
||||
#
|
||||
# Requires: redoxfs FUSE mount (or ext4 mount)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/validate-init-services.sh build/x86_64/redbear-full/harddrive.img
|
||||
# scripts/validate-init-services.sh build/x86_64/redbear-mini/harddrive.img
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — All validations passed
|
||||
# 1 — Validation failures found
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_PATH="${1:?Usage: $0 <image-path>}"
|
||||
|
||||
if [ ! -f "$IMAGE_PATH" ]; then
|
||||
echo "ERROR: Image not found: $IMAGE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MOUNT_DIR=$(mktemp -d /tmp/redbear-validate-XXXXXX)
|
||||
failures=0
|
||||
|
||||
cleanup() {
|
||||
fusermount -u "$MOUNT_DIR" 2>/dev/null || true
|
||||
rmdir "$MOUNT_DIR" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== Mounting image ==="
|
||||
redoxfs "$IMAGE_PATH" "$MOUNT_DIR"
|
||||
sleep 1
|
||||
|
||||
echo ""
|
||||
echo "=== 1. Checking /etc/init.d/ override effectiveness ==="
|
||||
|
||||
if [ -d "$MOUNT_DIR/usr/lib/init.d" ] && [ -d "$MOUNT_DIR/etc/init.d" ]; then
|
||||
etc_count=$(find "$MOUNT_DIR/etc/init.d" -type f 2>/dev/null | wc -l)
|
||||
usr_count=$(find "$MOUNT_DIR/usr/lib/init.d" -type f 2>/dev/null | wc -l)
|
||||
echo " /usr/lib/init.d/: $usr_count files"
|
||||
echo " /etc/init.d/: $etc_count files"
|
||||
|
||||
for etc_file in "$MOUNT_DIR"/etc/init.d/*; do
|
||||
[ -f "$etc_file" ] || continue
|
||||
basename=$(basename "$etc_file")
|
||||
usr_file="$MOUNT_DIR/usr/lib/init.d/$basename"
|
||||
|
||||
if [ -f "$usr_file" ]; then
|
||||
if diff -q "$etc_file" "$usr_file" > /dev/null 2>&1; then
|
||||
echo " WARN: /etc/init.d/$basename identical to /usr/lib/init.d/$basename (redundant override)"
|
||||
else
|
||||
echo " OK: /etc/init.d/$basename differs from /usr/lib/init.d/$basename (override active)"
|
||||
fi
|
||||
else
|
||||
echo " OK: /etc/init.d/$basename (new service, no package counterpart)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " One or both init.d directories missing — skipping override check"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 2. Checking scheme-type services have binaries ==="
|
||||
|
||||
if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then
|
||||
for svc_file in "$MOUNT_DIR"/usr/lib/init.d/*.service "$MOUNT_DIR"/etc/init.d/*.service; do
|
||||
[ -f "$svc_file" ] || continue
|
||||
|
||||
scheme_name=$(grep -oP 'type\s*=\s*\{\s*scheme\s*=\s*"([^"]+)"' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true)
|
||||
if [ -n "$scheme_name" ]; then
|
||||
# Scheme daemon binaries use <name>d convention (e.g., scheme "evdev" -> binary "evdevd")
|
||||
found_binary=false
|
||||
for candidate in "$scheme_name" "${scheme_name}d" "redbear-$scheme_name"; do
|
||||
if [ -x "$MOUNT_DIR/usr/bin/$candidate" ]; then
|
||||
echo " OK: scheme '$scheme_name' has binary /usr/bin/$candidate"
|
||||
found_binary=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$found_binary" = "false" ]; then
|
||||
echo " FAIL: scheme '$scheme_name' has NO binary at /usr/bin/$scheme_name, /usr/bin/${scheme_name}d, or /usr/bin/redbear-$scheme_name"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 3. Checking service dependency graph for cycles ==="
|
||||
|
||||
if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then
|
||||
# Collect all services
|
||||
services_dir=$(mktemp -d)
|
||||
for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do
|
||||
[ -f "$svc_file" ] || continue
|
||||
basename=$(basename "$svc_file")
|
||||
cp "$svc_file" "$services_dir/$basename" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Simple cycle detection: build adjacency list, do DFS
|
||||
python3 -c "
|
||||
import os, re, sys
|
||||
from collections import defaultdict
|
||||
|
||||
svc_dir = '$services_dir'
|
||||
graph = defaultdict(list)
|
||||
all_nodes = set()
|
||||
|
||||
for f in os.listdir(svc_dir):
|
||||
all_nodes.add(f)
|
||||
content = open(os.path.join(svc_dir, f)).read()
|
||||
for dep in re.findall(r'requires_weak\s*=\s*\[([^\]]*)\]', content) + re.findall(r'requires\s*=\s*\[([^\]]*)\]', content):
|
||||
for d in re.findall(r'\"([^\"]+)\"', dep):
|
||||
graph[f].append(d)
|
||||
|
||||
# DFS cycle detection
|
||||
WHITE, GRAY, BLACK = 0, 1, 2
|
||||
color = {n: WHITE for n in all_nodes}
|
||||
has_cycle = False
|
||||
|
||||
def dfs(node):
|
||||
global has_cycle
|
||||
color[node] = GRAY
|
||||
for neighbor in graph.get(node, []):
|
||||
if neighbor not in color:
|
||||
continue
|
||||
if color[neighbor] == GRAY:
|
||||
print(f' CYCLE: {node} -> {neighbor}')
|
||||
has_cycle = True
|
||||
elif color[neighbor] == WHITE:
|
||||
dfs(neighbor)
|
||||
color[node] = BLACK
|
||||
|
||||
for node in sorted(all_nodes):
|
||||
if color[node] == WHITE:
|
||||
dfs(node)
|
||||
|
||||
if not has_cycle:
|
||||
print(' OK: No dependency cycles detected')
|
||||
else:
|
||||
sys.exit(1)
|
||||
" 2>&1 || {
|
||||
echo " FAIL: Dependency cycle detected!"
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
rm -rf "$services_dir"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 4. Checking for missing dependencies ==="
|
||||
|
||||
if [ -d "$MOUNT_DIR/usr/lib/init.d" ] || [ -d "$MOUNT_DIR/etc/init.d" ]; then
|
||||
all_files=$(mktemp)
|
||||
for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do
|
||||
[ -f "$svc_file" ] || continue
|
||||
basename "$svc_file" >> "$all_files"
|
||||
done
|
||||
|
||||
for svc_file in "$MOUNT_DIR"/usr/lib/init.d/* "$MOUNT_DIR"/etc/init.d/*; do
|
||||
[ -f "$svc_file" ] || continue
|
||||
svc_name=$(basename "$svc_file")
|
||||
for dep in $(grep -oP 'requires_weak\s*=\s*\[([^\]]*)\]' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true); do
|
||||
if ! grep -q "^${dep}$" "$all_files" 2>/dev/null; then
|
||||
echo " WARN: $svc_name requires '$dep' but file not found in any init.d directory"
|
||||
fi
|
||||
done
|
||||
for dep in $(grep -oP 'requires\s*=\s*\[([^\]]*)\]' "$svc_file" 2>/dev/null | grep -oP '"[^"]+"' | tr -d '"' || true); do
|
||||
if ! grep -q "^${dep}$" "$all_files" 2>/dev/null; then
|
||||
echo " FAIL: $svc_name requires '$dep' (hard dep) but file not found"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
done
|
||||
done
|
||||
rm -f "$all_files"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Validation complete ==="
|
||||
if [ $failures -gt 0 ]; then
|
||||
echo "FAILED: $failures validation failure(s) found" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "PASSED: All validations passed"
|
||||
fi
|
||||
Reference in New Issue
Block a user