Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

scripts/dotfiles_integration_test.java

Raw
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.*;
import java.util.*;

public class dotfiles_integration_test {
	static final String CLEAN_ENV = "DOTFILES_TEST_CLEAN_ENV";
	static final Path SETSID = findExecutable("setsid");
	static final Path KILL = findExecutable("kill");
	static final Set<Long> ACTIVE_PROCESS_GROUPS = java.util.concurrent.ConcurrentHashMap.newKeySet();
	static final Set<Path> ACTIVE_FIXTURES = java.util.concurrent.ConcurrentHashMap.newKeySet();
	static final String FIXTURE_MARKER = ".dotfiles-test-fixture";
	static final Set<String> CHILD_SUITES = Set.of("--process-cancellation-child", "--process-inherit-cancellation-child");
	static Path root;
	static {
		Runtime.getRuntime().addShutdownHook(new Thread(() -> {
			ACTIVE_PROCESS_GROUPS.forEach(dotfiles_integration_test::killGroup);
			ACTIVE_FIXTURES.forEach(dotfiles_integration_test::deleteTreeQuietly);
		}, "dotfiles-test-cleanup"));
	}

	public static void main(String[] args) throws Exception {
		root = Path.of(run(null, "git", "rev-parse", "--show-toplevel").trim());
		// Children run inside a live fixture of their parent, so only top-level suites sweep.
		if (args.length == 0 || !CHILD_SUITES.contains(args[0])) sweepStaleFixtures();
		if (args.length == 0) host();
		else switch (args[0]) {
			case "--inside" -> inside();
			case "--crypto" -> freshCryptoChecks(CryptoScope.ALL);
			case "--crypto-git" -> freshCryptoChecks(CryptoScope.GIT);
			case "--crypto-restore" -> freshCryptoChecks(CryptoScope.RESTORE);
			case "--fresh-checkout-crypto" -> freshCheckoutCryptoChecks();
			case "--bootstrap" -> freshCryptoChecks(CryptoScope.BOOTSTRAP);
			case "--hooks" -> freshHookChecks();
			case "--manifests" -> manifestChecks();
			case "--manifest-contracts" -> manifestContractChecks();
			case "--mise-shell" -> miseShellSmoke();
			case "--mise-route" -> miseRouteChecks();
			case "--process-cleanup" -> processCleanupChecks();
			case "--process-cancellation-child" -> processCancellationChild(Path.of(args[1]), false);
			case "--process-inherit-cancellation-child" -> processCancellationChild(Path.of(args[1]), true);
			default -> throw new IllegalArgumentException("unknown suite: " + args[0]);
		}
	}

	static void host() throws Exception {
		root = Path.of(run(null, "git", "rev-parse", "--show-toplevel").trim());
		var common = Path.of(run(root, "git", "rev-parse", "--git-common-dir").trim());
		var key = (common.isAbsolute() ? common : root.resolve(common)).resolve("dotfiles-aes.key");
		if (!Files.isReadable(key)) fail("missing " + key);
		var container = "dotfiles-integration-" + LocalDateTime.now() + "-" + ProcessHandle.current().pid();
		container = container.replaceAll("[^A-Za-z0-9_.-]", "-");
		try {
			step("start container");
			run(null, "podman", "run", "--name", container, "--rm", "-d", "--hostname", "desktop",
				"-v", root + ":/src:ro", "-v", key + ":/dotfiles-aes.key:ro",
				"registry.fedoraproject.org/fedora:latest", "sleep", "infinity");
			ok("container-started");
			step("install bootstrap packages");
			run(null, "podman", "exec", container, "dnf", "-qy", "--setopt=install_weak_deps=False", "install", "git-core", "libatomic");
			ok("bootstrap-packages-installed");
			step("install mise");
			installMise(container);
			ok("mise-installed");
			run(null, "podman", "exec", container, "git", "config", "--global", "--add", "safe.directory", "/src");
			run(null, "podman", "exec", container, "mkdir", "-p", "/root/work");
			step("clone repo");
			run(null, "podman", "exec", container, "git", "clone", "--quiet", "--no-local", "/src", "/root/work/dotfiles");
			run(null, "podman", "exec", container, "install", "-m", "600", "/dotfiles-aes.key", "/root/work/dotfiles/.git/dotfiles-aes.key");
			step("trust mise config");
			run(null, "podman", "exec", "-w", "/root/work/dotfiles", container, "mise", "trust", "--yes");
			ok("mise-trusted");
			step("install mise tools");
			runInherit(null, "podman", "exec", "-w", "/root/work/dotfiles", container, "mise", "install");
			runInherit(null, "podman", "exec", "-w", "/root/work/dotfiles", container, "mise", "install", "gitleaks@8.30.1");
			step("run ci check");
			runInherit(null, "podman", "exec", "-w", "/root/work/dotfiles", container, "mise", "exec", "gitleaks@8.30.1", "--", "java", "./scripts/dotfiles_integration_test.java", "--inside");
		} finally {
			runAllowFail(null, "podman", "rm", "-f", container);
		}
	}

	static void installMise(String container) throws Exception {
		var version = run(null, "mise", "--version").trim().split("\\s+")[0];
		var arch = run(null, "podman", "exec", container, "uname", "-m").trim();
		var platform = switch (arch) {
			case "x86_64", "amd64" -> "x64";
			case "aarch64", "arm64" -> "arm64";
			default -> throw new IllegalStateException("unsupported container arch: " + arch);
		};
		var url = "https://github.com/jdx/mise/releases/download/v" + version + "/mise-v" + version + "-linux-" + platform;
		run(null, "podman", "exec", container, "curl", "-fsSL", "-o", "/usr/local/bin/mise", url);
		run(null, "podman", "exec", container, "chmod", "755", "/usr/local/bin/mise");
		System.out.println(run(null, "podman", "exec", container, "mise", "--version").trim());
	}

	static void inside() throws Exception {
		root = Path.of(".").toAbsolutePath().normalize();
		System.out.println("rootless=" + rootless());
		ok("deps");
		System.out.println(run(root, "git", "--version").trim());
		System.out.println(run(root, "mise", "--version").trim());
		System.out.println(run(root, "java", "-version").lines().findFirst().orElse("java"));
		miseShellSmoke();
		run(root, "gitleaks", "git", "--redact=100", "--no-banner", "--log-opts=--all --full-history --diff-merges=first-parent --no-ext-diff --no-textconv");
		ok("gitleaks-history");

		run(root, "mise", "run", "hook");
		var common = Path.of(run(root, "git", "rev-parse", "--git-common-dir").trim());
		var hook = (common.isAbsolute() ? common : root.resolve(common)).resolve("hooks/pre-commit");
		if (!Files.isExecutable(hook)) fail("hook-executable");
		if (!Files.readString(hook).contains("exec java bootstripper.java pre-commit")) fail("hook-content");
		var pushHook = hook.resolveSibling("pre-push");
		if (!Files.isExecutable(pushHook) || !Files.readString(pushHook).contains("exec java bootstripper.java pre-push")) fail("push-hook-content");
		ok("install-hooks");

		if (!run(root, "git", "show", "HEAD:nushell/secrets.nu").startsWith("DFAES1")) fail("committed-encrypted");
		ok("committed-encrypted");

		run(root, "mise", "run", "setup-crypto");
		manifestChecks();

		if (Files.readString(root.resolve("nushell/secrets.nu")).startsWith("DFAES1")) fail("worktree-still-encrypted");
		ok("worktree-decrypted");

		run(root, "mise", "run", "check");
		ok("check-task");
		run(root, ".git/hooks/pre-commit");
		ok("pre-commit-hook");

		freshCryptoChecks();
		freshCheckoutCryptoChecks();
		freshHookChecks();
		miseRouteChecks();
		System.out.println("RESULT:ok");
	}

	static void miseShellSmoke() throws Exception {
		var temporary = Files.createTempDirectory("dotfiles-mise-shell-");
		var smoke = temporary.resolve("smoke");
		var shell = root.resolve("scripts/mise_shell.java").toString();
		try {
			var snippet = """
				mkdir(\"%1$s/tree\");
				write(\"%1$s/a.txt\", \"alpha\");
				IO.println(read(\"%1$s/a.txt\"));
				IO.println(out(\"java\", \"-version\").contains(\"openjdk\"));
				rm(\"%1$s/a.txt\");
				write(\"%1$s/tree/b.txt\", \"beta\");
				rmTree(\"%1$s/tree\");
				rmdir(\"%1$s\");
				""".formatted(smoke);
			var out = run(temporary, "java", shell, snippet);
			if (!out.contains("alpha") || !out.contains("true")) fail("mise-shell-helpers");
			if (!runExpectFail(temporary, Map.of(), "java", shell, "bad();").contains("cannot find symbol")) fail("mise-shell-errors");
			ok("mise-shell");
		} finally {
			deleteTree(temporary);
		}
	}

