repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
bootstripper.java
Rawimport 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<String> warnings = new ArrayList<>();
private final static List<String> 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<String> 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<String> read_secrets(Path root) throws IOException {
var path = root.resolve(secrets_file);
if (!Files.exists(path))
return List.of();
var secrets = new ArrayList<String>();
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<String> 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<String> 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<String>();
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<String>();
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<String> 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<String> 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<String> 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<String>();
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<String, byte[]>();
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<Path, byte[]>();
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<String> 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<String> 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<String> 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<String> secrets) throws Throwable {
var command = new ArrayList<String>(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<String> 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<String> 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<Path>();
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<String> command, byte[] input, boolean inherit_io) throws Throwable {
return run(directory, command, input, inherit_io, true);
}
static Result run(Path directory, List<String> 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<A, B> {
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<String> lines, ThrowUp<String, String> 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);
}
}
}