import static java.text.MessageFormat.format; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetAddress; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermissions; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.stream.Collectors; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; class bootstripper { private final static String operating_system = System.getProperty("os.name"); private final static boolean win32 = operating_system.toLowerCase().contains("win"); private final static String filter_name = "dotfiles-aes"; private final static String key_file = "dotfiles-aes.key"; private final static String aes_cache_file = "dotfiles-aes.java"; private final static String salt_file = ".dotfiles-aes-salt"; private final static String secrets_file = ".secrets"; private final static String attributes_begin = "# BEGIN bootstripper secrets"; private final static String attributes_end = "# END bootstripper secrets"; private final static String hook_marker = "# Managed by dorkfiles bootstripper.java"; private final static byte[] aes_magic = new byte[] { 'D', 'F', 'A', 'E', 'S', '1' }; private final static int salt_bytes = 32; private final static int derived_key_bits = 512; private final static int pbkdf2_iterations = 310_000; private final static List warnings = new ArrayList<>(); private final static List errors = new ArrayList<>(); public static void main(String[] arguments) throws Throwable { var command = "bootstrap"; var dorkfile_path_input = "."; if (arguments.length == 1) { if (is_command(arguments[0])) command = arguments[0]; else dorkfile_path_input = arguments[0]; } else if (arguments.length == 2) { if (!is_command(arguments[0])) { usage(); throw fail(); } command = arguments[0]; dorkfile_path_input = arguments[1]; } else if (arguments.length > 2) { usage(); throw fail(); } var root = Path.of(dorkfile_path_input).toRealPath(); if ("init-crypto".equals(command)) { init_crypto(root); return; } if ("check".equals(command)) { setup_crypto(root); install_repo_settings(root); check(root); return; } if ("pre-commit".equals(command)) { pre_commit(root); return; } if ("pre-push".equals(command)) { pre_push(root); return; } if ("install-hooks".equals(command)) { require_git_repo(root); install_hooks(root); return; } log("Assuming dorkfiles root repo in {0}.", emphasize_local(root.toString())); if ("clean".equals(command)) { var symlinks_file = symlinks_file(root); delete_symlinks(root, symlinks_file); return; } if ("link".equals(command)) { var symlinks_file = symlinks_file(root); check_symlink_sources(root); converge_crypto(root); install_repo_settings(root); install_hooks(root); create_symlinks(root, symlinks_file); print_attention(false); return; } if ("setup-crypto".equals(command)) { converge_crypto(root); return; } var symlinks_file = symlinks_file(root); check_symlink_sources(root); converge_crypto(root); install_repo_settings(root); install_hooks(root); check(root); create_symlinks(root, symlinks_file); print_attention(false); } static boolean is_command(String argument) { return "bootstrap".equals(argument) || "link".equals(argument) || "clean".equals(argument) || "check".equals(argument) || "pre-commit".equals(argument) || "pre-push".equals(argument) || "install-hooks".equals(argument) || "init-crypto".equals(argument) || "setup-crypto".equals(argument); } static void init_crypto(Path root) throws Throwable { require_git_repo(root); var salt = root.resolve(salt_file); if (Files.exists(salt)) { log("Crypto salt already exists: {0}", emphasize_local(salt_file)); return; } var bytes = new byte[salt_bytes]; new SecureRandom().nextBytes(bytes); Files.writeString(salt, Base64.getEncoder().encodeToString(bytes) + "\n", StandardCharsets.UTF_8); log("Created crypto salt: {0}", emphasize_local(salt_file)); } static List setup_crypto(Path root) throws Throwable { require_git_repo(root); var secrets = read_secrets(root); if (secrets.isEmpty()) return secrets; validate_secret_paths(root, secrets); install_aes_tool(root); ensure_key(root, secrets); install_filter(root); write_attributes(root, secrets); remove_local_attributes(root); apply_secret_permissions(root, secrets); return secrets; } static void converge_crypto(Path root) throws Throwable { var secrets = setup_crypto(root); if (!secrets.isEmpty()) restore_tracked_secrets(root, secrets); } static void require_git_repo(Path root) throws Throwable { run(root, List.of("git", "rev-parse", "--is-inside-work-tree"), null, false); } static Path git_common_path(Path root, String path) throws Throwable { var common = run(root, List.of("git", "rev-parse", "--git-common-dir"), null, false).text().strip(); var common_path = Path.of(common); if (!common_path.isAbsolute()) common_path = root.resolve(common_path).normalize(); return common_path.resolve(path).toAbsolutePath(); } static Path key_path(Path root) throws Throwable { return git_common_path(root, key_file); } static String key_argument(Path root) throws Throwable { return key_path(root).toString(); } static String aes_tool_argument(Path root) throws Throwable { return git_common_path(root, aes_cache_file).toString(); } static String clean_filter_command(Path root) throws Throwable { return "java " + quote_shell(aes_tool_argument(root)) + " encrypt --key-file " + quote_shell(key_argument(root)); } static String smudge_filter_command(Path root) throws Throwable { return "java " + quote_shell(aes_tool_argument(root)) + " decrypt --key-file " + quote_shell(key_argument(root)); } static String merge_filter_command(Path root) throws Throwable { return "java " + quote_shell(aes_tool_argument(root)) + " merge --key-file " + quote_shell(key_argument(root)) + " " + quote_shell("%O") + " " + quote_shell("%A") + " " + quote_shell("%B"); } static List read_secrets(Path root) throws IOException { var path = root.resolve(secrets_file); if (!Files.exists(path)) return List.of(); var secrets = new ArrayList(); for (var raw : Files.readAllLines(path)) { var line = raw.strip(); if (line.isEmpty() || line.startsWith("#")) continue; if (line.contains(" ") || line.contains("\t") || line.startsWith("/") || line.contains("\\") || line.startsWith(":") || line.contains("*") || line.contains("?") || line.contains("[")) throw new IllegalArgumentException("invalid secret path: " + line); secrets.add(line); } return secrets; } static void install_aes_tool(Path root) throws Throwable { var source = root.resolve("tools/aes.java"); if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) throw fail("Expected a regular AES tool source at: {0}", source); var target = git_common_path(root, aes_cache_file); if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) throw fail("Refusing to replace non-file cached AES tool: {0}", target); if (Files.mismatch(source, target) == -1L) return; } Files.createDirectories(target.getParent()); var temporary = Files.createTempFile(target.getParent(), "." + aes_cache_file + ".", ".tmp"); try { Files.copy(source, temporary, StandardCopyOption.REPLACE_EXISTING); try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } } finally { Files.deleteIfExists(temporary); } } static void validate_secret_paths(Path root, List secrets) throws Throwable { for (var secret : secrets) { var original = Path.of(secret); for (var component : original) if (component.toString().equals("..")) throw fail("Secret path must not contain parent components: {0}", secret); var relative = original.normalize(); if (relative.isAbsolute() || relative.startsWith("..")) throw fail("Secret path must stay inside the repository: {0}", secret); var current = root; for (var component : relative) { current = current.resolve(component); if (Files.isSymbolicLink(current)) throw fail("Secret path must not contain symlinks: {0}", secret); } if (Files.exists(current, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS)) throw fail("Secret path must name a regular file: {0}", secret); } } static void install_filter(Path root) throws Throwable { set_local_config(root, "filter." + filter_name + ".clean", clean_filter_command(root)); set_local_config(root, "filter." + filter_name + ".smudge", smudge_filter_command(root)); set_local_config(root, "filter." + filter_name + ".required", "true"); set_local_config(root, "merge." + filter_name + ".driver", merge_filter_command(root)); } // Autostash is a trap in this repository: the worktree is nearly always dirty, so // every rebase or merge stashes first, and the required crypto filter plus the Git // hooks can abort the operation before the stash is restored. The changes then sit // in a silent `autostash` stash entry while the files look deleted. Refuse instead. static void install_repo_settings(Path root) throws Throwable { set_local_config(root, "rebase.autostash", "false"); set_local_config(root, "merge.autostash", "false"); } static void check_repo_settings(Path root) throws Throwable { check_local_config(root, "rebase.autostash", "false"); check_local_config(root, "merge.autostash", "false"); } static void check_local_config(Path root, String name, String value) throws Throwable { var current = run(root, List.of("git", "config", "--local", "--get", name), null, false, false); if (current.exit != 0 || !current.text().strip().equals(value)) throw fail("Local Git setting {0} must be {1}. Run `mise run link`.", name, value); } static void set_local_config(Path root, String name, String value) throws Throwable { var current = run(root, List.of("git", "config", "--local", "--get", name), null, false, false); if (current.exit != 0 || !current.text().strip().equals(value)) run(root, List.of("git", "config", "--local", name, value), null, false); } static String quote_shell(String argument) { return "'" + argument.replace("'", "'\\''") + "'"; } static void write_attributes(Path root, List secrets) throws IOException { var attributes = root.resolve(".gitattributes"); var content = Files.exists(attributes) ? without_managed_attributes(Files.readString(attributes)) : ""; if (!content.isEmpty() && !content.endsWith("\n")) content += "\n"; var lines = new ArrayList(); lines.add(attributes_begin); for (var secret : secrets) lines.add(secret + " -text filter=" + filter_name + " -diff merge=" + filter_name); lines.add(attributes_end); var desired = content + String.join("\n", lines) + "\n"; if (!Files.exists(attributes) || !Files.readString(attributes).equals(desired)) Files.writeString(attributes, desired, StandardCharsets.UTF_8); } static void remove_local_attributes(Path root) throws Throwable { var attributes = git_common_path(root, "info/attributes"); if (!Files.exists(attributes)) return; var previous = Files.readString(attributes); var content = without_managed_attributes(previous); if (!content.equals(previous)) Files.writeString(attributes, content, StandardCharsets.UTF_8); } static String without_managed_attributes(String content) { var lines = new ArrayList(); var in_block = false; for (var line : content.split("\\R", -1)) { if (attributes_begin.equals(line)) { in_block = true; continue; } if (attributes_end.equals(line)) { in_block = false; continue; } if (!in_block) lines.add(line); } return String.join("\n", lines); } static void ensure_key(Path root, List secrets) throws Throwable { var key = key_path(root); if (Files.exists(key, LinkOption.NOFOLLOW_LINKS)) { validate_key(key); ensure_permissions(key, "rw-------"); return; } Files.createDirectories(key.getParent()); var temporary = Files.createTempFile(key.getParent(), key_file + ".", ".tmp"); char[] passphrase = null; try { passphrase = passphrase(); var factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); var spec = new PBEKeySpec(passphrase, read_salt(root), pbkdf2_iterations, derived_key_bits); var encoded = factory.generateSecret(spec).getEncoded(); Files.writeString(temporary, Base64.getEncoder().encodeToString(encoded) + "\n", StandardCharsets.UTF_8); ensure_permissions(temporary, "rw-------"); if (!authenticate_key(root, secrets, temporary)) self_test_aes(root, temporary); try { Files.move(temporary, key, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, key); } log("Cached dotfiles AES key in {0}", emphasize_local(key.toString())); } finally { if (passphrase != null) Arrays.fill(passphrase, '\0'); Files.deleteIfExists(temporary); } } static void validate_key(Path key) throws IOException { if (!Files.isRegularFile(key, LinkOption.NOFOLLOW_LINKS) || !Files.isReadable(key)) throw new IllegalStateException("Dotfiles AES key must be a readable regular file: " + key); try { if (Base64.getDecoder().decode(Files.readString(key).trim()).length != derived_key_bits / 8) throw new IllegalStateException("Dotfiles AES key has the wrong length: " + key); } catch (IllegalArgumentException problem) { throw new IllegalStateException("Dotfiles AES key is not valid Base64: " + key); } } static boolean authenticate_key(Path root, List secrets, Path key) throws Throwable { String encrypted_secret = null; for (var secret : secrets) { var committed = run(root, List.of("git", "cat-file", "blob", "HEAD:" + secret), null, false, false); if (committed.exit != 0 || !starts_with(committed.stdout, aes_magic)) continue; encrypted_secret = secret; var decrypted = run(root, List.of("java", aes_tool_argument(root), "decrypt", "--key-file", key.toString()), committed.stdout, false, false); if (decrypted.exit == 0) return true; } if (encrypted_secret != null) throw new IllegalStateException("Passphrase does not decrypt committed secrets, including: " + encrypted_secret); return false; } static byte[] read_salt(Path root) throws IOException { var path = root.resolve(salt_file); if (!Files.exists(path)) throw new IllegalStateException("Missing " + salt_file + ". Run `java bootstripper.java init-crypto` once for this repo."); return Base64.getDecoder().decode(Files.readString(path).trim()); } static char[] passphrase() { var from_env = System.getenv("DOTFILES_AES_PASSPHRASE"); if (from_env != null && !from_env.isEmpty()) return from_env.toCharArray(); var console = System.console(); if (console == null) throw new IllegalStateException("No console available. Set DOTFILES_AES_PASSPHRASE for non-interactive bootstrap."); var first = console.readPassword("[%s]:", emphasize_global("Dotfiles AES passphrase")); var second = console.readPassword("[%s]:", emphasize_global("Repeat passphrase")); if (!Arrays.equals(first, second)) throw new IllegalStateException("Passphrases do not match."); Arrays.fill(second, '\0'); return first; } static void self_test_aes(Path root, Path key) throws Throwable { var plain = "bootstripper self-test".getBytes(StandardCharsets.UTF_8); var aes = aes_tool_argument(root); var encrypted = run(root, List.of("java", aes, "encrypt", "--key-file", key.toString()), plain, false, false); if (encrypted.exit != 0 || !starts_with(encrypted.stdout, aes_magic)) throw new IllegalStateException("AES self-test encryption failed: " + encrypted.error().strip()); var decrypted = run(root, List.of("java", aes, "decrypt", "--key-file", key.toString()), encrypted.stdout, false, false); if (decrypted.exit != 0 || !Arrays.equals(plain, decrypted.stdout)) throw new IllegalStateException("AES self-test decryption failed: " + decrypted.error().strip()); } static void restore_tracked_secrets(Path root, List secrets) throws Throwable { var tree_command = new ArrayList<>(List.of("git", "--literal-pathspecs", "ls-tree", "--name-only", "-z", "HEAD", "--")); tree_command.addAll(secrets); var tracked_output = run(root, tree_command, null, false, false); if (tracked_output.exit != 0) return; var tracked = new LinkedHashSet(); for (var path : new String(tracked_output.stdout, StandardCharsets.UTF_8).split("\u0000")) if (!path.isEmpty()) tracked.add(path); if (tracked.isEmpty()) return; var staged_command = new ArrayList<>(List.of("git", "--literal-pathspecs", "diff", "--cached", "--quiet", "HEAD", "--")); staged_command.addAll(tracked); if (run(root, staged_command, null, false, false).exit != 0) { for (var secret : tracked) if (run(root, List.of("git", "--literal-pathspecs", "diff", "--cached", "--quiet", "HEAD", "--", secret), null, false, false).exit != 0) throw fail("Refusing to restore locally changed secret: {0}", secret); } var current = new LinkedHashMap(); var needs_restoration = false; for (var secret : tracked) { var path = root.resolve(secret); if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) throw fail("Refusing to restore locally changed secret: {0}", secret); var bytes = Files.readAllBytes(path); current.put(secret, bytes); needs_restoration |= starts_with(bytes, aes_magic); } if (!needs_restoration) return; var pending = new LinkedHashMap(); for (var secret : tracked) { var committed = run(root, List.of("git", "cat-file", "blob", "HEAD:" + secret), null, false); var plain = run(root, List.of("java", aes_tool_argument(root), "decrypt", "--key-file", key_argument(root)), committed.stdout, false).stdout; var bytes = current.get(secret); if (Arrays.equals(bytes, committed.stdout)) pending.put(root.resolve(secret), plain); else if (!Arrays.equals(bytes, plain)) throw fail("Refusing to restore locally changed secret: {0}", secret); } for (var entry : pending.entrySet()) Files.write(entry.getKey(), entry.getValue()); } static void apply_secret_permissions(Path root, List secrets) throws IOException { for (var secret : secrets) { var path = root.resolve(secret); if (Files.exists(path) && secret.startsWith("ssh/") && secret.endsWith(".key")) ensure_permissions(path, "rw-------"); } } static void install_hooks(Path root) throws Throwable { var hooks = git_hooks_path(root); var pre_commit = hooks.resolve("pre-commit"); var pre_push = hooks.resolve("pre-push"); var legacy_pre_commit = "#!/bin/sh\ncd \"$(git rev-parse --show-toplevel)\" || exit 1\nexec java bootstripper.java pre-commit\n"; var pre_commit_content = hook_content("pre-commit"); var pre_push_content = hook_content("pre-push"); check_hook_destination(pre_commit, pre_commit_content, legacy_pre_commit); check_hook_destination(pre_push, pre_push_content, null); install_hook(hooks, pre_commit, pre_commit_content); install_hook(hooks, pre_push, pre_push_content); } static Path git_hooks_path(Path root) throws Throwable { var configured = run(root, List.of("git", "config", "--show-scope", "--get", "core.hooksPath"), null, false, false); if (configured.exit == 0 && !configured.text().matches("(?s)^(local|worktree)\\s+.*")) throw fail("Refusing to modify inherited core.hooksPath. Configure a repository-local hooks path or unset it."); var result = run(root, List.of("git", "rev-parse", "--path-format=absolute", "--git-path", "hooks"), null, false).text().strip(); var hooks = Path.of(result).toAbsolutePath().normalize(); if (hooks.equals(Path.of("/dev/null")) || Files.exists(hooks, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(hooks, LinkOption.NOFOLLOW_LINKS)) throw fail("Invalid Git hooks directory: {0}", hooks); return hooks; } static String hook_content(String command) { return "#!/bin/sh\n" + hook_marker + "\ncd \"$(git rev-parse --show-toplevel)\" || exit 1\nexec java bootstripper.java " + command + "\n"; } static void check_hook_destination(Path hook, String expected, String legacy) throws Throwable { if (!Files.exists(hook, LinkOption.NOFOLLOW_LINKS)) return; if (Files.isSymbolicLink(hook) || !Files.isRegularFile(hook, LinkOption.NOFOLLOW_LINKS)) throw fail("Refusing to replace non-file Git hook: {0}", hook); var content = Files.readString(hook); if (content.equals(expected) || content.equals(legacy) || content.contains("\n" + hook_marker + "\n")) return; throw fail("Refusing to replace unmanaged Git hook: {0}", hook); } static void install_hook(Path hooks, Path hook, String content) throws Throwable { var content_matches = Files.isRegularFile(hook, LinkOption.NOFOLLOW_LINKS) && Files.readString(hook).equals(content); var permissions_match = hook_permissions_match(hook); if (content_matches && permissions_match) return; Files.createDirectories(hooks); if (!content_matches) { var temporary = Files.createTempFile(hooks, "." + hook.getFileName() + ".", ".tmp"); try { Files.writeString(temporary, content, StandardCharsets.UTF_8); chmod(temporary, "rwxr-xr-x"); try { Files.move(temporary, hook, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException ignored) { Files.move(temporary, hook, StandardCopyOption.REPLACE_EXISTING); } } finally { Files.deleteIfExists(temporary); } } else { chmod(hook, "rwxr-xr-x"); } log("Installed Git hook: {0}", emphasize_local(hook.toString())); } static boolean hook_permissions_match(Path hook) throws IOException { if (win32) return true; try { return Files.exists(hook) && Files.getPosixFilePermissions(hook).equals(PosixFilePermissions.fromString("rwxr-xr-x")); } catch (UnsupportedOperationException ignored) { return Files.isExecutable(hook); } } static void pre_commit(Path root) throws Throwable { run_gitleaks(root, List.of("--pre-commit", "--staged")); check_staged_matches_worktree(root, secrets_file); check_staged_matches_worktree(root, ".gitattributes"); check(root); } static void pre_push(Path root) throws Throwable { for (var line : new String(System.in.readAllBytes(), StandardCharsets.UTF_8).lines().toList()) { if (line.isBlank()) continue; var fields = line.strip().split("\\s+"); if (fields.length != 4) throw fail("Invalid pre-push input: expected four fields."); var local_oid = fields[1]; var remote_oid = fields[3]; if (is_zero_oid(local_oid)) continue; if (!is_oid(local_oid) || !is_oid(remote_oid)) throw fail("Invalid object ID in pre-push input."); var local_commit = resolve_commit(root, local_oid, "local"); var remote_commit = run(root, List.of("git", "rev-parse", "--verify", remote_oid + "^{commit}"), null, false, false); var revisions = is_zero_oid(remote_oid) || remote_commit.exit != 0 ? local_commit : remote_commit.text().strip() + ".." + local_commit; var log_options = "--full-history --diff-merges=first-parent --no-ext-diff --no-textconv " + revisions; run_gitleaks(root, List.of("--platform=none", "--log-opts=" + log_options)); } } static boolean is_oid(String value) { return value.matches("[0-9a-fA-F]{40}|[0-9a-fA-F]{64}"); } static boolean is_zero_oid(String value) { return is_oid(value) && value.chars().allMatch(character -> character == '0'); } static String resolve_commit(Path root, String oid, String label) throws Throwable { var result = run(root, List.of("git", "rev-parse", "--verify", oid + "^{commit}"), null, false, false); if (result.exit != 0) throw fail("Pre-push {0} object is not a commit: {1}", label, oid); return result.text().strip(); } static void run_gitleaks(Path root, List scan_arguments) throws Throwable { var command = new ArrayList<>(List.of("gitleaks", "git")); command.addAll(scan_arguments); command.addAll(List.of("--redact=100", "--no-banner")); Result result; try { result = run(root, command, null, true, false); } catch (IOException error) { throw fail("Gitleaks is required for Git hooks. Install Gitleaks, ensure it is on Git''s PATH, then retry."); } if (result.exit != 0) throw fail("Gitleaks blocked the operation (exit {0}). Review its redacted output.", result.exit); } static void check_staged_matches_worktree(Path root, String path) throws Throwable { var tracked = run(root, List.of("git", "ls-files", "--error-unmatch", "--", path), null, false, false); var changed = run(root, List.of("git", "-c", "diff.autoRefreshIndex=false", "diff", "--no-ext-diff", "--no-textconv", "--quiet", "--", path), null, false, false); if (tracked.exit != 0 || changed.exit != 0) throw fail("{0} must match the staged version.", path); } static void check(Path root) throws Throwable { var secrets = read_secrets(root); if (!secrets.isEmpty()) { if (!Files.isReadable(key_path(root))) throw fail("Dotfiles AES key is unavailable. Run `mise run setup-crypto`, then retry."); check_filter(root); check_attributes(root, secrets); check_secrets_not_ignored(root, secrets); check_worktree_secrets_decrypted(root, secrets); check_key_not_tracked(root); check_staged_secrets(root, secrets); } check_repo_settings(root); check_symlink_sources(root); log("OK bootstripper check passed."); } static void check_filter(Path root) throws Throwable { var clean = run(root, List.of("git", "config", "--local", "--get", "filter." + filter_name + ".clean"), null, false).text().strip(); var smudge = run(root, List.of("git", "config", "--local", "--get", "filter." + filter_name + ".smudge"), null, false).text().strip(); var required = run(root, List.of("git", "config", "--local", "--get", "filter." + filter_name + ".required"), null, false).text().strip(); var merge = run(root, List.of("git", "config", "--local", "--get", "merge." + filter_name + ".driver"), null, false).text().strip(); if (!clean.equals(clean_filter_command(root))) throw fail("Unexpected clean filter: {0}", clean); if (!smudge.equals(smudge_filter_command(root))) throw fail("Unexpected smudge filter: {0}", smudge); if (!required.equals("true")) throw fail("Filter is not required."); if (!merge.equals(merge_filter_command(root))) throw fail("Unexpected merge driver: {0}", merge); } static void check_attributes(Path root, List secrets) throws Throwable { for (var secret : secrets) { var output = run(root, List.of("git", "--literal-pathspecs", "check-attr", "text", "filter", "diff", "merge", "--", secret), null, false).text(); if (!output.contains(secret + ": text: unset")) throw fail("Missing -text attribute for {0}: {1}", secret, output.strip()); if (!output.contains(secret + ": filter: " + filter_name)) throw fail("Missing filter attribute for {0}: {1}", secret, output.strip()); if (!output.contains(secret + ": diff: unset")) throw fail("Missing -diff attribute for {0}: {1}", secret, output.strip()); if (!output.contains(secret + ": merge: " + filter_name)) throw fail("Missing merge attribute for {0}: {1}", secret, output.strip()); } } static void check_secrets_not_ignored(Path root, List secrets) throws Throwable { var command = new ArrayList(List.of("git", "check-ignore", "--no-index", "--verbose", "--")); command.addAll(secrets); var result = run(root, command, null, false, false); if (result.exit == 0) throw fail("Secret paths are matched by ignore rules, so they can never be encrypted or staged: {0}", result.text().strip()); if (result.exit != 1) throw fail("Unable to check ignore rules for secrets: {0}", result.text().strip()); } static void check_worktree_secrets_decrypted(Path root, List secrets) throws Throwable { for (var secret : secrets) { var path = root.resolve(secret); if (Files.exists(path) && starts_with(Files.readAllBytes(path), aes_magic)) throw fail("Worktree secret is encrypted; smudge failed: {0}", secret); } } static void check_key_not_tracked(Path root) throws Throwable { var bad = run(root, List.of("git", "ls-files", "--", ".git/" + key_file, key_file), null, false).text().strip(); if (!bad.isEmpty()) throw fail("Key file is tracked: {0}", key_file); var staged = run(root, List.of("git", "diff", "--cached", "--name-only", "--", ".git/" + key_file, key_file), null, false).text().strip(); if (!staged.isEmpty()) throw fail("Key file is staged: {0}", key_file); } static void check_staged_secrets(Path root, List secrets) throws Throwable { for (var secret : secrets) { var status = run(root, List.of("git", "--literal-pathspecs", "diff", "--cached", "--name-status", "--", secret), null, false).text().strip(); if (status.isEmpty() || status.startsWith("D")) continue; var blob = run(root, List.of("git", "show", ":" + secret), null, false).stdout; if (!starts_with(blob, aes_magic)) throw fail("Staged secret is not encrypted: {0}", secret); run(root, List.of("java", aes_tool_argument(root), "decrypt", "--key-file", key_argument(root)), blob, false); } } static void check_symlink_sources(Path root) throws Throwable { try (var stream = Files.list(root)) { var files = stream.filter(path -> path.getFileName().toString().endsWith(".symlinks")).collect(Collectors.toList()); for (var file : files) { var targets = new HashSet(); var lines = Files.readAllLines(file); String source = null; var source_line = 0; var line_number = 0; for (var raw : lines) { line_number++; var line = raw.strip(); if (line.isEmpty()) continue; if (source == null) { source = line; source_line = line_number; continue; } var source_path = root.resolve(source); if (!Files.exists(source_path, LinkOption.NOFOLLOW_LINKS)) throw fail("Missing symlink source: {0}:{1}: {2}", file.getFileName(), source_line, source); var target_path = expand_target(line).normalize(); if (!targets.add(target_path)) throw fail("Duplicate symlink target: {0}:{1}: {2}", file.getFileName(), line_number, line); source = null; } if (source != null) throw fail("Missing symlink target after: {0}: {1}", file.getFileName(), source); } } } static Path symlinks_file(Path root) throws Throwable { var hostname = hostname(); var manifest = symlinks_manifest(root, hostname); log("Hostname: {0}", emphasize_global(hostname)); log("Symlinks manifest: {0}", emphasize_global(manifest + ".symlinks")); var symlinks_file = root.resolve(manifest + ".symlinks"); if (!Files.exists(symlinks_file)) throw fail("Expected a file containing symlinks at: {0}. Found nothing!", symlinks_file); return symlinks_file; } static String symlinks_manifest(Path root, String hostname) throws IOException { var explicit = System.getenv("DOTFILES_SYMLINKS"); if (explicit != null && !explicit.isBlank()) return explicit.replaceFirst("\\.symlinks$", ""); return hostname; } static void create_symlinks(Path root, Path symlinks_file) throws Throwable { log("Creating symlinks as defined in {0} file.", emphasize_local(symlinks_file.getFileName().toString())); var lines = Files.readAllLines(symlinks_file); for_each_mapping(lines, (source, target) -> link(root, source, target)); } static void delete_symlinks(Path root, Path symlinks_file) throws Throwable { log("Deleting symlinks as defined in {0} file.", emphasize_local(symlinks_file.getFileName().toString())); var lines = Files.readAllLines(symlinks_file); for_each_mapping(lines, (source, target) -> delete(root, source, target)); } static void delete(Path root, String source, String target) throws Throwable { var source_path = root.resolve(source).toAbsolutePath(); var target_path = expand_target(target); if (Files.exists(target_path, LinkOption.NOFOLLOW_LINKS)) { if (Files.isSymbolicLink(target_path)) { log("\nOK {0} exists and points to {1}, a symlink indeed!\nDeleting {0}.", emphasize_global(target_path.toString()), emphasize_local(source_path.toString())); Files.delete(target_path); } else { log("\nNOPE {0} exists, but it is not a symlink! Clean manually.", target_path.toString()); } } } static void link(Path root, String source, String target) throws Throwable { var source_path = root.resolve(source).toAbsolutePath().normalize(); if (!Files.exists(source_path)) { log("\n{0} does not exist anymore in my dorkfiles. Have I yeeted it?", emphasize_global(source_path.toString())); return; } var expected = source_path.toRealPath(); require_inside_home(expected, "Symlink source"); var target_path = expand_target(target); if (Files.exists(target_path, LinkOption.NOFOLLOW_LINKS)) { if (Files.isSymbolicLink(target_path)) { var raw_actual = symlink_target_path(target_path); if (!inside_home(raw_actual)) throw fail("Target symlink points outside HOME: {0} -> {1}. Move it manually.", target_path, raw_actual); if (!Files.exists(target_path)) { log("\nRELINK {0}: broken symlink.", emphasize_global(target_path.toString())); remove_owned_symlink(target_path); } else { var actual = target_path.toRealPath(); if (actual.equals(expected)) { log("\nOK {0} -> {1}.", emphasize_global(target_path.toString()), emphasize_local(source_path.toString())); return; } if (!inside_home(actual)) throw fail("Target symlink points outside HOME: {0} -> {1}. Move it manually.", target_path, actual); else { if (!actual.startsWith(root.toRealPath())) throw fail("Target symlink points outside dorkfiles: {0} -> {1}. Move it manually.", target_path, actual); log("\nRELINK {0}: {1} -> {2}.", emphasize_global(target_path.toString()), emphasize_local(actual.toString()), emphasize_local(expected.toString())); remove_owned_symlink(target_path); } } } else if (Files.exists(target_path)) { var actual = target_path.toRealPath(); if (actual.equals(expected)) { log("\nOK {0} resolves to {1}.", emphasize_global(target_path.toString()), emphasize_local(source_path.toString())); return; } var backup = sibling_backup_path(target_path); var message = format("BACKUP {0} TO {1}.", target_path, backup); log("\n{0}", message); warn(message); Files.move(target_path, backup); } else { log("\nNOPE {0} exists, but it points into nirvana. Removing broken link!", target_path.toString()); Files.delete(target_path); } } log("\nLINK {0} TO {1}.", emphasize_local(source_path.toString()), emphasize_local(target_path.toString())); Files.createDirectories(target_path.getParent()); if (target_path.getParent().endsWith(".ssh")) chmod(target_path.getParent(), "rwx------"); Files.createSymbolicLink(target_path, source_path); } static void remove_owned_symlink(Path target_path) throws Throwable { if (!win32) { Files.delete(target_path); return; } var retired = sibling_path(target_path, "replaced"); Files.move(target_path, retired); try { Files.delete(retired); } catch (IOException error) { log("WIN32 kept retired link for later cleanup: {0}", retired); } } static Path symlink_target_path(Path target_path) throws IOException { var link = Files.readSymbolicLink(target_path); var resolved = link.isAbsolute() ? link : target_path.getParent().resolve(link); return resolved.toAbsolutePath().normalize(); } static Path sibling_backup_path(Path target_path) throws Throwable { return sibling_path(target_path, "backup"); } static Path sibling_path(Path target_path, String kind) throws Throwable { var stamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").format(LocalDateTime.now()); var base = target_path.resolveSibling(target_path.getFileName() + ".dorkfiles-" + kind + "-" + stamp); var candidate = base; var suffix = 1; while (Files.exists(candidate, LinkOption.NOFOLLOW_LINKS)) { candidate = Path.of(base + "." + suffix); suffix++; } return candidate; } static Path expand_target(String target) throws Throwable { var path = switch (target) { case "~" -> target_home_path(); default -> target.startsWith("~/") ? target_home_path().resolve(target.substring(2)) : Path.of(target).toAbsolutePath(); }; path = path.normalize(); require_inside_home(path, "Symlink target"); return path; } static Path target_home_path() { return Path.of(System.getProperty("user.home")).toAbsolutePath().normalize(); } static Path home_path() { var home = System.getenv("HOME"); if (home == null || home.isBlank()) home = System.getProperty("user.home"); return Path.of(home).toAbsolutePath().normalize(); } static boolean inside_home(Path path) { return path.toAbsolutePath().normalize().startsWith(home_path()); } static void require_inside_home(Path path, String label) throws Throwable { if (!inside_home(path)) throw fail("{0} outside HOME: {1}", label, path.toAbsolutePath().normalize()); } static String hostname() throws Exception { var computername = System.getenv("COMPUTERNAME"); if (computername != null && !computername.isBlank()) return computername; var wsl_distro = System.getenv("WSL_DISTRO_NAME"); if (wsl_distro != null && !wsl_distro.isBlank()) return wsl_distro; var hostname = System.getenv("HOSTNAME"); if (hostname != null && !hostname.isBlank() && !"localhost".equals(hostname)) return hostname; var detected = InetAddress.getLocalHost().getHostName(); if ("localhost".equals(detected) && System.getenv("PREFIX") != null) return "termux"; return detected; } static void ensure_permissions(Path path, String permissions) throws IOException { if (win32) return; try { var desired = PosixFilePermissions.fromString(permissions); if (!Files.getPosixFilePermissions(path).equals(desired)) Files.setPosixFilePermissions(path, desired); } catch (UnsupportedOperationException ignored) {} } static void chmod(Path path, String permissions) throws IOException { if (win32) return; try { Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(permissions)); } catch (UnsupportedOperationException ignored) { } } static boolean starts_with(byte[] bytes, byte[] prefix) { if (bytes.length < prefix.length) return false; for (var index = 0; index < prefix.length; index++) if (bytes[index] != prefix[index]) return false; return true; } static Result run(Path directory, List command, byte[] input, boolean inherit_io) throws Throwable { return run(directory, command, input, inherit_io, true); } static Result run(Path directory, List command, byte[] input, boolean inherit_io, boolean fail) throws Throwable { var process_builder = new ProcessBuilder(command).directory(directory.toFile()); if (inherit_io) process_builder.inheritIO(); var process = process_builder.start(); if (input != null) { try (var stdin = process.getOutputStream()) { stdin.write(input); } } else { process.getOutputStream().close(); } var stdout = inherit_io ? new byte[0] : process.getInputStream().readAllBytes(); var stderr = inherit_io ? new byte[0] : process.getErrorStream().readAllBytes(); var exit = process.waitFor(); var result = new Result(exit, stdout, stderr); if (fail && exit != 0) throw fail("Command failed ({0}): {1}\n{2}", exit, String.join(" ", command), result.error().strip()); return result; } static void usage() { log("Bootstraps bugabingas {0}.", emphasize_local("dorkfiles")); log("{0}: java bootstripper.java [bootstrap|link|init-crypto|setup-crypto|check|pre-commit|pre-push|install-hooks|clean] [dotfiles root path]", emphasize_global("Usage")); } static String emphasize_global(String message) { return "\033[4m" + message + "\033[0m"; } static String emphasize_local(String message) { return "\033[3m" + message + "\033[0m"; } static void warn(String message) { warnings.add(message); } static void error(String message) { errors.add(message); } static void print_attention(boolean fatal) { if (warnings.isEmpty() && errors.isEmpty()) return; var color = fatal || !errors.isEmpty() ? "\033[31m" : "\033[33m"; System.out.println(); System.out.println(color + "ATTENTION"); for (var warning : warnings) System.out.println("- " + warning); for (var error : errors) System.out.println("- " + error); System.out.println("\033[0m"); } static Throwable fail(String message, Object... arguments) { error(format(message, arguments)); return fail(); } static Throwable fail() { print_attention(true); System.exit(-1); return new Throwable("use 'throw fail()' for control flow"); } static void log(String message, Object... arguments) { System.out.println(format(message, arguments)); } static interface ThrowUp { default Throwable gurgh(A a, B b) { try { apply(a, b); return null; } catch (Throwable vomit) { return vomit; } } void apply(A a, B b) throws Throwable; } static void for_each_mapping(List lines, ThrowUp block) throws Throwable { String source = null; String target = null; for (String line : lines) { if (line.isBlank()) continue; if (source == null) source = line; else target = line; if (target != null) { var vomit = block.gurgh(source, target); if (vomit != null) throw vomit; source = null; target = null; } } } static class Result { final int exit; final byte[] stdout; final byte[] stderr; Result(int exit, byte[] stdout, byte[] stderr) { this.exit = exit; this.stdout = stdout; this.stderr = stderr; } String text() { return new String(stdout, StandardCharsets.UTF_8); } String error() { return new String(stderr, StandardCharsets.UTF_8); } } }