From 9101d4a1227aa5e1e06f9dfd943a84eaad81b77d Mon Sep 17 00:00:00 2001 From: humunkulud Date: Tue, 6 Oct 2026 17:30:50 +0200 Subject: [PATCH] Protect shared config sources when linking home aliases --- scripts/check-deployment.py | 75 ++++++++++++++++++++++++++++++++++++- scripts/link-home.sh | 36 +++++++++++++----- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/scripts/check-deployment.py b/scripts/check-deployment.py index 8a8ff7b..f42633a 100644 --- a/scripts/check-deployment.py +++ b/scripts/check-deployment.py @@ -56,6 +56,79 @@ def run(script, *args, expected=0): assert result.returncode == expected, result.stdout + result.stderr return result + def snapshot(directory): + result = {} + for path in directory.rglob("*"): + if path.is_symlink(): + value = ("link", str(path.readlink())) + elif path.is_file(): + value = ("file", path.read_bytes()) + else: + value = ("directory",) + result[str(path.relative_to(directory))] = value + return result + + alias = root / "checkout alias" + alias.symlink_to(repo, target_is_directory=True) + shared_before = snapshot(repo) + for name, destination, shared in ( + ("shared-config", ".config", "home/.config"), + ("shared-outside-home", ".config", "agent"), + ("shared-headless", ".config/mise", "headless"), + ("shared-units", ".config/systemd/user", "systemd/user"), + ("shared-ssh", ".ssh", "ssh"), + ("shared-backups", ".local/share/config-link-backups", "agent"), + ("shared-backup-parent", ".local/share", "agent"), + ): + isolated = root / name + isolated.mkdir() + (isolated / ".zshrc").write_text("original shell\n") + target = isolated / destination + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(repo / shared, target_is_directory=True) + before = snapshot(isolated) + safety_env = dict(env, CONFIG_LINK_HOME=str(isolated)) + command = ["bash", str(alias / "scripts/link-home.sh"), "homelab-dev"] + for mode in ("--dry-run", "--check"): + subprocess.run(command + [mode], env=safety_env, capture_output=True, text=True) + assert snapshot(isolated) == before, name + assert snapshot(repo) == shared_before, name + result = subprocess.run(command, env=safety_env, capture_output=True, text=True) + assert result.returncode == 1, name + result.stdout + result.stderr + assert "Destination parent points into shared repo" in result.stderr, name + assert snapshot(isolated) == before, name + assert snapshot(repo) == shared_before, name + + safety_env = dict(env, CONFIG_LINK_HOME=str(alias / "home")) + result = subprocess.run( + ["bash", str(alias / "scripts/link-home.sh"), "homelab-dev"], + env=safety_env, capture_output=True, text=True, + ) + assert result.returncode == 1, result.stdout + result.stderr + assert snapshot(repo) == shared_before + + # Local aliases and relative paths remain supported, including backups. + custom = root / "custom home" + custom.mkdir() + local_config = root / "local config" + local_config.mkdir() + local_backups = root / "local backups" + local_backups.mkdir() + (custom / ".config").symlink_to(local_config, target_is_directory=True) + (custom / ".local/share").mkdir(parents=True) + (custom / ".local/share/config-link-backups").symlink_to(local_backups, target_is_directory=True) + (custom / ".zshrc").write_text("local shell\n") + custom_env = dict(env, CONFIG_LINK_HOME="missing/../custom home") + command = ["bash", str(alias / "scripts/link-home.sh"), "homelab-dev"] + for args in ([], ["--check"], []): + result = subprocess.run(command + args, cwd=root, env=custom_env, capture_output=True, text=True) + assert result.returncode == 0, result.stdout + result.stderr + assert (custom / ".zshrc").resolve() == repo / "home/.zshrc" + backups = list(local_backups.iterdir()) + assert len(backups) == 1 + assert (backups[0] / ".zshrc").read_text() == "local shell\n" + assert snapshot(repo) == shared_before + for machine in ("laptop", "desktop"): home = root / f"{machine} home" home.mkdir() @@ -189,7 +262,7 @@ def run(script, *args, expected=0): run("link-home.sh", "laptop", expected=1) run("install.sh", "laptop", expected=1) assert list(untouched.iterdir()) == [] - print("PASS: workstation/headless/system profiles, preview/check, invalid input, backups, broken links, idempotency, missing sources, no service changes, package failures stop installation") + print("PASS: workstation/headless/system profiles, preview/check, shared-parent/backup safety, local aliases, invalid input, backups, broken links, idempotency, missing sources, no service changes, package failures stop installation") if __name__ == "__main__": diff --git a/scripts/link-home.sh b/scripts/link-home.sh index 47273df..daaef3b 100755 --- a/scripts/link-home.sh +++ b/scripts/link-home.sh @@ -3,9 +3,9 @@ # Usage: bash scripts/link-home.sh [laptop|desktop|homelab-dev] [--dry-run|--check] set -euo pipefail -repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -home_root="$repo_root/home" -target_home="${CONFIG_LINK_HOME:-$HOME}" +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +home_root="$(realpath -m -- "$repo_root/home")" +target_home="$(realpath -m -- "${CONFIG_LINK_HOME:-$HOME}")" mode=install machine="" for arg in "$@"; do @@ -98,6 +98,28 @@ done backup_root="" failures=0 +ensure_local_destination() { + local parent + parent="$(realpath -m -- "$(dirname -- "$1")")" + case "$parent/" in + "$repo_root/"*|"$home_root/"*) echo "Destination parent points into shared repo: $1" >&2; exit 1 ;; + esac +} + +# Reject shared parents and backup aliases before changing any existing links. +if [[ "$mode" == install ]]; then + for rel in "${links[@]}"; do ensure_local_destination "$target_home/$rel"; done + if [[ "$machine" == homelab-dev ]]; then + ensure_local_destination "$target_home/.zshenv" + ensure_local_destination "$target_home/.config/mise/config.toml" + else + ensure_local_destination "$target_home/.config/alacritty/machine.toml" + fi + ensure_local_destination "$target_home/.config/systemd/user/t3code.service.d/path.conf" + ensure_local_destination "$target_home/.ssh/config" + ensure_local_destination "$target_home/.local/share/config-link-backups/new-backup" +fi + link_file() { local rel="$1" src="${2:-$home_root/$1}" dst="$target_home/$1" if [[ -L "$dst" && "$(readlink -- "$dst")" == "$src" ]]; then @@ -108,15 +130,11 @@ link_file() { --check) echo "MISSING or different link: $rel"; failures=$((failures + 1)); return ;; --dry-run) echo "LINK $dst -> $src (back up existing destination)"; return ;; esac - # A parent link into home/ would write machine-local files into the shared repo. - local parent - parent="$(realpath -m -- "$(dirname -- "$dst")")" - case "$parent/" in - "$home_root/"*) echo "Destination parent points into shared home/: $dst" >&2; exit 1 ;; - esac + ensure_local_destination "$dst" mkdir -p -- "$(dirname -- "$dst")" if [[ -e "$dst" || -L "$dst" ]]; then if [[ -z "$backup_root" ]]; then + ensure_local_destination "$target_home/.local/share/config-link-backups/new-backup" mkdir -p -- "$target_home/.local/share/config-link-backups" backup_root="$(mktemp -d "$target_home/.local/share/config-link-backups/$(date +%Y%m%d-%H%M%S)-XXXXXX")" fi