	static void processCleanupChecks() throws Exception {
		if (SETSID == null || KILL == null) fail("process-group-tools-unavailable");
		var temporary = Files.createTempDirectory("dotfiles-process-cleanup-");
		var pidFile = temporary.resolve("child.pid");
		try {
			runExpectFail(temporary, Map.of(), "sh", "-c", "sleep 30 & echo $! > '" + pidFile + "'; exit 7");
			assertProcessDead(Long.parseLong(Files.readString(pidFile).strip()), "descendant-survived-command-failure");

			var successPidFile = temporary.resolve("successful-child.pid");
			run(temporary, "sh", "-c", "sleep 30 </dev/null >/dev/null 2>&1 & echo $! > '" + successPidFile + "'; exit 0");
			assertProcessDead(Long.parseLong(Files.readString(successPidFile).strip()), "descendant-survived-successful-command");

			var stdinPidFile = temporary.resolve("stdin-child.pid");
			var stdinFailed = false;
			try {
				runInput(temporary, Map.of(), "x".repeat(256 * 1024), "sh", "-c", "exec 0<&-; sleep 30 & echo $! > '" + stdinPidFile + "'; exit 7");
			} catch (Exception expected) {
				stdinFailed = true;
			}
			if (!stdinFailed) fail("stdin-failure-not-reported");
			assertProcessDead(Long.parseLong(Files.readString(stdinPidFile).strip()), "descendant-survived-stdin-failure");

			var inheritPidFile = temporary.resolve("inherit-child.pid");
			try {
				runInherit(temporary, "sh", "-c", "sleep 30 & echo $! > '" + inheritPidFile + "'; exit 7");
				fail("inherited-command-failure-not-reported");
			} catch (CommandFailed expected) {}
			assertProcessDead(Long.parseLong(Files.readString(inheritPidFile).strip()), "descendant-survived-inherited-command-failure");

			assertCancellationCleanup(temporary, "--process-cancellation-child", "captured");
			assertCancellationCleanup(temporary, "--process-inherit-cancellation-child", "inherited");
			ok("process-cleanup");
		} finally {
			deleteTree(temporary);
		}
	}

	static void assertProcessDead(long pid, String failure) throws Exception {
		var process = ProcessHandle.of(pid);
		for (var attempts = 0; process.isPresent() && process.get().isAlive() && attempts < 20; attempts++) Thread.sleep(50);
		if (process.isPresent() && process.get().isAlive()) fail(failure);
	}

	static void assertCancellationCleanup(Path temporary, String mode, String name) throws Exception {
		var pidFile = temporary.resolve(name + "-cancellation-child.pid");
		var javaCommand = ProcessHandle.current().info().command().orElseThrow();
		var harness = root.resolve("scripts/dotfiles_integration_test.java").toString();
		var process = new ProcessBuilder(javaCommand, harness, mode, pidFile.toString())
				.redirectOutput(ProcessBuilder.Redirect.DISCARD).redirectError(ProcessBuilder.Redirect.DISCARD).start();
		try {
			for (var attempts = 0; !Files.exists(pidFile) && process.isAlive() && attempts < 200; attempts++) Thread.sleep(50);
			if (!Files.exists(pidFile)) fail(name + "-cancellation-child-not-started");
			var childPid = Long.parseLong(Files.readString(pidFile).strip());
			process.destroy();
			if (!process.waitFor(10, java.util.concurrent.TimeUnit.SECONDS)) process.destroyForcibly();
			assertProcessDead(childPid, "descendant-survived-" + name + "-cancellation");
		} finally {
			if (process.isAlive()) process.destroyForcibly();
		}
	}

	static void processCancellationChild(Path pidFile, boolean inherit) throws Exception {
		var command = new String[] { "sh", "-c", "sleep 30 & echo $! > '" + pidFile + "'; wait" };
		if (inherit) runInherit(root, command);
		else run(root, command);
	}

	static void manifestContractChecks() throws Exception {
		var syntheticHome = Path.of("/home/dotfiles-test");
		var hosts = allHosts();
		for (var host : hosts) {
			var targets = new HashSet<Path>();
			forEachMapping(root.resolve(host + ".symlinks"), (source, target) -> {
				if (!Files.exists(root.resolve(source), LinkOption.NOFOLLOW_LINKS)) fail("missing-source:" + host + ":" + source);
				var resolved = targetPath(syntheticHome, target).normalize();
				if (!resolved.startsWith(syntheticHome)) fail("outside-home:" + host + ":" + target);
				if (!targets.add(resolved)) fail("duplicate-target:" + host + ":" + target);
			});
		}
		ok("manifest-contracts:" + hosts.size());
	}

	static void manifestChecks() throws Exception {
		var hosts = allHosts();
		System.out.println("manifests=" + String.join(" ", hosts));
		for (var host : hosts) runManifest(host);
		ok("symlink-manifests:" + hosts.size());
	}

	static List<String> allHosts() throws IOException {
		var hosts = new ArrayList<String>();
		try (var s = Files.list(root)) {
			s.filter(p -> p.getFileName().toString().endsWith(".symlinks"))
				.map(p -> p.getFileName().toString().replaceFirst("\\.symlinks$", ""))
				.sorted().forEach(hosts::add);
		}
		return hosts;
	}

	static void runManifest(String host) throws Exception {
		var manifest = root.resolve(host + ".symlinks");
		if (!Files.exists(manifest)) fail("missing-manifest:" + manifest.getFileName());
		var home = Path.of(System.getProperty("user.home"), "home-" + host);
		System.out.println("manifest=" + manifest.getFileName());
		deleteTree(home);
		Files.createDirectories(home);
		var env = Map.of("HOSTNAME", host, "JAVA_TOOL_OPTIONS", "-Duser.home=" + home);
		run(root, env, "mise", "run", "link");
		ok("link:" + host);
		run(root, env, "mise", "run", "check");
		ok("check:" + host);
		forEachMapping(manifest, (source, target) -> {
			var path = targetPath(home, target);
			if (!Files.isSymbolicLink(path)) throw new RuntimeException("FAIL:link:" + host + ":" + target);
		});
		ok("links:" + host);
		run(root, env, "mise", "run", "clean");
		ok("clean:" + host);
	}

	record Fixture(Path home, Path repo, Path classes, Path realJava, Map<String, String> env) {}
	enum CryptoScope { ALL, GIT, RESTORE, BOOTSTRAP }

	static void freshCryptoChecks() throws Exception {
		freshCryptoChecks(CryptoScope.ALL);
	}

	static void freshCryptoChecks(CryptoScope scope) throws Exception {
		var name = scope == CryptoScope.ALL ? "crypto" : "crypto-" + scope.name().toLowerCase(Locale.ROOT);
		withFixture(name, fixture -> cryptoChecks(fixture, scope));
	}

