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 ACTIVE_PROCESS_GROUPS = java.util.concurrent.ConcurrentHashMap.newKeySet(); static final Set ACTIVE_FIXTURES = java.util.concurrent.ConcurrentHashMap.newKeySet(); static final String FIXTURE_MARKER = ".dotfiles-test-fixture"; static final Set 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 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(); 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 allHosts() throws IOException { var hosts = new ArrayList(); 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 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(); 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 env, String command) throws Exception { return bootAt(fixture, env, command, fixture.repo()); } static String bootAt(Fixture fixture, Map 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 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 env, String... cmd) throws Exception { return runInput(dir, env, null, cmd); } static String runInput(Path dir, Map env, String input, String... cmd) throws Exception { var launch = new ArrayList(); 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(); 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 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 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(); 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(); 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 env, String... cmd) throws Exception { return runInputExpectFail(dir, env, null, cmd); } static String runInputExpectFail(Path dir, Map 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) {} } }