"""Unit-Tests des Profil-Builders (synthetischer Home-Baum, deterministisch, ohne Kaefig-Start)."""
import os
import tempfile
import unittest

from aria_router.isolation import profiles as P


def touch(path):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("x")


class SyntheticHome(unittest.TestCase):
    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.H = os.path.join(self._tmp.name, "home")
        for d in (".ssh", ".claude/hooks", ".config/gh", ".codex", "brain", "projekte/x",
                  ".local/share/claude", "repo/src", "repo/node_modules"):
            os.makedirs(os.path.join(self.H, d), exist_ok=True)
        for f in (".env", ".env.bak.1", ".kadiv2-secrets", "brain/note.md", "projekte/x/app.py",
                  "projekte/x/token.js", "repo/.env", "repo/src/main.py", "repo/src/creds.pem",
                  "repo/node_modules/has-token.js", ".ssh/id", ".claude/settings.json",
                  ".config/gh/hosts.yml", ".codex/auth.json", ".local/share/claude/bin"):
            touch(os.path.join(self.H, f))

    def tearDown(self):
        self._tmp.cleanup()

    def dirs(self):
        rr = os.path.join(self._tmp.name, "run")
        return P.RunDirs(rr, rr + "/ws", rr + "/home", rr + "/codex-home", rr + "/authdir", rr + "/tmp")

    def test_scan_secret_names(self):
        sec = {os.path.relpath(s, self.H) for s in P.scan_secret_files(self.H)}
        self.assertIn(".env", sec)
        self.assertIn(".env.bak.1", sec)
        self.assertIn(".kadiv2-secrets", sec)
        self.assertIn("repo/.env", sec)
        self.assertIn("repo/src/creds.pem", sec)        # .pem -> credential
        # token.js ist QUELLCODE, kein Credential -> NICHT gesperrt (bewusste Verfeinerung)
        self.assertNotIn("projekte/x/token.js", sec)
        # node_modules wird nicht nach Secrets durchsucht
        self.assertNotIn("repo/node_modules/has-token.js", sec)

    def _covered(self, cfg, rel):
        p = os.path.join(self.H, rel)
        if p in set(cfg["file_rw"]):
            return "file_rw"
        if p in set(cfg["file_ro"]):
            return "file_ro"
        for d in cfg["rw"]:
            if p == d or p.startswith(d + os.sep):
                return "rw"
        for d in cfg["ro"]:
            if p == d or p.startswith(d + os.sep):
                return "ro"
        return "NONE"

    def test_tools_blocks_credentials_and_claude(self):
        # protect_startup=False: Kern-Credential-Sperre ohne den Startschutz.
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H, exec_roots=(), protect_startup=False)
        # normale Dateien: lesbar; seit E44 Option 2 ohne Allowlist aber NICHT schreibbar (vorher rw)
        self.assertIn(self._covered(cfg, "brain/note.md"), ("ro", "file_ro"))
        self.assertIn(self._covered(cfg, "projekte/x/app.py"), ("ro", "file_ro"))
        self.assertIn(self._covered(cfg, "projekte/x/token.js"), ("ro", "file_ro"))  # Quellcode lesbar
        # Credentials/hard-blocked: keine Regel
        for rel in (".env", "repo/.env", "repo/src/creds.pem", ".kadiv2-secrets",
                    ".ssh/id", ".claude/settings.json", ".config/gh/hosts.yml", ".codex/auth.json"):
            self.assertEqual(self._covered(cfg, rel), "NONE", rel)
        # claude-Binaries: weder rw noch exec
        self.assertEqual(self._covered(cfg, ".local/share/claude/bin"), "NONE")

    def test_extended_credential_names_blocked(self):
        # Review #4: erweiterte Credential-Erkennung (keine Regel = NONE).
        for rel in (".netrc", ".npmrc", ".pgpass", ".pypirc", ".my.cnf", ".bash_history",
                    ".git-credentials", "id_ed25519", ".docker/config.json",
                    ".cargo/credentials.toml", ".aws/credentials", ".gnupg/secring.gpg"):
            P_ = os.path.join(self.H, rel)
            os.makedirs(os.path.dirname(P_), exist_ok=True)
            if not os.path.exists(P_):
                with open(P_, "w") as fh:
                    fh.write("CANARY\n")
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H, exec_roots=(), protect_startup=False)
        for rel in (".netrc", ".npmrc", ".pgpass", ".pypirc", ".my.cnf", ".bash_history",
                    ".git-credentials", "id_ed25519", ".docker/config.json",
                    ".cargo/credentials.toml", ".aws/credentials", ".gnupg/secring.gpg"):
            self.assertEqual(self._covered(cfg, rel), "NONE", rel)
        # id_*.pub ist oeffentlich -> KEINE Secret-Erkennung
        self.assertFalse(P._is_secret_name("id_ed25519.pub"))

    def test_gitconfig_read_only_even_without_startup(self):
        with open(os.path.join(self.H, ".gitconfig"), "w") as fh:
            fh.write("[credential]\n\thelper = store\n")
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H, exec_roots=(), protect_startup=False)
        self.assertEqual(self._covered(cfg, ".gitconfig"), "file_ro")

    def test_symlinked_hard_block_covers_target(self):
        # Review #5: .ssh ist ein Symlink -> keystore mit id_rsa; das Ziel MUSS gesperrt sein.
        os.makedirs(os.path.join(self.H, "keystore"), exist_ok=True)
        with open(os.path.join(self.H, "keystore", "private"), "w") as fh:
            fh.write("CANARY\n")
        # vorhandenes .ssh-Verzeichnis durch einen Symlink ersetzen
        import shutil as _sh
        _sh.rmtree(os.path.join(self.H, ".ssh"))
        os.symlink(os.path.join(self.H, "keystore"), os.path.join(self.H, ".ssh"))
        hard = P.hard_credential_blocklist(self.H)
        self.assertIn(os.path.join(self.H, "keystore"), hard)   # realpath-Ziel gesperrt
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H, exec_roots=(), protect_startup=False)
        self.assertEqual(self._covered(cfg, "keystore/private"), "NONE")
        self.assertEqual(self._covered(cfg, "keystore"), "NONE")

    def test_exec_root_does_not_regrant_secret(self):
        # Review #2: ein Secret unter einem EXEC-ROOT darf nicht per rx wieder lesbar werden.
        os.makedirs(os.path.join(self.H, ".local/share/app"), exist_ok=True)
        with open(os.path.join(self.H, ".local/share/app/.env"), "w") as fh:
            fh.write("CANARY\n")
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H, exec_roots=(os.path.join(self.H, ".local"),),
                              protect_startup=False)
        secret = os.path.join(self.H, ".local/share/app/.env")
        for d in cfg["rx"] + cfg["rw"] + cfg["ro"]:
            self.assertFalse(secret == d or secret.startswith(d + os.sep),
                             f"Secret unter exec-root durch {d} wieder erreichbar")
        self.assertNotIn(secret, set(cfg["file_rw"]) | set(cfg["file_ro"]) | set(cfg["file_x"]))

    def test_state_dir_and_repo_blocked_workspace_rw(self):
        # Review #6: state_dir (Budget/Nonces/Schluessel) und das Repo komplett gesperrt,
        # aber die ephemeren Lauf-Verzeichnisse darunter bleiben rw.
        state = os.path.join(self.H, "state")
        os.makedirs(os.path.join(state, "keys"), exist_ok=True)
        for rel in ("budget.json", "nonces.json", "keys/clearance.key"):
            with open(os.path.join(state, rel), "w") as fh:
                fh.write("x\n")
        repo = os.path.join(self.H, "v3repo")
        os.makedirs(repo, exist_ok=True)
        with open(os.path.join(repo, "gateway.py"), "w") as fh:
            fh.write("code\n")
        rr = os.path.join(state, "codex-work", "run")
        os.makedirs(rr, exist_ok=True)
        dirs = P.RunDirs(rr, rr + "/ws", rr + "/home", rr + "/codex-home", rr + "/authdir", rr + "/tmp")
        for d in (dirs.workspace, dirs.home, dirs.codex_home, dirs.authdir, dirs.tmp):
            os.makedirs(d, exist_ok=True)
        with open(dirs.auth_copy, "w") as fh:
            fh.write("{}")
        cfg = P.tools_profile(dirs, codex_bin="/usr/bin/true", rlimits={"AS": 1}, home_root=self.H,
                              exec_roots=(), protect_startup=False, state_dir=state, repo_root=repo)

        def covered(p):
            if p in set(cfg["file_rw"]) | set(cfg["file_ro"]):
                return True
            return any(p == d or p.startswith(d + os.sep) for d in cfg["rw"] + cfg["ro"])
        for rel in ("budget.json", "nonces.json", "keys/clearance.key"):
            self.assertFalse(covered(os.path.join(state, rel)), rel)
        self.assertFalse(covered(os.path.join(repo, "gateway.py")))
        self.assertTrue(any(dirs.workspace == d for d in cfg["rw"]))

    def test_protect_startup_true_makes_startup_read_only(self):
        for f in (".bashrc", ".profile", ".zshrc"):
            with open(os.path.join(self.H, f), "w") as fh:
                fh.write("echo hi\n")
        os.makedirs(os.path.join(self.H, "scripts"), exist_ok=True)
        with open(os.path.join(self.H, "scripts", "wrapper.sh"), "w") as fh:
            fh.write("#!/bin/sh\n")
        os.makedirs(os.path.join(self.H, "myrepo", ".git", "hooks"), exist_ok=True)
        with open(os.path.join(self.H, "myrepo", ".git", "hooks", "pre-commit"), "w") as fh:
            fh.write("#!/bin/sh\n")
        with open(os.path.join(self.H, "myrepo", ".git", "config"), "w") as fh:
            fh.write("[core]\n")
        cfg_on = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                                 home_root=self.H, exec_roots=(), protect_startup=True)
        for rel in (".bashrc", ".profile", ".zshrc", "scripts/wrapper.sh", "brain/note.md",
                    "myrepo/.git/hooks/pre-commit", "myrepo/.git/config"):
            self.assertIn(self._covered(cfg_on, rel), ("ro", "file_ro"), f"{rel} nicht read-only")
        # kein Schreibrecht: nirgends in rw/file_rw
        for rel in (".bashrc", "scripts/wrapper.sh", "brain/note.md", "myrepo/.git/config"):
            self.assertNotEqual(self._covered(cfg_on, rel), "rw", rel)
            self.assertNotEqual(self._covered(cfg_on, rel), "file_rw", rel)

    def test_protect_startup_false_does_not_reopen_home_writes(self):
        # Bis E44 machte protect_startup=False Startdateien/scripts/brain wieder schreibbar. Seit Option 2
        # ist das Home ohne Allowlist in KEINEM Modus schreibbar; der Schalter wirkt nur noch innerhalb von
        # Allowlist-Ordnern (test_allowlist_dir_keeps_startup_carveouts) und bei der Geschwister-Sperre.
        for f in (".bashrc", ".zshrc"):
            with open(os.path.join(self.H, f), "w") as fh:
                fh.write("echo hi\n")
        os.makedirs(os.path.join(self.H, "scripts"), exist_ok=True)
        with open(os.path.join(self.H, "scripts", "wrapper.sh"), "w") as fh:
            fh.write("#!/bin/sh\n")
        cfg_off = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                                  home_root=self.H, exec_roots=(), protect_startup=False)
        for rel in (".bashrc", ".zshrc", "scripts/wrapper.sh", "brain/note.md"):
            self.assertIn(self._covered(cfg_off, rel), ("ro", "file_ro"), rel)

    def test_allowlist_dir_keeps_startup_carveouts(self):
        # E44: in einem Allowlist-Repo bleiben .git/hooks + .git/config read-only (protect_startup an) und
        # ein Secret hart gesperrt; mit protect_startup=False werden nur die Hooks darin schreibbar.
        repo = os.path.join(self.H, "projekte", "x")
        touch(os.path.join(repo, ".git", "hooks", "pre-commit"))
        touch(os.path.join(repo, ".git", "config"))
        touch(os.path.join(repo, ".env"))
        on = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                             exec_roots=(), protect_startup=True, cron_targets=[], write_allowlist=[repo])
        self.assertIn(self._covered(on, "projekte/x/app.py"), ("rw", "file_rw"))
        for rel in ("projekte/x/.git/hooks/pre-commit", "projekte/x/.git/config"):
            self.assertIn(self._covered(on, rel), ("ro", "file_ro"), rel)
        self.assertEqual(self._covered(on, "projekte/x/.env"), "NONE")
        off = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                              exec_roots=(), protect_startup=False, write_allowlist=[repo])
        self.assertIn(self._covered(off, "projekte/x/.git/hooks/pre-commit"), ("rw", "file_rw"))
        self.assertEqual(self._covered(off, "projekte/x/.env"), "NONE")
        self.assertIn(self._covered(off, "brain/note.md"), ("ro", "file_ro"))

    def test_tools_claude_not_in_exec_grants(self):
        # mit echten exec_roots: claude darf NICHT in rx/file_x auftauchen
        cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={"AS": 1},
                              home_root=self.H)
        for p in cfg["rx"] + cfg["file_x"]:
            self.assertNotIn("/share/claude", p)
            self.assertFalse(p.endswith("/claude"), p)

    def test_auth_default_readonly_vs_writable(self):
        d = self.dirs()
        ro = P.tools_profile(d, codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                             writable_auth=False, exec_roots=())
        self.assertIn(d.auth_copy, ro["file_ro"])
        self.assertNotIn(d.auth_copy, ro["file_rw"])
        self.assertNotIn(d.authdir, ro["rw"])             # authdir nie rw -> Kopie read-only
        rw = P.tools_profile(d, codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                             writable_auth=True, exec_roots=())
        self.assertIn(d.auth_copy, rw["file_rw"])

    def test_text_profile_minimal(self):
        d = self.dirs()
        cfg = P.text_profile(d, codex_bin="/usr/bin/true", rlimits={"AS": 1})
        self.assertEqual(cfg["tcp_connect"], [443])
        self.assertIn("/usr/bin/true", cfg["file_x"])
        self.assertIn(d.auth_copy, cfg["file_ro"])
        self.assertNotIn("rx", cfg)                       # Codex ist statisch -> kein /usr
        # kein Zugriff auf /home/aria im text-Profil
        self.assertNotIn("/home/aria", cfg["rw"])

    def test_descent_excludes_blocked(self):
        blocked = frozenset({os.path.join(self.H, "repo/.env"), os.path.join(self.H, ".ssh")})
        rw_dirs, rw_files = P.build_home_rw(self.H, blocked)
        allowed = set(rw_dirs)
        self.assertNotIn(os.path.join(self.H, ".ssh"), allowed)
        self.assertTrue(any(d.endswith("/brain") for d in rw_dirs))
        # repo/.env ist eine Datei -> repo wird abgestiegen, .env faellt weg
        self.assertNotIn(os.path.join(self.H, "repo/.env"), set(rw_files))


