repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
scripts/dotfiles_integration_test.java
Rawimport 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) {}
}
}