	static void freshCheckoutCryptoChecks() throws Exception {
		withFixture("fresh-checkout-crypto", fixture -> {
			var repo = fixture.repo();
			var realEnv = new HashMap<>(fixture.env());
			realEnv.put("PATH", System.getenv("PATH"));
			var missingManifestEnv = new HashMap<>(realEnv);
			missingManifestEnv.remove("DOTFILES_AES_PASSPHRASE");
			missingManifestEnv.put("DOTFILES_SYMLINKS", "missing");
			if (!bootExpectFail(fixture, missingManifestEnv, "link").contains("Expected a file containing symlinks")) fail("missing-manifest-not-rejected-first");
			if (Files.exists(repo.resolve(".git/dotfiles-aes.key")) || Files.exists(repo.resolve(".git/dotfiles-aes.java")) || Files.exists(repo.resolve(".gitattributes")) || Files.exists(repo.resolve(".git/hooks/pre-commit"))) fail("missing-manifest-mutated-state");
			var failingAesEnv = new HashMap<>(fixture.env());
			failingAesEnv.put("FAKE_AES_EXIT", "1");
			if (!bootExpectFail(fixture, failingAesEnv, "setup-crypto").contains("AES self-test encryption failed")) fail("aes-self-test-failure-not-rejected");
			if (Files.exists(repo.resolve(".git/dotfiles-aes.key"))) fail("aes-self-test-failure-cached-key");
			try (var files = Files.list(repo.resolve(".git"))) {
				if (files.anyMatch(path -> path.getFileName().toString().startsWith("dotfiles-aes.key.") && path.getFileName().toString().endsWith(".tmp"))) fail("aes-self-test-failure-left-temporary-key");
			}
			boot(fixture, realEnv, "setup-crypto");
			var plain = Files.readAllBytes(repo.resolve("secret.txt"));
			var ciphertext = repo.resolve("secret.ciphertext");
			run(repo, realEnv, fixture.realJava().toString(), "-cp", fixture.classes().toString(), "aes", "encrypt", "--key-file",
					repo.resolve(".git/dotfiles-aes.key").toString(), repo.resolve("secret.txt").toString(), ciphertext.toString());
			run(repo, realEnv, "git", "add", ".dotfiles-aes-salt", ".gitattributes", ".secrets", "secret.txt", "bootstripper.java", "tools/aes.java", "mini.symlinks", "dummy.txt");
			run(repo, realEnv, "git", "commit", "--quiet", "-m", "encrypted checkout");
			var index = Files.readAllBytes(repo.resolve(".git/index"));
			Files.delete(repo.resolve(".git/dotfiles-aes.key"));
			run(repo, realEnv, "git", "config", "--local", "--remove-section", "filter.dotfiles-aes");
			run(repo, realEnv, "git", "config", "--local", "--remove-section", "merge.dotfiles-aes");
			var encrypted = Files.readAllBytes(ciphertext);
			Files.write(repo.resolve("secret.txt"), encrypted);
			Files.delete(ciphertext);

			var wrongEnv = new HashMap<>(realEnv);
			wrongEnv.put("DOTFILES_AES_PASSPHRASE", "wrong");
			if (!bootExpectFail(fixture, wrongEnv, "link").contains("Passphrase does not decrypt committed secret")) fail("wrong-passphrase-not-rejected");
			if (Files.exists(repo.resolve(".git/dotfiles-aes.key"))) fail("wrong-passphrase-poisoned-key-cache");
			try (var files = Files.list(repo.resolve(".git"))) {
				if (files.anyMatch(path -> path.getFileName().toString().startsWith("dotfiles-aes.key.") && path.getFileName().toString().endsWith(".tmp"))) fail("wrong-passphrase-left-temporary-key");
			}
			if (!Arrays.equals(Files.readAllBytes(repo.resolve("secret.txt")), encrypted)) fail("wrong-passphrase-rewrote-secret");
			var key = repo.resolve(".git/dotfiles-aes.key");
			Files.writeString(key, "invalid\n");
			if (!bootExpectFail(fixture, realEnv, "link").contains("Dotfiles AES key")) fail("malformed-key-not-rejected");
			if (!Files.readString(key).equals("invalid\n")) fail("malformed-key-replaced");
			Files.delete(key);

			boot(fixture, realEnv, "link");
			if (!run(repo, realEnv, "git", "config", "--local", "--get", "rebase.autostash").strip().equals("false")) fail("link-did-not-disable-rebase-autostash");
			if (!run(repo, realEnv, "git", "config", "--local", "--get", "merge.autostash").strip().equals("false")) fail("link-did-not-disable-merge-autostash");
			if (!Arrays.equals(Files.readAllBytes(repo.resolve("secret.txt")), plain)) fail("link-fresh-checkout-not-decrypted");
			if (!Arrays.equals(Files.readAllBytes(repo.resolve(".git/index")), index)) fail("link-fresh-checkout-changed-index");
			var keyModified = Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.key"));
			var aesToolModified = Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.java"));
			var configModified = Files.getLastModifiedTime(repo.resolve(".git/config"));
			var attributesModified = Files.getLastModifiedTime(repo.resolve(".gitattributes"));
			var secretModified = Files.getLastModifiedTime(repo.resolve("secret.txt"));
			var warmEnv = new HashMap<>(realEnv);
			warmEnv.remove("DOTFILES_AES_PASSPHRASE");
			boot(fixture, warmEnv, "link");
			if (!Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.key")).equals(keyModified)) fail("warm-link-rewrote-key");
			if (!Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.java")).equals(aesToolModified)) fail("warm-link-rewrote-aes-tool");
			if (!Files.getLastModifiedTime(repo.resolve(".git/config")).equals(configModified)) fail("warm-link-rewrote-config");
			if (!Files.getLastModifiedTime(repo.resolve(".gitattributes")).equals(attributesModified)) fail("warm-link-rewrote-attributes");
			if (!Files.getLastModifiedTime(repo.resolve("secret.txt")).equals(secretModified)) fail("warm-link-rewrote-secret");
			Files.writeString(repo.resolve("secret.txt"), "local plaintext edit\n");
			index = Files.readAllBytes(repo.resolve(".git/index"));
			boot(fixture, warmEnv, "link");
			if (!Files.readString(repo.resolve("secret.txt")).equals("local plaintext edit\n")) fail("warm-link-overwrote-local-plaintext");
			if (!Arrays.equals(Files.readAllBytes(repo.resolve(".git/index")), index)) fail("warm-link-with-local-edit-changed-index");

			var linkedHome = fixture.home().resolve("real-linked-home");
			var linked = linkedHome.resolve("repo");
			Files.createDirectories(linkedHome);
			var filterBeforeWorktree = run(repo, realEnv, "git", "config", "--local", "--get", "filter.dotfiles-aes.clean").strip();
			var configTimeBeforeWorktree = Files.getLastModifiedTime(repo.resolve(".git/config"));
			run(repo, realEnv, "git", "worktree", "add", "--quiet", "-b", "real-linked-test", linked.toString());
			if (!Arrays.equals(Files.readAllBytes(linked.resolve("secret.txt")), plain)) fail("real-linked-worktree-not-decrypted");
			var linkedEnv = new HashMap<>(realEnv);
			linkedEnv.put("HOME", linkedHome.toString());
			linkedEnv.put("JAVA_TOOL_OPTIONS", "-Duser.home=" + linkedHome);
			bootAt(fixture, linkedEnv, "link", linked);
			if (!run(repo, realEnv, "git", "config", "--local", "--get", "filter.dotfiles-aes.clean").strip().equals(filterBeforeWorktree) || !Files.getLastModifiedTime(repo.resolve(".git/config")).equals(configTimeBeforeWorktree)) fail("real-linked-worktree-rewrote-filter");
			run(repo, realEnv, "git", "worktree", "remove", "--force", linked.toString());
			ok("fresh-checkout-link-crypto");
		});
	}

	static void freshHookChecks() throws Exception {
		withFixture("hooks", fixture -> {
			Files.writeString(fixture.repo().resolve(".gitattributes"), "");
			run(fixture.repo(), fixture.env(), "git", "add", ".secrets", ".gitattributes", "bootstripper.java", "mini.symlinks", "dummy.txt");
			run(fixture.repo(), fixture.env(), "git", "commit", "--quiet", "-m", "base");
			linkAndHookChecks(fixture);
		});
	}

	static void miseRouteChecks() throws Exception {
		withFixture("mise-route", fixture -> {
			run(fixture.repo(), fixture.env(), "mise", "trust", "--yes");
			run(fixture.repo(), fixture.env(), "mise", "run", "--skip-tools", "link");
			if (!Files.isSymbolicLink(fixture.home().resolve("dummy.txt"))) fail("mise-route-link");
			ok("mise-route");
		});
	}

	interface FixtureCheck { void run(Fixture fixture) throws Exception; }

	static void withFixture(String name, FixtureCheck check) throws Exception {
		var home = Files.createTempDirectory(Path.of(System.getProperty("user.home")), "dotfiles-" + name + "-");
		Files.writeString(home.resolve(FIXTURE_MARKER), name + "\n");
		ACTIVE_FIXTURES.add(home);
		var started = System.nanoTime();
		try {
			check.run(createFixture(home));
			ok(name + ":" + Duration.ofNanos(System.nanoTime() - started).toMillis() + "ms");
		} finally {
			ACTIVE_FIXTURES.remove(home);
			deleteTree(home);
		}
	}

	static Fixture createFixture(Path home) throws Exception {
		var repo = home.resolve("repo");
		var classes = home.resolve("classes");
		var bin = home.resolve("bin");
		Files.createDirectories(repo.resolve("tools"));
		Files.createDirectories(repo.resolve("scripts"));
		Files.createDirectories(classes);
		Files.createDirectories(bin);
		Files.copy(root.resolve("bootstripper.java"), repo.resolve("bootstripper.java"));
		Files.copy(root.resolve("tools/aes.java"), repo.resolve("tools/aes.java"));
		Files.copy(root.resolve(".dotfiles-aes-salt"), repo.resolve(".dotfiles-aes-salt"));
		Files.copy(root.resolve("scripts/mise_shell.java"), repo.resolve("scripts/mise_shell.java"));
		try (var files = Files.list(root)) {
			for (var source : files.filter(path -> {
				var filename = path.getFileName().toString();
				return filename.equals(".miserc.toml") || filename.equals("mise.lock") || filename.matches("^\\.?mise(?:\\..+)?\\.toml$");
			}).toList())
				Files.copy(source, repo.resolve(source.getFileName()));
		}
		Files.writeString(repo.resolve(".secrets"), "secret.txt\n");
		Files.writeString(repo.resolve("mini.symlinks"), "dummy.txt\n~/dummy.txt\n");
		Files.writeString(repo.resolve("secret.txt"), "alpha=base\nmiddle=stable\nomega=base\n");
		Files.writeString(repo.resolve("dummy.txt"), "dummy\n");
		var gitleaks = bin.resolve("gitleaks");
		Files.writeString(gitleaks, "#!/bin/sh\nif [ -n \"${FAKE_GITLEAKS_LOG:-}\" ]; then printf '%s\\n' \"$*\" >> \"$FAKE_GITLEAKS_LOG\"; fi\nif [ \"${FAKE_GITLEAKS_EXIT:-0}\" != 0 ]; then echo 'Finding: REDACTED' >&2; exit \"$FAKE_GITLEAKS_EXIT\"; fi\nexit 0\n");
		Files.setPosixFilePermissions(gitleaks, PosixFilePermissions.fromString("rwxr-xr-x"));
		var xdg = home.resolve("xdg");
		var temporary = home.resolve("tmp");
		var gitConfig = home.resolve("empty.gitconfig");
		var gitTemplates = home.resolve("git-templates");
		Files.createDirectories(xdg);
		Files.createDirectories(temporary);
		Files.createDirectories(gitTemplates);
		Files.writeString(gitConfig, "");
		var env = new HashMap<String, String>();
		env.put(CLEAN_ENV, "1");
		env.put("HOME", home.toString());
		env.put("USER", "dotfiles-test");
		env.put("SHELL", "/bin/sh");
		env.put("LANG", "C");
		env.put("LC_ALL", "C");
		env.put("TMPDIR", temporary.toString());
		env.put("XDG_CONFIG_HOME", xdg.resolve("config").toString());
		env.put("XDG_CACHE_HOME", xdg.resolve("cache").toString());
		env.put("XDG_DATA_HOME", xdg.resolve("data").toString());
		env.put("XDG_STATE_HOME", xdg.resolve("state").toString());
		env.put("MISE_DATA_DIR", xdg.resolve("mise-data").toString());
		env.put("MISE_CACHE_DIR", xdg.resolve("mise-cache").toString());
		env.put("MISE_SYSTEM_CONFIG_DIR", xdg.resolve("mise-system").toString());
		env.put("MISE_GLOBAL_CONFIG_FILE", xdg.resolve("mise-global.toml").toString());
		env.put("MISE_CEILING_PATHS", home.toString());
		env.put("GIT_CONFIG_NOSYSTEM", "1");
		env.put("GIT_CONFIG_GLOBAL", gitConfig.toString());
		env.put("GIT_ATTR_NOSYSTEM", "1");
		env.put("GIT_TEMPLATE_DIR", gitTemplates.toString());
		env.put("GIT_TERMINAL_PROMPT", "0");
		env.put("GIT_CEILING_DIRECTORIES", home.toString());
		env.put("DOTFILES_AES_PASSPHRASE", "test");
		env.put("JAVA_TOOL_OPTIONS", "-Duser.home=" + home);
		env.put("PATH", bin + File.pathSeparator + System.getenv("PATH"));
		env.put("DOTFILES_SYMLINKS", "mini");
		run(repo, env, "git", "init", "--quiet");
		run(repo, env, "git", "config", "user.name", "Dotfiles Test");
		run(repo, env, "git", "config", "user.email", "dotfiles@example.invalid");
		run(repo, env, "git", "config", "commit.gpgSign", "false");
		Files.createDirectories(repo.resolve(".git/info"));
		Files.writeString(repo.resolve(".git/info/attributes"), "*.bin -diff\n# BEGIN bootstripper secrets\nsecret.txt -text filter=dotfiles-aes -diff merge=dotfiles-aes\n# END bootstripper secrets\n");
		// Exercise restoration directly so refusal cases do not repeat unrelated filter setup.
		Files.writeString(repo.resolve("scripts/restore_test_driver.java"), """
				import java.nio.file.Path;

				class restore_test_driver {
					public static void main(String[] arguments) throws Throwable {
						var root = Path.of(arguments[0]).toRealPath();
						bootstripper.restore_tracked_secrets(root, bootstripper.read_secrets(root));
					}
				}
				""");
		Files.writeString(repo.resolve("scripts/real_aes_test_driver.java"), """
				import java.nio.charset.StandardCharsets;
				import java.nio.file.*;
				import java.util.*;

				class real_aes_test_driver {
					static byte[] bytes(String text) { return text.getBytes(StandardCharsets.UTF_8); }
					static void require(boolean condition, String message) {
						if (!condition) throw new AssertionError(message);
					}
					static void writeEncrypted(Path path, byte[] key, String text) throws Exception {
						Files.write(path, aes.encrypt(key, bytes(text)));
					}
					public static void main(String[] arguments) throws Exception {
						var key = Base64.getDecoder().decode(Files.readString(Path.of(arguments[0])).trim());
						var work = Path.of(arguments[1]);
						Files.createDirectories(work);
						var plain = bytes("synthetic secret\\n");
						var encrypted = aes.encrypt(key, plain);
						require(Arrays.equals(encrypted, aes.encrypt(key, plain)), "encryption is not deterministic");
						require(Arrays.equals(plain, aes.decrypt(key, encrypted)), "round trip failed");
						try { aes.encrypt(key, encrypted); throw new AssertionError("double encryption accepted"); }
						catch (IllegalArgumentException expected) {}

						var base = work.resolve("base");
						var current = work.resolve("current");
						var other = work.resolve("other");
						writeEncrypted(base, key, "alpha=base\\nmiddle=stable\\nomega=base\\n");
						writeEncrypted(current, key, "alpha=base\\nmiddle=stable\\nomega=current\\n");
						writeEncrypted(other, key, "alpha=other\\nmiddle=stable\\nomega=base\\n");
						require(aes.merge(key, base, current, other) == 0, "clean merge failed");
						var merged = new String(aes.decrypt(key, Files.readAllBytes(current)), StandardCharsets.UTF_8);
						require(merged.contains("alpha=other") && merged.contains("omega=current"), "clean merge lost changes");

						writeEncrypted(base, key, "value=base\\n");
						writeEncrypted(current, key, "value=current\\n");
						writeEncrypted(other, key, "value=other\\n");
						require(aes.merge(key, base, current, other) == 1, "conflicting merge did not conflict");
						var conflict = new String(aes.decrypt(key, Files.readAllBytes(current)), StandardCharsets.UTF_8);
						require(conflict.contains("<<<<<<< current") && conflict.contains("value=other"), "conflict markers missing");
					}
				}
				""");
		run(repo, env, "javac", "-d", classes.toString(), "bootstripper.java", "tools/aes.java", "scripts/restore_test_driver.java", "scripts/real_aes_test_driver.java");
		var realJava = Path.of(ProcessHandle.current().info().command().orElseThrow()).toAbsolutePath();
		// Native Git uses a fast deterministic codec; realAesChecks covers the production codec separately.
		var fastAes = bin.resolve("test-aes");
		Files.writeString(fastAes, """
				#!/bin/sh
				set -eu
				command=$1; shift
				if [ -n "${FAKE_AES_LOG:-}" ]; then printf '%s\\n' "$command" >> "$FAKE_AES_LOG"; fi
				if [ "${FAKE_AES_EXIT:-0}" != 0 ]; then echo 'synthetic AES failure' >&2; exit "$FAKE_AES_EXIT"; fi
				[ "$1" = --key-file ]; shift 2
				tmp=${TMPDIR:-/tmp}/dotfiles-test-aes-$$
				trap 'rm -rf "$tmp"' EXIT
				mkdir "$tmp"
				decrypt() { [ "$(head -c 6 "$1")" = DFAES1 ] || { echo 'invalid dotfiles AES blob' >&2; return 1; }; tail -c +7 "$1"; }
				case "$command" in
				  encrypt)
				    if [ "$#" -eq 0 ]; then cat > "$tmp/in"; input=$tmp/in; output=-; else input=$1; output=$2; [ ! -e "$output" ]; fi
				    [ "$(head -c 6 "$input")" != DFAES1 ] || { echo 'input is already a dotfiles AES blob' >&2; exit 2; }
				    if [ "$output" = - ]; then printf DFAES1; cat "$input"; else { printf DFAES1; cat "$input"; } > "$output"; fi;;
				  decrypt)
				    if [ "$#" -eq 0 ]; then cat > "$tmp/in"; decrypt "$tmp/in"; else [ ! -e "$2" ]; decrypt "$1" > "$2"; fi;;
				  merge)
				    decrypt "$1" > "$tmp/base"; decrypt "$2" > "$tmp/current"; decrypt "$3" > "$tmp/other"
				    status=0; git merge-file -L current -L base -L other "$tmp/current" "$tmp/base" "$tmp/other" || status=$?
				    { printf DFAES1; cat "$tmp/current"; } > "$2"
				    exit "$status";;
				  *) exit 2;;
				esac
				""");
		Files.setPosixFilePermissions(fastAes, PosixFilePermissions.fromString("rwxr-xr-x"));
		var java = bin.resolve("java");
		Files.writeString(java, "#!/bin/sh\ncase \"$1\" in\n  */tools/aes.java|tools/aes.java|*/dotfiles-aes.java) shift; exec " + shellQuote(fastAes) + " \"$@\";;\n  *) exec " + shellQuote(realJava) + " \"$@\";;\nesac\n");
		Files.setPosixFilePermissions(java, PosixFilePermissions.fromString("rwxr-xr-x"));
		return new Fixture(home, repo, classes, realJava, Map.copyOf(env));
	}

	static String shellQuote(Path path) {
		return "'" + path.toString().replace("'", "'\"'\"'") + "'";
	}

	static String boot(Fixture fixture, String command) throws Exception {
		return boot(fixture, fixture.env(), command);
	}

	static String boot(Fixture fixture, Map<String, String> env, String command) throws Exception {
		return bootAt(fixture, env, command, fixture.repo());
	}

	static String bootAt(Fixture fixture, Map<String, String> env, String command, Path root) throws Exception {
		return run(root, env, "java", "-cp", fixture.classes().toString(), "bootstripper", command, root.toString());
	}

	static String bootExpectFail(Fixture fixture, Map<String, String> env, String command) throws Exception {
		try {
			var output = boot(fixture, env, command);
			throw new AssertionError("expected failure: bootstripper " + command + "\n" + output);
		} catch (CommandFailed error) {
			return error.getMessage();
		}
	}

	static String restoreExpectFail(Fixture fixture) throws Exception {
		return runExpectFail(fixture.repo(), fixture.env(), "java", "-cp", fixture.classes().toString(), "restore_test_driver", fixture.repo().toString());
	}

	static void cryptoChecks(Fixture fixture, CryptoScope scope) throws Exception {
		var home = fixture.home();
		var repo = fixture.repo();
		var env = fixture.env();
		boot(fixture, "init-crypto");
		boot(fixture, "setup-crypto");
		var mergeDriver = run(repo, env, "git", "config", "--local", "--get", "merge.dotfiles-aes.driver").strip();
		if (!mergeDriver.endsWith(" '%O' '%A' '%B'")) fail("merge-driver-placeholders-not-portable");
		var attributes = repo.resolve(".gitattributes");
		Files.writeString(attributes, Files.readString(attributes).replace("\n", "\r\n"));
		boot(fixture, "setup-crypto");
		var rewrittenAttributes = Files.readString(attributes);
		if (rewrittenAttributes.indexOf("# BEGIN bootstripper secrets") != rewrittenAttributes.lastIndexOf("# BEGIN bootstripper secrets")) fail("crlf-attributes-duplicated");
		if (scope == CryptoScope.ALL || scope == CryptoScope.GIT) realAesChecks(fixture);
		boot(fixture, "check");
		if (!Files.readString(repo.resolve(".gitattributes")).contains("secret.txt -text filter=dotfiles-aes -diff merge=dotfiles-aes")) fail("tracked-attributes-generated");
		if (!Files.readString(repo.resolve(".git/info/attributes")).equals("*.bin -diff\n")) fail("local-attributes-migrated");
		run(repo, env, "git", "add", ".dotfiles-aes-salt", ".gitattributes", ".secrets", "bootstripper.java", "tools/aes.java", "mini.symlinks", "dummy.txt", "secret.txt");
		if (!run(repo, env, "git", "show", ":secret.txt").startsWith("DFAES1")) fail("fresh-staged-encrypted");
		run(repo, env, "git", "commit", "--quiet", "-m", "base");
		var base = run(repo, env, "git", "rev-parse", "HEAD").trim();
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nincoming-secret.txt\n");
		Files.writeString(repo.resolve("incoming-secret.txt"), "incoming=plain\n");
		boot(fixture, "check");
		run(repo, env, "git", "add", ".secrets", ".gitattributes", "incoming-secret.txt");
		run(repo, env, "git", "commit", "--quiet", "-m", "add encrypted secret");
		var incoming = run(repo, env, "git", "rev-parse", "HEAD").trim();
		run(repo, env, "git", "switch", "--quiet", "--detach", base);
		run(repo, env, "git", "merge", "--quiet", "--ff-only", incoming);
		if (!Files.readString(repo.resolve("incoming-secret.txt")).equals("incoming=plain\n")) fail("same-pull-secret-not-smudged");
		ok("same-pull-secret-smudged");
		base = incoming;
		if (scope == CryptoScope.ALL || scope == CryptoScope.RESTORE) cryptoRestoreChecks(fixture);
		if (scope == CryptoScope.RESTORE) {
			ok("crypto-restore");
			return;
		}

		if (scope != CryptoScope.BOOTSTRAP) {
			cryptoGitChecks(fixture, base);
			if (scope == CryptoScope.GIT) return;
		}
		Files.writeString(repo.resolve("secret.txt"), "not a real secret\n");
		var wslHome = home.resolve("fresh-wsl-home");
		Files.createDirectories(wslHome);
		var wslEnv = new HashMap<>(env);
		wslEnv.remove("DOTFILES_SYMLINKS");
		wslEnv.put("JAVA_TOOL_OPTIONS", "-Duser.home=" + wslHome);
		wslEnv.put("COMPUTERNAME", "");
		wslEnv.put("WSL_DISTRO_NAME", "mini");
		wslEnv.put("HOSTNAME", "ignored");
		boot(fixture, wslEnv, "bootstrap");
		if (!Files.readString(repo.resolve("secret.txt")).equals("not a real secret\n")) fail("bootstrap-overwrote-local-secret");
		if (!Files.isSymbolicLink(wslHome.resolve("dummy.txt"))) fail("fresh-wsl-distro-manifest");
		boot(fixture, wslEnv, "clean");
		var env2 = new HashMap<>(env);
		env2.remove("DOTFILES_SYMLINKS");
		env2.put("HOSTNAME", "mini");
		Files.createSymbolicLink(home.resolve("dummy.txt"), Path.of("/usr/lib/outside-home"));
		if (!bootExpectFail(fixture, env2, "bootstrap").contains("outside HOME")) fail("outside-home-symlink-not-rejected");
		Files.delete(home.resolve("dummy.txt"));
		boot(fixture, env2, "bootstrap");
		if (!Files.isSymbolicLink(home.resolve("dummy.txt"))) fail("fresh-link");
		boot(fixture, env2, "clean");
		ok(scope == CryptoScope.ALL ? "fresh-crypto" : "bootstrap");
	}

	static void cryptoGitChecks(Fixture fixture, String base) throws Exception {
		var repo = fixture.repo();
		var env = fixture.env();
		run(repo, env, "git", "switch", "--quiet", "-c", "secret-other");
		Files.writeString(repo.resolve("secret.txt"), "alpha=other\nmiddle=stable\nomega=base\n");
		run(repo, env, "git", "add", "secret.txt");
		run(repo, env, "git", "commit", "--quiet", "-m", "other secret change");
		run(repo, env, "git", "switch", "--quiet", "-c", "secret-current", base);
		Files.writeString(repo.resolve("secret.txt"), "alpha=base\nmiddle=stable\nomega=current\n");
		run(repo, env, "git", "add", "secret.txt");
		run(repo, env, "git", "commit", "--quiet", "-m", "current secret change");
		run(repo, env, "git", "merge", "--quiet", "--no-edit", "secret-other");
		var merged = Files.readString(repo.resolve("secret.txt"));
		if (!merged.contains("alpha=other") || !merged.contains("omega=current") || merged.startsWith("DFAES1")) fail("secret-clean-merge");
		if (!run(repo, env, "git", "show", "HEAD:secret.txt").startsWith("DFAES1")) fail("secret-merge-committed-encrypted");
		ok("secret-clean-merge");

		var conflictBase = run(repo, env, "git", "rev-parse", "HEAD").trim();
		run(repo, env, "git", "switch", "--quiet", "-c", "secret-conflict-other");
		Files.writeString(repo.resolve("secret.txt"), "alpha=conflict-other\nmiddle=stable\nomega=current\n");
		run(repo, env, "git", "add", "secret.txt");
		run(repo, env, "git", "commit", "--quiet", "-m", "other conflicting secret change");
		run(repo, env, "git", "switch", "--quiet", "-c", "secret-conflict-current", conflictBase);
		Files.writeString(repo.resolve("secret.txt"), "alpha=conflict-current\nmiddle=stable\nomega=current\n");
		run(repo, env, "git", "add", "secret.txt");
		run(repo, env, "git", "commit", "--quiet", "-m", "current conflicting secret change");
		runExpectFail(repo, env, "git", "merge", "--quiet", "--no-edit", "secret-conflict-other");
		var conflicted = Files.readString(repo.resolve("secret.txt"));
		if (!conflicted.contains("<<<<<<< current") || !conflicted.contains("alpha=conflict-current") || !conflicted.contains("alpha=conflict-other") || conflicted.startsWith("DFAES1")) fail("secret-conflict-plaintext");
		run(repo, env, "git", "merge", "--abort");
		ok("secret-conflict-plaintext");

		run(repo, env, fixture.home().resolve("bin/test-aes").toString(), "encrypt", "--key-file", ".git/dotfiles-aes.key", "secret.txt", "encrypted-secret.txt");
		Files.write(repo.resolve("secret.txt"), Files.readAllBytes(repo.resolve("encrypted-secret.txt")));
		Files.delete(repo.resolve("encrypted-secret.txt"));
		if (!runExpectFail(repo, env, "git", "add", "secret.txt").contains("input is already a dotfiles AES blob")) fail("encrypted-worktree-clean-not-rejected");
		ok("crypto-git");
	}

	static void realAesChecks(Fixture fixture) throws Exception {
		run(fixture.repo(), fixture.env(), fixture.realJava().toString(), "-cp", fixture.classes().toString(), "real_aes_test_driver",
				fixture.repo().resolve(".git/dotfiles-aes.key").toString(), fixture.home().resolve("real-aes").toString());
		ok("real-aes-contract");
	}

	static void linkAndHookChecks(Fixture fixture) throws Exception {
		var repo = fixture.repo();
		var home = fixture.home();
		var env = fixture.env();
		var linkEnv = new HashMap<>(env);
		linkEnv.put("DOTFILES_SYMLINKS", "mini");
		var gitleaksLog = home.resolve("gitleaks.log");
		var aesLog = home.resolve("aes.log");
		linkEnv.put("FAKE_GITLEAKS_LOG", gitleaksLog.toString());
		linkEnv.put("FAKE_AES_LOG", aesLog.toString());
		var secret = Files.readString(repo.resolve("secret.txt"));
		var hook = repo.resolve(".git/hooks/pre-commit");
		var pushHook = repo.resolve(".git/hooks/pre-push");
		boot(fixture, linkEnv, "link");
		if (Files.exists(gitleaksLog)) fail("link-invoked-gitleaks");
		if (!Files.isReadable(repo.resolve(".git/dotfiles-aes.key"))) fail("link-key-created");
		if (!Files.readString(repo.resolve("secret.txt")).equals(secret)) fail("link-plaintext-secret-changed");
		var configured = Files.readString(repo.resolve(".git/config"));
		var trackedAttributes = Files.readString(repo.resolve(".gitattributes"));
		var localAttributes = Files.readString(repo.resolve(".git/info/attributes"));
		if (!trackedAttributes.contains("secret.txt -text filter=dotfiles-aes -diff merge=dotfiles-aes")) fail("link-attributes-configured");
		if (!localAttributes.equals("*.bin -diff\n")) fail("link-local-attributes-migrated");
		if (!Files.isExecutable(hook) || !Files.readString(hook).contains("exec java bootstripper.java pre-commit")) fail("link-hook-installed");
		if (!Files.isExecutable(pushHook) || !Files.readString(pushHook).contains("exec java bootstripper.java pre-push")) fail("link-push-hook-installed");
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nother.txt\n");
		if (!runExpectFail(repo, linkEnv, hook.toString()).contains(".secrets must match the staged version")) fail("pre-commit-staged-manifest-check");
		run(repo, env, "git", "restore", ".secrets");
		Files.writeString(repo.resolve(".gitattributes"), "*.tmp -diff\n");
		if (!runExpectFail(repo, linkEnv, hook.toString()).contains(".gitattributes must match the staged version")) fail("pre-commit-staged-attributes-check");
		Files.writeString(repo.resolve(".gitattributes"), trackedAttributes);
		Files.deleteIfExists(gitleaksLog);
		var keyModified = Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.key"));
		var blockedEnv = new HashMap<>(linkEnv);
		blockedEnv.put("FAKE_GITLEAKS_EXIT", "1");
		if (!bootExpectFail(fixture, blockedEnv, "pre-commit").contains("Gitleaks blocked the operation")) fail("gitleaks-finding-not-rejected");
		Files.deleteIfExists(gitleaksLog);
		var base = run(repo, env, "git", "rev-parse", "HEAD").trim();
		var zero = "0".repeat(base.length());
		runInput(repo, linkEnv, "refs/heads/main " + base + " refs/heads/main " + zero + "\n", pushHook.toString());
		var pushArguments = Files.readString(gitleaksLog);
		if (!pushArguments.contains("--platform=none") || !pushArguments.contains("--full-history") || !pushArguments.contains(base)) fail("pre-push-new-ref-range");
		Files.delete(gitleaksLog);
		var pushBlockedEnv = new HashMap<>(linkEnv);
		pushBlockedEnv.put("FAKE_GITLEAKS_EXIT", "1");
		if (!runInputExpectFail(repo, pushBlockedEnv, "refs/heads/main " + base + " refs/heads/main " + zero + "\n", pushHook.toString()).contains("Gitleaks blocked the operation")) fail("pre-push-gitleaks-failure");
		Files.deleteIfExists(gitleaksLog);
		runInput(repo, linkEnv, "refs/heads/main " + zero + " refs/heads/main " + base + "\n", pushHook.toString());
		if (Files.exists(gitleaksLog)) fail("pre-push-deletion-scan");
		Files.deleteIfExists(gitleaksLog);
		var unchanged = java.nio.file.attribute.FileTime.from(Instant.parse("2000-01-01T00:00:00Z"));
		Files.setLastModifiedTime(hook, unchanged);
		Files.setLastModifiedTime(pushHook, unchanged);
		var configModified = Files.getLastModifiedTime(repo.resolve(".git/config"));
		var aesToolModified = Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.java"));
		var attributesModified = Files.getLastModifiedTime(repo.resolve(".gitattributes"));
		var secretModified = Files.getLastModifiedTime(repo.resolve("secret.txt"));
		Files.deleteIfExists(aesLog);
		boot(fixture, linkEnv, "link");
		if (Files.exists(aesLog)) fail("link-warm-noop-invoked-aes");
		if (!Files.getLastModifiedTime(hook).equals(unchanged) || !Files.getLastModifiedTime(pushHook).equals(unchanged)) fail("link-hook-not-idempotent");
		if (!Files.getLastModifiedTime(repo.resolve(".git/config")).equals(configModified)) fail("link-config-not-idempotent");
		if (!Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.java")).equals(aesToolModified)) fail("link-aes-tool-not-idempotent");
		if (!Files.getLastModifiedTime(repo.resolve(".gitattributes")).equals(attributesModified)) fail("link-attributes-not-idempotent");
		if (!Files.getLastModifiedTime(repo.resolve("secret.txt")).equals(secretModified)) fail("link-secret-not-idempotent");
		if (!Files.getLastModifiedTime(repo.resolve(".git/dotfiles-aes.key")).equals(keyModified)) fail("link-key-not-idempotent");
		run(repo, env, "git", "config", "--local", "filter.dotfiles-aes.clean", "broken");
		Files.writeString(repo.resolve(".gitattributes"), "");
		Files.setPosixFilePermissions(repo.resolve(".git/dotfiles-aes.key"), PosixFilePermissions.fromString("rw-r--r--"));
		boot(fixture, linkEnv, "link");
		if (!Files.readString(repo.resolve(".git/config")).equals(configured)) fail("link-filter-not-repaired");
		if (!Files.readString(repo.resolve(".gitattributes")).equals(trackedAttributes)) fail("link-attributes-not-repaired");
		if (!Files.getPosixFilePermissions(repo.resolve(".git/dotfiles-aes.key")).equals(PosixFilePermissions.fromString("rw-------"))) fail("link-key-mode-not-repaired");
		Files.writeString(hook, "#!/bin/sh\n# Managed by dorkfiles bootstripper.java\nbroken\n");
		Files.setPosixFilePermissions(hook, PosixFilePermissions.fromString("rw-r--r--"));
		boot(fixture, linkEnv, "link");
		if (!Files.isExecutable(hook) || !Files.readString(hook).contains("exec java bootstripper.java pre-commit")) fail("link-hook-not-repaired");
		var managed = Files.readString(hook);
		Files.writeString(hook, "#!/bin/sh\nexit 0\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Refusing to replace unmanaged Git hook")) fail("link-unmanaged-hook-not-rejected");
		if (!Files.readString(hook).equals("#!/bin/sh\nexit 0\n")) fail("link-unmanaged-hook-changed");
		Files.writeString(hook, managed);
		Files.setPosixFilePermissions(hook, PosixFilePermissions.fromString("rwxr-xr-x"));
		var symlinkTarget = home.resolve("unmanaged-hook");
		Files.writeString(symlinkTarget, "unmanaged\n");
		Files.delete(hook);
		Files.createSymbolicLink(hook, symlinkTarget);
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Refusing to replace non-file Git hook")) fail("link-symlink-hook-not-rejected");
		if (!Files.readString(symlinkTarget).equals("unmanaged\n")) fail("link-symlink-target-changed");
		Files.delete(hook);
		Files.writeString(hook, managed);
		Files.setPosixFilePermissions(hook, PosixFilePermissions.fromString("rwxr-xr-x"));
		if (!Files.isSymbolicLink(home.resolve("dummy.txt")) || !Files.isSameFile(home.resolve("dummy.txt"), repo.resolve("dummy.txt"))) fail("link-target");
		if (!Files.readString(repo.resolve("secret.txt")).equals(secret)) fail("link-secret-changed");
		if (!Files.readString(repo.resolve(".gitattributes")).equals(trackedAttributes)) fail("link-tracked-attributes-changed");
		if (!Files.readString(repo.resolve(".git/config")).equals(configured)) fail("link-config-changed");
		if (!Files.readString(repo.resolve(".git/info/attributes")).equals(localAttributes)) fail("link-local-attributes-changed");
		if (!Files.isReadable(repo.resolve(".git/dotfiles-aes.key"))) fail("link-key-missing");
		run(repo, env, "git", "config", "--local", "core.hooksPath", "custom-hooks");
		boot(fixture, linkEnv, "link");
		var customHooks = Path.of(run(repo, env, "git", "rev-parse", "--path-format=absolute", "--git-path", "hooks").trim());
		if (!Files.isExecutable(customHooks.resolve("pre-commit")) || !Files.isExecutable(customHooks.resolve("pre-push"))) fail("custom-hooks-path");
		run(repo, env, "git", "config", "--local", "--unset", "core.hooksPath");
		run(repo, env, "git", "config", "--local", "extensions.worktreeConfig", "true");
		run(repo, env, "git", "config", "--worktree", "core.hooksPath", "worktree-hooks");
		boot(fixture, linkEnv, "link");
		var worktreeHooks = Path.of(run(repo, env, "git", "rev-parse", "--path-format=absolute", "--git-path", "hooks").trim());
		if (!Files.isExecutable(worktreeHooks.resolve("pre-commit")) || !Files.isExecutable(worktreeHooks.resolve("pre-push"))) fail("worktree-hooks-path");
		run(repo, env, "git", "config", "--worktree", "--unset", "core.hooksPath");
		Files.delete(home.resolve("dummy.txt"));

		var manifest = repo.resolve("mini.symlinks");
		var original = Files.readString(manifest);
		for (var invalid : List.of("missing.txt\n~/missing.txt\n", "dummy.txt\n", "dummy.txt\n~/dummy.txt\n", "dummy.txt\n/etc/dotfiles-test\n")) {
			Files.writeString(manifest, original + invalid);
			bootExpectFail(fixture, linkEnv, "link");
			if (Files.exists(home.resolve("dummy.txt"), LinkOption.NOFOLLOW_LINKS)) fail("link-before-validation");
		}
		Files.writeString(manifest, original);
		boot(fixture, "init-crypto");
		boot(fixture, "setup-crypto");
		run(repo, env, "git", "add", ".dotfiles-aes-salt", ".gitattributes", ".secrets", "secret.txt", "tools/aes.java");
		run(repo, env, "git", "-c", "core.hooksPath=/dev/null", "commit", "--quiet", "-m", "configure crypto");
		Files.writeString(repo.resolve("secret.txt"), "valid staged secret\n");
		run(repo, env, "git", "add", "secret.txt");
		boot(fixture, "pre-commit");
		run(repo, env, "git", "reset", "--quiet", "HEAD", "--", "secret.txt");
		run(repo, env, "git", "restore", "secret.txt");

		var outsideSecret = home.resolve("outside-secret");
		Files.writeString(outsideSecret, "outside\n");
		Files.setPosixFilePermissions(outsideSecret, PosixFilePermissions.fromString("rw-r--r--"));
		Files.createDirectories(repo.resolve("ssh"));
		Files.createSymbolicLink(repo.resolve("ssh/test.key"), outsideSecret);
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nssh/test.key\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Secret path must not contain symlinks")) fail("secret-symlink-not-rejected");
		if (!Files.readString(outsideSecret).equals("outside\n") || !Files.getPosixFilePermissions(outsideSecret).equals(PosixFilePermissions.fromString("rw-r--r--"))) fail("secret-symlink-mutated-target");
		Files.delete(repo.resolve("ssh/test.key"));
		Files.delete(repo.resolve("ssh"));
		var canceledOutside = home.resolve("canceled-outside");
		Files.createDirectories(canceledOutside.resolve("child"));
		var canceledTarget = canceledOutside.resolve("victim.key");
		Files.writeString(canceledTarget, "canceled outside\n");
		Files.setPosixFilePermissions(canceledTarget, PosixFilePermissions.fromString("rw-r--r--"));
		Files.createDirectories(repo.resolve("ssh"));
		Files.createSymbolicLink(repo.resolve("ssh/jump"), canceledOutside.resolve("child"));
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nssh/jump/../victim.key\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Secret path must not contain parent components")) fail("secret-canceled-component-not-rejected");
		if (!Files.readString(canceledTarget).equals("canceled outside\n") || !Files.getPosixFilePermissions(canceledTarget).equals(PosixFilePermissions.fromString("rw-r--r--"))) fail("secret-canceled-component-mutated-target");
		Files.delete(repo.resolve("ssh/jump"));
		Files.delete(repo.resolve("ssh"));
		var outsideDirectory = home.resolve("outside-directory");
		Files.createDirectories(outsideDirectory);
		Files.writeString(outsideDirectory.resolve("secret.txt"), "outside parent\n");
		Files.createSymbolicLink(repo.resolve("nested"), outsideDirectory);
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nnested/secret.txt\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Secret path must not contain symlinks")) fail("secret-parent-symlink-not-rejected");
		if (!Files.readString(outsideDirectory.resolve("secret.txt")).equals("outside parent\n")) fail("secret-parent-symlink-mutated-target");
		Files.delete(repo.resolve("nested"));
		var listedDirectory = repo.resolve("secret-directory");
		Files.createDirectories(listedDirectory);
		Files.createSymbolicLink(listedDirectory.resolve("nested"), outsideDirectory);
		Files.writeString(repo.resolve(".secrets"), "secret.txt\nsecret-directory\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("Secret path must name a regular file")) fail("secret-directory-not-rejected");
		if (!Files.readString(outsideDirectory.resolve("secret.txt")).equals("outside parent\n")) fail("secret-directory-mutated-descendant");
		Files.writeString(repo.resolve(".secrets"), ":(top)secret-directory/\n");
		if (!bootExpectFail(fixture, linkEnv, "link").contains("invalid secret path")) fail("secret-pathspec-magic-not-rejected");
		if (!Files.readString(outsideDirectory.resolve("secret.txt")).equals("outside parent\n")) fail("secret-pathspec-magic-mutated-descendant");
		Files.delete(listedDirectory.resolve("nested"));
		Files.delete(listedDirectory);
		run(repo, env, "git", "restore", ".secrets");

		var filterBeforeWorktree = run(repo, env, "git", "config", "--local", "--get", "filter.dotfiles-aes.clean").strip();
		var configBeforeWorktree = Files.readString(repo.resolve(".git/config"));
		var configTimeBeforeWorktree = Files.getLastModifiedTime(repo.resolve(".git/config"));
		var linkedHome = home.resolve("linked-home");
		var linked = linkedHome.resolve("repo");
		Files.createDirectories(linkedHome);
		run(repo, env, "git", "worktree", "add", "--quiet", "-b", "linked-test", linked.toString());
		var linkedEnv = new HashMap<>(linkEnv);
		linkedEnv.put("HOME", linkedHome.toString());
		linkedEnv.put("JAVA_TOOL_OPTIONS", "-Duser.home=" + linkedHome);
		bootAt(fixture, linkedEnv, "link", linked);
		bootAt(fixture, linkedEnv, "pre-commit", linked);
		boot(fixture, "link");
		boot(fixture, "pre-commit");
		var sharedFilter = run(repo, env, "git", "config", "--local", "--get", "filter.dotfiles-aes.clean").strip();
		var common = Path.of(run(repo, env, "git", "rev-parse", "--git-common-dir").strip());
		if (!common.isAbsolute()) common = repo.resolve(common).normalize();
		var expectedTool = "'" + common.resolve("dotfiles-aes.java").toAbsolutePath() + "'";
		var expectedKey = "'" + common.resolve("dotfiles-aes.key").toAbsolutePath() + "'";
		if (!sharedFilter.equals("java " + expectedTool + " encrypt --key-file " + expectedKey) || sharedFilter.contains(linked.toString())) fail("worktree-dependent-filter-command");
		if (!sharedFilter.equals(filterBeforeWorktree) || !Files.readString(repo.resolve(".git/config")).equals(configBeforeWorktree) || !Files.getLastModifiedTime(repo.resolve(".git/config")).equals(configTimeBeforeWorktree)) fail("worktree-rewrote-filter-config");
		run(repo, env, "git", "worktree", "remove", "--force", linked.toString());
		ok("link-hooks-and-manifest-validation");
	}

	static void cryptoRestoreChecks(Fixture fixture) throws Exception {
		var repo = fixture.repo();
		var env = fixture.env();
		var first = repo.resolve("secret.txt");
		var second = repo.resolve("incoming-secret.txt");
		var firstPlain = Files.readAllBytes(first);
		var secondPlain = Files.readAllBytes(second);
		run(repo, env, fixture.home().resolve("bin/test-aes").toString(), "encrypt", "--key-file", ".git/dotfiles-aes.key", "secret.txt", "encrypted-secret.txt");
		var encrypted = Files.readAllBytes(repo.resolve("encrypted-secret.txt"));
		Files.delete(repo.resolve("encrypted-secret.txt"));
		Files.write(first, encrypted);
		var index = Files.readAllBytes(repo.resolve(".git/index"));
		boot(fixture, "setup-crypto");
		if (!Arrays.equals(Files.readAllBytes(first), firstPlain)) fail("explicit-crypto-decryption");
		if (!Arrays.equals(Files.readAllBytes(repo.resolve(".git/index")), index)) fail("crypto-changed-index");
		var modified = Files.getLastModifiedTime(first);
		boot(fixture, "setup-crypto");
		if (!Files.getLastModifiedTime(first).equals(modified)) fail("crypto-rewrote-plaintext");

		for (var change : List.of("plaintext", "ciphertext", "deleted", "staged", "staged-deletion")) {
			Files.write(first, encrypted);
			Files.write(second, secondPlain);
			switch (change) {
				case "plaintext", "staged" -> Files.writeString(second, "local change\n");
				case "ciphertext" -> Files.write(second, encrypted);
				case "deleted" -> Files.delete(second);
				case "staged-deletion" -> run(repo, env, "git", "rm", "--cached", "--quiet", "incoming-secret.txt");
			}
			if (change.equals("staged")) run(repo, env, "git", "add", "incoming-secret.txt");
			var before = Files.exists(second) ? Files.readAllBytes(second) : null;
			index = Files.readAllBytes(repo.resolve(".git/index"));
			var refusal = restoreExpectFail(fixture);
			if (!refusal.contains("Refusing to restore locally changed secret")) fail("crypto-accepted:" + change);
			if (!Arrays.equals(Files.readAllBytes(first), encrypted)) fail("crypto-partial-restore:" + change);
			if (before == null ? Files.exists(second) : !Arrays.equals(Files.readAllBytes(second), before)) fail("crypto-overwrote:" + change);
			if (!Arrays.equals(Files.readAllBytes(repo.resolve(".git/index")), index)) fail("crypto-changed-index:" + change);
			run(repo, env, "git", "reset", "--quiet", "HEAD", "--", "incoming-secret.txt");
			ok("crypto-refuses:" + change);
		}
		var linkEnv = new HashMap<>(env);
		linkEnv.put("DOTFILES_SYMLINKS", "mini");
		boot(fixture, linkEnv, "link");
		if (!Arrays.equals(Files.readAllBytes(first), firstPlain)) fail("link-did-not-decrypt-secret");
		boot(fixture, linkEnv, "clean");
		Files.deleteIfExists(repo.resolve(".git/hooks/pre-commit"));
		Files.deleteIfExists(repo.resolve(".git/hooks/pre-push"));
		Files.write(first, firstPlain);
		Files.write(second, secondPlain);
		ok("explicit-crypto-preserves-local-changes");
	}

	interface Mapping { void accept(String source, String target) throws Exception; }
	static void forEachMapping(Path manifest, Mapping mapping) throws Exception {
		String source = null;
		for (var raw : Files.readAllLines(manifest)) {
			var line = raw.strip();
			if (line.isEmpty()) continue;
			if (source == null) source = line;
			else { mapping.accept(source, line); source = null; }
		}
		if (source != null) fail("missing target after " + source);
	}

	static Path targetPath(Path home, String target) {
		return target.startsWith("~/") ? home.resolve(target.substring(2)) : Path.of(target);
	}

	static Path findExecutable(String name) {
		var path = System.getenv("PATH");
		if (path == null) return null;
		for (var directory : path.split(java.util.regex.Pattern.quote(File.pathSeparator))) {
			var candidate = Path.of(directory, name);
			if (Files.isExecutable(candidate)) return candidate.toAbsolutePath();
		}
		return null;
	}

	static String rootless() throws IOException {
		var p = Path.of("/proc/self/uid_map");
		if (!Files.exists(p)) return "unknown";
		var parts = Files.readString(p).trim().split("\\s+");
		return parts.length >= 2 && parts[0].equals("0") && !parts[1].equals("0") ? "yes" : "unknown";
	}

	static void deleteTree(Path p) throws IOException {
		if (!Files.exists(p)) return;
		try (var s = Files.walk(p)) {
			for (var x : s.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(x);
		}
	}

	static void deleteTreeQuietly(Path p) {
		try {
			deleteTree(p);
		} catch (IOException e) {
			log("cleanup-failed:" + p + ":" + e.getMessage());
		}
	}

	/// Removes fixture homes left behind by runs that died before their `finally` block.
	static void sweepStaleFixtures() throws IOException {
		var home = Path.of(System.getProperty("user.home"));
		try (var entries = Files.list(home)) {
			for (var stale : entries.filter(entry -> Files.isRegularFile(entry.resolve(FIXTURE_MARKER))).toList()) {
				log("sweep:" + stale.getFileName());
				deleteTreeQuietly(stale);
			}
		}
	}

	static void step(String what) { log("step:" + what); }
	static void ok(String what) { log("ok:" + what); }
	static void log(String message) { System.out.println(message); System.out.flush(); }
	static void fail(String what) { throw new RuntimeException("FAIL:" + what); }

	static final class CommandFailed extends RuntimeException {
		CommandFailed(String message) { super(message); }
	}
	static final class CommandTimedOut extends RuntimeException {
		CommandTimedOut(String message) { super(message); }
	}

	static String run(Path dir, String... cmd) throws Exception { return run(dir, Map.of(), cmd); }
	static String run(Path dir, Map<String, String> env, String... cmd) throws Exception {
		return runInput(dir, env, null, cmd);
	}
	static String runInput(Path dir, Map<String, String> env, String input, String... cmd) throws Exception {
		var launch = new ArrayList<String>();
		if (SETSID != null) {
			launch.add(SETSID.toString());
			launch.add("--");
		}
		launch.addAll(List.of(cmd));
		var pb = new ProcessBuilder(launch);
		if (dir != null) pb.directory(dir.toFile());
		applyEnvironment(pb, env);
		var started = System.nanoTime();
		var p = pb.redirectErrorStream(true).start();
		var descendants = new HashSet<ProcessHandle>();
		if (SETSID != null) ACTIVE_PROCESS_GROUPS.add(p.pid());
		try {
			var output = java.util.concurrent.CompletableFuture.supplyAsync(() -> {
				try { return p.getInputStream().readAllBytes(); }
				catch (IOException problem) { throw new UncheckedIOException(problem); }
			});
			var stdin = java.util.concurrent.CompletableFuture.runAsync(() -> {
				try (var stream = p.getOutputStream()) {
					if (input != null) stream.write(input.getBytes());
				} catch (IOException problem) { throw new UncheckedIOException(problem); }
			});
			var deadline = System.nanoTime() + Duration.ofSeconds(90).toNanos();
			while (p.isAlive() && System.nanoTime() < deadline) {
				if (stdin.isCompletedExceptionally()) stdin.join();
				p.descendants().forEach(descendants::add);
				p.waitFor(10, java.util.concurrent.TimeUnit.MILLISECONDS);
			}
			p.descendants().forEach(descendants::add);
			if (p.isAlive()) throw new CommandTimedOut("timed out: " + String.join(" ", cmd));
			try {
				stdin.get(1, java.util.concurrent.TimeUnit.SECONDS);
			} catch (java.util.concurrent.TimeoutException problem) {
				throw new CommandTimedOut("timed out writing input: " + String.join(" ", cmd));
			}
			byte[] bytes;
			var drainTimedOut = false;
			try {
				bytes = output.get(1, java.util.concurrent.TimeUnit.SECONDS);
			} catch (java.util.concurrent.TimeoutException problem) {
				drainTimedOut = true;
				terminate(p, descendants);
				bytes = output.get(5, java.util.concurrent.TimeUnit.SECONDS);
			}
			var out = new String(bytes);
			var elapsed = Duration.ofNanos(System.nanoTime() - started).toMillis();
			if (elapsed >= 2_000) log("slow:" + Path.of(cmd[0]).getFileName() + ":" + elapsed + "ms");
			if (p.exitValue() != 0) throw new CommandFailed(String.join(" ", cmd) + "\n" + out);
			if (drainTimedOut) throw new CommandTimedOut("timed out draining output: " + String.join(" ", cmd));
			return out;
		} catch (Exception | Error problem) {
			terminate(p, descendants);
			throw problem;
		} finally {
			killGroup(p.pid());
			ACTIVE_PROCESS_GROUPS.remove(p.pid());
		}
	}
	static void applyEnvironment(ProcessBuilder builder, Map<String, String> environment) {
		if ("1".equals(environment.get(CLEAN_ENV))) builder.environment().clear();
		for (var entry : environment.entrySet())
			if (!entry.getKey().equals(CLEAN_ENV)) builder.environment().put(entry.getKey(), entry.getValue());
	}

	static void killGroup(long processGroup) {
		if (SETSID == null || KILL == null) return;
		try {
			new ProcessBuilder(KILL.toString(), "-KILL", "--", "-" + processGroup)
					.redirectOutput(ProcessBuilder.Redirect.DISCARD)
					.redirectError(ProcessBuilder.Redirect.DISCARD)
					.start().waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
		} catch (Exception ignored) {}
	}

	static void terminate(Process process, Collection<ProcessHandle> descendants) {
		process.descendants().forEach(descendants::add);
		killGroup(process.pid());
		for (var descendant : descendants) descendant.destroyForcibly();
		process.destroyForcibly();
	}

	static void runInherit(Path dir, String... cmd) throws Exception {
		var launch = new ArrayList<String>();
		if (SETSID != null) {
			launch.add(SETSID.toString());
			launch.add("--");
		}
		launch.addAll(List.of(cmd));
		var builder = new ProcessBuilder(launch);
		if (dir != null) builder.directory(dir.toFile());
		var process = builder.inheritIO().start();
		var descendants = new HashSet<ProcessHandle>();
		if (SETSID != null) ACTIVE_PROCESS_GROUPS.add(process.pid());
		try {
			if (!process.waitFor(10, java.util.concurrent.TimeUnit.MINUTES))
				throw new CommandTimedOut("timed out: " + String.join(" ", cmd));
			if (process.exitValue() != 0)
				throw new CommandFailed(String.join(" ", cmd) + " exited " + process.exitValue());
		} catch (Exception | Error problem) {
			terminate(process, descendants);
			throw problem;
		} finally {
			killGroup(process.pid());
			ACTIVE_PROCESS_GROUPS.remove(process.pid());
		}
	}

	static String runExpectFail(Path dir, Map<String, String> env, String... cmd) throws Exception {
		return runInputExpectFail(dir, env, null, cmd);
	}

	static String runInputExpectFail(Path dir, Map<String, String> env, String input, String... cmd) throws Exception {
		try {
			var out = runInput(dir, env, input, cmd);
			throw new AssertionError("expected failure: " + String.join(" ", cmd) + "\n" + out);
		} catch (CommandFailed error) {
			return error.getMessage();
		}
	}

	static void runAllowFail(Path dir, String... cmd) {
		try { run(dir, cmd); } catch (Exception ignored) {}
	}
}