class ReReview2DeferredExecution(unittest.TestCase):
    """Re-Review 2 (verzoegerte Ausfuehrung): Top-Level-Dotfiles, .claude.json, Paketmanager/state ro,
    gesourcte Dateien, Cron-Ziele, Geschwister-Worktrees. Synthetischer Home-Baum, kein Kaefig-Start."""

    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.H = os.path.join(self._tmp.name, "home")
        for d in (".ssh", ".claude", ".local/bin", ".local/share/claude", ".local/lib/pkg",
                  ".nvm", ".bun/bin", ".cargo/bin", ".cargo/registry/crate", "state", "brain",
                  "work/project", "main-repo-dev/.git/worktrees/wt-a", "main-repo-lane-a",
                  "main-repo-shadow/sub", "unrelated"):
            os.makedirs(os.path.join(self.H, d), exist_ok=True)
        files = {
            ".bashrc": '. "$HOME/.mysource"\nsource ~/.other\n[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"\n',
            ".profile": '. "$HOME/.cargo/env"\n',
            ".mysource": "x", ".other": "y",
            ".cargo/env": "export CARGO=1\n", ".cargo/bin/cargo": "bin",
            ".cargo/registry/crate/lib.rs": "code", ".cargo/credentials.toml": "SECRET",
            ".local/bin/tool": "bin", ".local/share/claude/bin": "claudebin", ".local/lib/pkg/m.js": "js",
            ".nvm/nvm.sh": "nvm", ".bun/bin/bun": "bun",
            "state/run-heartbeat.conf": "CONF=1", "state/other.txt": "x",
            ".claude.json": '{"mcpServers":{}}', ".env": "SECRET", ".gitignore": "x", ".lesshst": "x",
            "CLAUDE.md": "instructions", "AGENTS.md": "agents",
            "brain/note.md": "n", "work/project/app.py": "code",
            "main-repo-dev/code.py": "main", "main-repo-shadow/sub/copy.py": "shadow",
            "unrelated/thing.txt": "ok",
        }
        for rel, body in files.items():
            touch(os.path.join(self.H, rel))
            with open(os.path.join(self.H, rel), "w", encoding="utf-8") as fh:
                fh.write(body)
        # Geschwister-Worktree: .git ist eine DATEI, die in die worktrees des Hauptrepos zeigt.
        with open(os.path.join(self.H, "main-repo-lane-a", ".git"), "w", encoding="utf-8") as fh:
            fh.write("gitdir: %s\n" % os.path.join(self.H, "main-repo-dev", ".git", "worktrees", "wt-a"))

    def tearDown(self):
        self._tmp.cleanup()

    def dirs(self):
        rr = os.path.join(self._tmp.name, "run")
        return P.RunDirs(rr, rr + "/ws", rr + "/home", rr + "/codex-home", rr + "/authdir", rr + "/tmp")

    def cfg(self, *, protect_startup=True, cron_targets=None, repo_root=None, exec_roots=(), write_allowlist=()):
        return P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                               exec_roots=exec_roots, protect_startup=protect_startup,
                               cron_targets=cron_targets if cron_targets is not None else [],
                               repo_root=repo_root, write_allowlist=write_allowlist)

    # -- Klassifikationshelfer (Schreibbarkeit ist die sicherheitsrelevante Eigenschaft) --------
    def writable(self, cfg, rel):
        p = os.path.join(self.H, rel)
        if p in set(cfg["file_rw"]):
            return True
        return any(p == d or p.startswith(d + os.sep) for d in cfg["rw"])

    def readonly(self, cfg, rel):
        p = os.path.join(self.H, rel)
        if p in set(cfg["file_ro"]):
            return True
        return any(p == d or p.startswith(d + os.sep) for d in cfg["ro"])

    def no_access(self, cfg, rel):
        p = os.path.join(self.H, rel)
        covered = (p in set(cfg["file_rw"]) | set(cfg["file_ro"]) | set(cfg["file_x"])
                   or any(p == d or p.startswith(d + os.sep) for d in cfg["rw"] + cfg["ro"] + cfg["rx"]))
        return not covered

    # -- 1a: Top-Level-Dotfiles + CLAUDE.md/AGENTS.md read-only, Secrets bleiben hart gesperrt ----
    def test_top_level_dotfiles_read_only(self):
        cfg = self.cfg()
        for rel in (".bashrc", ".profile", ".gitignore", ".lesshst", "CLAUDE.md", "AGENTS.md"):
            self.assertTrue(self.readonly(cfg, rel), f"{rel} nicht read-only")
            self.assertFalse(self.writable(cfg, rel), f"{rel} ist schreibbar")

    def test_secret_top_level_dotfile_stays_hard_blocked(self):
        cfg = self.cfg()
        self.assertTrue(self.no_access(cfg, ".env"), ".env darf weder ro noch rw sein (bleibt Secret)")

    def test_home_root_not_writable_latent_dotfile_uncreatable(self):
        # home_root selbst bekommt KEINE rw-Regel -> kein MAKE_REG -> latentes ~/.bash_aliases
        # (nicht existent) kann nicht neu angelegt werden.
        cfg = self.cfg()
        self.assertNotIn(os.path.abspath(self.H), set(cfg["rw"]))
        self.assertFalse(os.path.exists(os.path.join(self.H, ".bash_aliases")))

    # -- 1b: .claude.json hart gesperrt (weder lesen noch schreiben) ------------------------------
    def test_claude_json_hard_blocked(self):
        cfg = self.cfg()
        self.assertTrue(self.no_access(cfg, ".claude.json"))
        self.assertIn(os.path.join(self.H, ".claude.json"), P.hard_credential_blocklist(self.H))

    # -- 1c: Verzeichnisse ro; cargo-registry seit E44 ebenfalls nur lesbar ----------------------
    def test_package_dirs_and_cargo_registry_read_only(self):
        cfg = self.cfg(exec_roots=(os.path.join(self.H, ".local"), os.path.join(self.H, ".nvm"),
                                   os.path.join(self.H, ".bun")))
        for rel in (".local/lib/pkg/m.js", ".local/bin/tool", ".nvm/nvm.sh", ".bun/bin/bun",
                    ".cargo/bin/cargo", ".cargo/env", "state/run-heartbeat.conf", "state/other.txt"):
            self.assertTrue(self.readonly(cfg, rel), f"{rel} nicht read-only")
            self.assertFalse(self.writable(cfg, rel), f"{rel} schreibbar")
        # cargo registry/git-Cache: bis E44 rw (Build-Cache), seit Option 2 nur lesbar - ein vergifteter
        # Crate-Quelltext liefe beim naechsten ungekaefigten `cargo build` (build.rs) mit.
        self.assertTrue(self.readonly(cfg, ".cargo/registry/crate/lib.rs"), "cargo registry nicht lesbar")
        self.assertFalse(self.writable(cfg, ".cargo/registry/crate/lib.rs"), "cargo registry schreibbar")
        # claude-Teil unter .local bleibt hart gesperrt (nicht ro lesbar)
        self.assertTrue(self.no_access(cfg, ".local/share/claude/bin"))
        # .local/bin/tool bleibt ausfuehrbar (exec_root union)
        self.assertTrue(any(os.path.join(self.H, ".local") == d or
                            os.path.join(self.H, ".local").startswith(d + os.sep) or
                            d.startswith(os.path.join(self.H, ".local") + os.sep) for d in cfg["rx"]),
                        ".local nicht mehr ausfuehrbar")

    def test_normal_home_readable_not_writable(self):
        # E44 Option 2: ohne Allowlist ist KEIN Home-Pfad schreibbar (vorher: work/project/app.py rw),
        # Lesen bleibt. Mit Allowlist wird genau der benannte Ordner schreibbar, seine Nachbarn nicht.
        cfg = self.cfg()
        for rel in ("work/project/app.py", "unrelated/thing.txt", "brain/note.md"):
            self.assertTrue(self.readonly(cfg, rel), rel)
            self.assertFalse(self.writable(cfg, rel), rel)
        cfg2 = self.cfg(write_allowlist=[os.path.join(self.H, "work", "project")])
        self.assertTrue(self.writable(cfg2, "work/project/app.py"))
        self.assertFalse(self.writable(cfg2, "unrelated/thing.txt"))
        self.assertFalse(self.writable(cfg2, "brain/note.md"))

    # -- 1d: aus Startdateien gesourcte Dateien read-only ----------------------------------------
    def test_sourced_files_read_only(self):
        srcs = P.parse_sourced_paths(self.H)
        self.assertIn(os.path.join(self.H, ".mysource"), srcs)   # $HOME/.mysource
        self.assertIn(os.path.join(self.H, ".other"), srcs)      # ~/.other
        self.assertIn(os.path.join(self.H, ".cargo", "env"), srcs)  # aus .profile
        # $NVM_DIR unaufloesbar -> nicht geraten (nvm.sh wird ueber .nvm-ro ohnehin abgedeckt)
        self.assertNotIn(os.path.join(self.H, ".nvm", "nvm.sh"), srcs)
        cfg = self.cfg()
        self.assertTrue(self.readonly(cfg, ".mysource"))
        self.assertTrue(self.readonly(cfg, ".other"))

    def test_sourced_unresolved_variable_skipped(self):
        # Ein gesourcter Pfad mit unaufloesbarer Variable darf NICHT geraten/gesetzt werden.
        self.assertIsNone(P._resolve_home_token("$NVM_DIR/nvm.sh", self.H))
        self.assertIsNone(P._resolve_home_token("$PWD/x", self.H))
        self.assertEqual(P._resolve_home_token("~/a/b", self.H), os.path.join(self.H, "a/b"))
        self.assertEqual(P._resolve_home_token("$HOME/c", self.H), os.path.join(self.H, "c"))

    # -- 1e: Cron-Ziele read-only (Datei + cd-Verzeichnis), Fehler -> Warnung --------------------
    def test_cron_targets_read_only(self):
        line_file = "12 7 * * * /usr/bin/env bash %s/work/project/app.py >/dev/null" % self.H
        line_cd = "*/5 * * * * cd %s/unrelated && python3 -m http.server" % self.H
        cfg = self.cfg(cron_targets=[line_file, line_cd])
        self.assertTrue(self.readonly(cfg, "work/project/app.py"), "cron-Datei nicht ro")
        self.assertFalse(self.writable(cfg, "work/project/app.py"))
        self.assertTrue(self.readonly(cfg, "unrelated/thing.txt"), "cd-Verzeichnis nicht ro")
        self.assertFalse(self.writable(cfg, "unrelated"))

    def test_cron_read_failure_yields_warning_not_crash(self):
        import subprocess as _sp
        real = P.subprocess.run

        def boom(*a, **k):
            raise _sp.TimeoutExpired(cmd="crontab", timeout=5)
        P.subprocess.run = boom
        try:
            lines, warn = P.read_user_crontab()
            self.assertEqual(lines, [])
            self.assertIsNotNone(warn)
            d, f, warns = P.resolve_cron_targets(self.H, None)
            self.assertEqual((d, f), (set(), set()))
            self.assertTrue(warns, "Warnung fehlt")
            # fail-open (leere Menge), aber im Profil sichtbar
            cfg = P.tools_profile(self.dirs(), codex_bin="/usr/bin/true", rlimits={}, home_root=self.H,
                                  exec_roots=(), protect_startup=True, cron_targets=None, repo_root=None)
            self.assertTrue(cfg["warnings"], "Profil-Warnung fehlt")
        finally:
            P.subprocess.run = real

    def test_cron_no_crontab_is_silent(self):
        # rc != 0 (kein Crontab) ist KEINE Warnung.
        import subprocess as _sp
        real = P.subprocess.run

        def empty(*a, **k):
            return _sp.CompletedProcess(a[0], 1, stdout=b"", stderr=b"no crontab for x")
        P.subprocess.run = empty
        try:
            lines, warn = P.read_user_crontab()
            self.assertEqual((lines, warn), ([], None))
        finally:
            P.subprocess.run = real

    # -- 1f: STARTUP_RO_ABS bereinigt (kein totes /home/aria/aria) -------------------------------
    def test_startup_ro_abs_has_no_dead_entry(self):
        self.assertEqual(P.STARTUP_RO_ABS, ())
        self.assertNotIn("/home/aria/aria", P.STARTUP_RO_ABS)

    # -- 1g: Geschwister-Worktrees + Namens-Stamm-Familie gesperrt -------------------------------
    def test_sibling_worktrees_and_family_blocked(self):
        repo = os.path.join(self.H, "main-repo-dev")
        sib = P.sibling_worktrees(self.H, repo)
        self.assertIn(os.path.join(self.H, "main-repo-lane-a"), sib)   # echter Worktree (.git-Datei)
        self.assertIn(os.path.join(self.H, "main-repo-shadow"), sib)   # Familien-Stamm (keine .git)
        self.assertNotIn(os.path.join(self.H, "unrelated"), sib)       # Fremdverzeichnis bleibt
        cfg = self.cfg(repo_root=repo)
        self.assertTrue(self.no_access(cfg, "main-repo-lane-a/.git"))
        self.assertTrue(self.no_access(cfg, "main-repo-shadow/sub/copy.py"))
        # Fremdverzeichnis bleibt erreichbar (seit E44 lesend; Schreiben nur per Allowlist)
        self.assertTrue(self.readonly(cfg, "unrelated/thing.txt"), "Fremdverzeichnis faelschlich gesperrt")

    def test_sibling_worktrees_repo_without_git_does_not_crash(self):
        # Befund bei E44: ist repo_root KEIN Git-Checkout (z. B. ausgerollte Kopie ohne .git) und hat ein
        # Home-Kind eine .git-DATEI (Worktree), warf sibling_worktrees TypeError (None + str) -> jeder
        # tools-Lauf PROFILE_BUILD_FAILED. Jetzt: keine Worktree-Treffer, Familien-Stamm entfaellt.
        repo = os.path.join(self.H, "unrelated")                  # existiert, ohne .git
        self.assertTrue(os.path.isfile(os.path.join(self.H, "main-repo-lane-a", ".git")))
        self.assertEqual(P.sibling_worktrees(self.H, repo), set())
        self.cfg(repo_root=repo)                                   # Profilbau laeuft durch

    def test_sibling_family_kept_for_deployed_copy_without_git(self):
        # Review E44 (SOLLTE): ausgerollte Kopie ohne .git (z. B. /home/aria/aria-v3) darf die Familien-
        # Sperre nicht still verlieren; Stamm ist dann der eigene Verzeichnisname.
        repo = os.path.join(self.H, "main-repo")
        os.makedirs(repo, exist_ok=True)
        self.assertFalse(os.path.exists(os.path.join(repo, ".git")))
        sib = P.sibling_worktrees(self.H, repo)
        for name in ("main-repo-dev", "main-repo-shadow", "main-repo-lane-a"):
            self.assertIn(os.path.join(self.H, name), sib)
        self.assertNotIn(os.path.abspath(repo), sib)

    def test_siblings_not_blocked_when_startup_off(self):
        repo = os.path.join(self.H, "main-repo-dev")
        cfg = self.cfg(protect_startup=False, repo_root=repo)
        # protect_startup=False -> Geschwister nicht hart gesperrt (lesbar), seit E44 aber nicht schreibbar
        self.assertTrue(self.readonly(cfg, "main-repo-shadow/sub/copy.py"))
        self.assertFalse(self.writable(cfg, "main-repo-shadow/sub/copy.py"))
        # ... und auch per Allowlist nicht oeffenbar (Familien-Sperre der Validierung gilt unabhaengig davon)
        with self.assertRaises(P.WriteAllowlistError):
            self.cfg(protect_startup=False, repo_root=repo,
                     write_allowlist=[os.path.join(self.H, "main-repo-shadow")])


if __name__ == "__main__":
    unittest.main()
