diff --git a/README.md b/README.md index d6f42ee..3875ff0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 424](https://img.shields.io/badge/tests-424-brightgreen?style=flat)](test/) +[![tests: 431](https://img.shields.io/badge/tests-431-brightgreen?style=flat)](test/) ![lints: 8](https://img.shields.io/badge/lints-8-blue?style=flat) [![license: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) diff --git a/hooks/encryption b/hooks/encryption.template old mode 100755 new mode 100644 similarity index 51% rename from hooks/encryption rename to hooks/encryption.template index 7a3164b..fdc3534 --- a/hooks/encryption +++ b/hooks/encryption.template @@ -7,51 +7,46 @@ # commit. See KnickKnackLabs/notes#49. set -eo pipefail +NOTES_TOOL_ROOT="__NOTES_TOOL_ROOT__" +source "$NOTES_TOOL_ROOT/lib/encryption.sh" + if ! command -v git-crypt &>/dev/null; then echo "WARNING: git-crypt not found — skipping encryption check" >&2 exit 0 fi +staged_input=$(mktemp) || { + echo "ERROR: Failed to create encryption hook staged-path input" >&2 + exit 1 +} +attribute_input=$(mktemp) || { + rm -f "$staged_input" + echo "ERROR: Failed to create encryption hook attribute input" >&2 + exit 1 +} +trap 'rm -f "$staged_input" "$attribute_input"' EXIT + # Staged adds/copies/modifies/renames, NUL-delimited for path safety. +if ! git diff --cached --name-only --diff-filter=ACMR -z > "$staged_input"; then + echo "ERROR: Could not inspect staged paths for encryption" >&2 + exit 1 +fi staged=() -while IFS= read -r -d '' path; do staged+=("$path"); done \ - < <(git diff --cached --name-only --diff-filter=ACMR -z) +while IFS= read -r -d '' path; do staged+=("$path"); done < "$staged_input" [ "${#staged[@]}" -eq 0 ] && exit 0 # Which staged paths are under an encrypted (filter=git-crypt) pattern? # check-attr -z emits NUL-delimited triples. +if ! git check-attr --cached -z filter -- "${staged[@]}" > "$attribute_input"; then + echo "ERROR: Could not inspect staged encryption attributes" >&2 + exit 1 +fi encrypted_staged=() while IFS= read -r -d '' path \ && IFS= read -r -d '' _attr \ && IFS= read -r -d '' value; do [ "$value" = "git-crypt" ] && encrypted_staged+=("$path") -done < <(git check-attr --cached -z filter -- "${staged[@]}") +done < "$attribute_input" [ "${#encrypted_staged[@]}" -eq 0 ] && exit 0 -# Flag any that are currently plaintext (e.g. git-crypt locked at stage time). -bad_files=() -for file in "${encrypted_staged[@]}"; do - status_output="" - status=0 - if status_output=$(git-crypt status -- "$file" 2>&1); then - : - else - status=$? - fi - - if grep -qi "not encrypted" <<< "$status_output"; then - bad_files+=("$file") - elif [ "$status" -ne 0 ]; then - echo "ERROR: git-crypt could not inspect staged encrypted path: $file" >&2 - [ -z "$status_output" ] || printf '%s\n' "$status_output" >&2 - exit 1 - fi -done - -if [ "${#bad_files[@]}" -gt 0 ]; then - echo "ERROR: Staged files should be encrypted but are plaintext:" >&2 - printf ' %s\n' "${bad_files[@]}" >&2 - echo "" >&2 - echo "Run 'notes unlock' if git-crypt is locked, then re-stage." >&2 - exit 1 -fi +verify_encrypted_paths "${encrypted_staged[@]}" diff --git a/hooks/obfuscation.template b/hooks/obfuscation.template index 8196bb8..06e05f6 100644 --- a/hooks/obfuscation.template +++ b/hooks/obfuscation.template @@ -9,6 +9,8 @@ NOTES_TOOL_ROOT="__NOTES_TOOL_ROOT__" MISE_BIN="__MISE_BIN__" MODE="${NOTES_OBFUSCATE_HOOK:-auto}" +source "$NOTES_TOOL_ROOT/lib/obfuscate.sh" + run_notes() { local repo_root repo_root="$(git rev-parse --show-toplevel)" @@ -16,17 +18,49 @@ run_notes() { (cd "$NOTES_TOOL_ROOT" && "$MISE_BIN" run -q "$@") } -if [ -f "$NOTES_ROOT/.manifest" ]; then - unobfuscated=() +build_staged_obfuscation_plan() { + local candidates="$1" plan="$2" + local staged="$candidates.staged" + local file relpath + : > "$candidates" + + if ! git diff --cached --name-only --diff-filter=ACMR > "$staged"; then + rm -f "$staged" + return 1 + fi while IFS= read -r file; do [[ "$file" != "$NOTES_ROOT/"* ]] && continue - base=$(basename "$file") - [[ "$base" == ".manifest" ]] && continue - # Check if basename is a known ID in the manifest - if ! grep -q "^${base} " "$NOTES_ROOT/.manifest"; then - unobfuscated+=("$file") - fi - done < <(git diff --cached --name-only --diff-filter=ACMR) + relpath="${file#"$NOTES_ROOT/"}" + [[ "$relpath" == ".manifest" ]] && continue + printf '%s\n' "$relpath" >> "$candidates" + done < "$staged" + rm -f "$staged" + + classify_obfuscation_candidates \ + "$NOTES_ROOT" "$NOTES_ROOT/.manifest" "$candidates" "$plan" +} + +if [ -f "$NOTES_ROOT/.manifest" ]; then + candidates=$(mktemp) || { + echo "ERROR: Failed to create obfuscation hook candidates" >&2 + exit 1 + } + plan=$(mktemp) || { + rm -f "$candidates" + echo "ERROR: Failed to create obfuscation hook plan" >&2 + exit 1 + } + trap 'rm -f "$candidates" "$plan"' EXIT + + if ! build_staged_obfuscation_plan "$candidates" "$plan"; then + echo "ERROR: Could not classify staged note filenames" >&2 + exit 1 + fi + + unobfuscated=() + while IFS=$'\t' read -r _kind _id relpath; do + [ -n "$relpath" ] && unobfuscated+=("$NOTES_ROOT/$relpath") + done < "$plan" if [ "${#unobfuscated[@]}" -gt 0 ]; then if [ "$MODE" = "auto" ]; then @@ -35,6 +69,21 @@ if [ -f "$NOTES_ROOT/.manifest" ]; then # `notes` command happens to appear first on PATH. # Pass only the staged unobfuscated files. run_notes obfuscate "${unobfuscated[@]}" >&2 + + # Auto mode must prove the index no longer contains readable note paths. + # A staged file can be absent from the working tree and therefore cannot + # be repaired by obfuscation; fail rather than committing that path. + if ! build_staged_obfuscation_plan "$candidates" "$plan"; then + echo "ERROR: Could not verify staged note filenames" >&2 + exit 1 + fi + if [ -s "$plan" ]; then + echo "ERROR: Auto-obfuscation left staged non-obfuscated filenames:" >&2 + while IFS=$'\t' read -r _kind _id relpath; do + [ -n "$relpath" ] && printf ' %s/%s\n' "$NOTES_ROOT" "$relpath" >&2 + done < "$plan" + exit 1 + fi else echo "ERROR: Staged notes have non-obfuscated filenames:" >&2 printf ' %s\n' "${unobfuscated[@]}" >&2 diff --git a/lib/encryption.sh b/lib/encryption.sh new file mode 100644 index 0000000..e3d3e54 --- /dev/null +++ b/lib/encryption.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# encryption.sh — staged encrypted-path validation + +# Verify that indexed blobs for encrypted paths are encrypted. +# The common path checks all paths in one git-crypt call. If that call reports +# plaintext or fails, inspect paths individually so diagnostics remain precise +# and unexpected backend failures remain fail closed. +# Usage: verify_encrypted_paths +verify_encrypted_paths() { + local encrypted_paths=("$@") + local batch_output batch_status=0 marker_status=0 + + [ "${#encrypted_paths[@]}" -gt 0 ] || return 0 + + if batch_output=$(git-crypt status -- "${encrypted_paths[@]}" 2>&1); then + : + else + batch_status=$? + fi + + # git-crypt reports plaintext paths in human output but still exits zero. + # Use the same marker as the existing per-path contract only to select the + # precise fallback; do not parse path names from batched output. + grep -qi "not encrypted" <<< "$batch_output" || marker_status=$? + case "$marker_status" in + 0) ;; + 1) + [ "$batch_status" -eq 0 ] && return 0 + ;; + *) + echo "ERROR: could not interpret git-crypt inspection output" >&2 + [ -z "$batch_output" ] || printf '%s\n' "$batch_output" >&2 + return 1 + ;; + esac + + local bad_files=() + local file status_output status + for file in "${encrypted_paths[@]}"; do + status_output="" + status=0 + marker_status=0 + if status_output=$(git-crypt status -- "$file" 2>&1); then + : + else + status=$? + fi + + grep -qi "not encrypted" <<< "$status_output" || marker_status=$? + case "$marker_status" in + 0) + bad_files+=("$file") + ;; + 1) + if [ "$status" -ne 0 ]; then + echo "ERROR: git-crypt could not inspect staged encrypted path: $file" >&2 + [ -z "$status_output" ] || printf '%s\n' "$status_output" >&2 + return 1 + fi + ;; + *) + echo "ERROR: could not interpret git-crypt inspection output for: $file" >&2 + [ -z "$status_output" ] || printf '%s\n' "$status_output" >&2 + return 1 + ;; + esac + done + + if [ "${#bad_files[@]}" -gt 0 ]; then + echo "ERROR: Staged files should be encrypted but are plaintext:" >&2 + printf ' %s\n' "${bad_files[@]}" >&2 + echo "" >&2 + echo "Run 'notes unlock' if git-crypt is locked, then re-stage." >&2 + return 1 + fi + + # The batched command failed, but no individual path explained the failure. + # Preserve fail-closed behavior rather than trusting an ambiguous result. + echo "ERROR: git-crypt could not inspect staged encrypted paths" >&2 + [ -z "$batch_output" ] || printf '%s\n' "$batch_output" >&2 + return 1 +} diff --git a/lib/hooks.sh b/lib/hooks.sh index b8cd28e..0094b77 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -42,10 +42,12 @@ ensure_hook_dispatcher() { } # Install the encryption pre-commit check. +# Render the source package path so the hook uses the exact Notes version that +# installed it rather than whichever `notes` command appears first on PATH. install_encryption_hook() { ensure_hook_dispatcher pre-commit local target="$TARGET_DIR/.git/hooks/pre-commit.d/encryption" - cp "$HOOKS_DIR/encryption" "$target" + _render_notes_hook_template "$HOOKS_DIR/encryption.template" "." > "$target" chmod +x "$target" } diff --git a/lib/obfuscate.sh b/lib/obfuscate.sh index 548262c..92cc5f1 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -37,48 +37,14 @@ EOF return 0 } -# Build a corpus-level obfuscation plan. +# Classify a prepared readable-path snapshot against one manifest snapshot. # Output rows are "\t\t", where kind is # "known" for an existing manifest mapping or "new" for a new readable name. # Already-obfuscated IDs are omitted. The full plan is checked for orphan # hash-shaped names before any caller mutates the working tree. -# Usage: build_obfuscation_plan [file...] -build_obfuscation_plan() { - local notes_dir="$1" plan_file="$2" - shift 2 - local scoped_files=("$@") - local manifest="$notes_dir/.manifest" - local workspace candidates manifest_input - - workspace=$(mktemp -d) || { - echo "Error: failed to create obfuscation workspace" >&2 - return 1 - } - candidates="$workspace/candidates" - : > "$candidates" - - if [ ${#scoped_files[@]} -gt 0 ] && [ -n "${scoped_files[0]}" ]; then - local relpath - for relpath in "${scoped_files[@]}"; do - [[ "$relpath" == ".manifest" ]] && continue - [ -f "$notes_dir/$relpath" ] || continue - printf '%s\n' "$relpath" >> "$candidates" - done - else - local file relpath - while IFS= read -r file; do - [ -f "$file" ] || continue - relpath="${file#"$notes_dir"/}" - [[ "$relpath" == ".manifest" ]] && continue - printf '%s\n' "$relpath" >> "$candidates" - done < <(find "$notes_dir" -type f | sort) - fi - - manifest_input="$manifest" - if [ ! -f "$manifest_input" ]; then - manifest_input="$workspace/manifest" - : > "$manifest_input" - fi +# Usage: classify_obfuscation_candidates +classify_obfuscation_candidates() { + local notes_dir="$1" manifest_input="$2" candidates="$3" plan_file="$4" if ! awk -F '\t' -v OFS='\t' -v candidates="$candidates" ' FILENAME != candidates { @@ -109,30 +75,69 @@ build_obfuscation_plan() { } } ' "$manifest_input" "$candidates" > "$plan_file"; then - rm -rf "$workspace" return 1 fi - local kind id + local kind id relpath while IFS=$'\t' read -r kind id relpath; do if [ "$kind" = "invalid" ]; then - rm -rf "$workspace" refuse_if_hex_basename "$relpath" return 1 fi if [ "$kind" = "collision" ]; then - rm -rf "$workspace" echo "Error: manifest ID '$id' maps to multiple readable paths" >&2 return 1 fi if [ "$kind" = "known" ] && { [ -e "$notes_dir/$id" ] || [ -L "$notes_dir/$id" ]; }; then - rm -rf "$workspace" echo "Error: refusing to overwrite existing obfuscated path: $id" >&2 return 1 fi done < "$plan_file" +} + +# Build a corpus-level obfuscation plan from readable files on disk. +# Usage: build_obfuscation_plan [file...] +build_obfuscation_plan() { + local notes_dir="$1" plan_file="$2" + shift 2 + local scoped_files=("$@") + local manifest="$notes_dir/.manifest" + local workspace candidates manifest_input rc=0 + + workspace=$(mktemp -d) || { + echo "Error: failed to create obfuscation workspace" >&2 + return 1 + } + candidates="$workspace/candidates" + : > "$candidates" + if [ ${#scoped_files[@]} -gt 0 ] && [ -n "${scoped_files[0]}" ]; then + local relpath + for relpath in "${scoped_files[@]}"; do + [[ "$relpath" == ".manifest" ]] && continue + [ -f "$notes_dir/$relpath" ] || continue + printf '%s\n' "$relpath" >> "$candidates" + done + else + local file relpath + while IFS= read -r file; do + [ -f "$file" ] || continue + relpath="${file#"$notes_dir"/}" + [[ "$relpath" == ".manifest" ]] && continue + printf '%s\n' "$relpath" >> "$candidates" + done < <(find "$notes_dir" -type f | sort) + fi + + manifest_input="$manifest" + if [ ! -f "$manifest_input" ]; then + manifest_input="$workspace/manifest" + : > "$manifest_input" + fi + + classify_obfuscation_candidates \ + "$notes_dir" "$manifest_input" "$candidates" "$plan_file" || rc=$? rm -rf "$workspace" + return "$rc" } # Apply a precomputed obfuscation plan. diff --git a/test/encrypt.bats b/test/encrypt.bats index 5dfc87c..8305d40 100644 --- a/test/encrypt.bats +++ b/test/encrypt.bats @@ -97,6 +97,10 @@ load test_helper # Individual hooks [ -x "$TARGET_DIR/.git/hooks/pre-commit.d/encryption" ] grep -q "git-crypt" "$TARGET_DIR/.git/hooks/pre-commit.d/encryption" + grep -qF "NOTES_TOOL_ROOT=\"$REPO_DIR\"" \ + "$TARGET_DIR/.git/hooks/pre-commit.d/encryption" + grep -qF 'source "$NOTES_TOOL_ROOT/lib/encryption.sh"' \ + "$TARGET_DIR/.git/hooks/pre-commit.d/encryption" [ -x "$TARGET_DIR/.git/hooks/pre-commit.d/obfuscation" ] grep -q "manifest" "$TARGET_DIR/.git/hooks/pre-commit.d/obfuscation" } diff --git a/test/integration.bats b/test/integration.bats index 15a81c3..11525df 100644 --- a/test/integration.bats +++ b/test/integration.bats @@ -539,6 +539,125 @@ run_encryption_hook() { [ "$status" -eq 0 ] } +@test "encryption hook checks multiple valid staged paths in one git-crypt call (#49)" { + notes setup --yes + local fpr + fpr=$(generate_test_key "$GNUPGHOME") + notes add-user -- --gpg-key "$fpr" + + mkdir -p "$TARGET_DIR/notes" + printf '%s\n' "first secret" > "$TARGET_DIR/notes/first.md" + printf '%s\n' "second secret" > "$TARGET_DIR/notes/second.md" + git -C "$TARGET_DIR" add notes/first.md notes/second.md + + local mock_bin="$BATS_TEST_TMPDIR/counting-git-crypt-bin" + local calls="$BATS_TEST_TMPDIR/git-crypt-calls" + export GIT_CRYPT_CALLS="$calls" + mkdir -p "$mock_bin" + cat > "$mock_bin/git-crypt" <<'SH' +#!/usr/bin/env bash +printf 'call\n' >> "$GIT_CRYPT_CALLS" +PATH="${PATH#*:}" exec git-crypt "$@" +SH + chmod +x "$mock_bin/git-crypt" + export PATH="$mock_bin:$PATH" + + run_encryption_hook + [ "$status" -eq 0 ] + [ "$(wc -l < "$calls" | tr -d ' ')" -eq 1 ] +} + +@test "encryption hook fails closed when staged-path inspection fails (#49)" { + notes setup --yes + + printf '%s\n' "public" > "$TARGET_DIR/public.md" + git -C "$TARGET_DIR" add public.md + + local mock_bin="$BATS_TEST_TMPDIR/failing-encryption-diff-bin" + local real_git + real_git=$(command -v git) + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = "diff" ]; then + printf '%s\n' "staged inspection failed" >&2 + exit 73 +fi +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + export PATH="$mock_bin:$PATH" + export REAL_GIT="$real_git" + + run_encryption_hook + [ "$status" -eq 1 ] + [[ "$output" == *"staged inspection failed"* ]] + [[ "$output" == *"Could not inspect staged paths for encryption"* ]] +} + +@test "encryption hook fails closed when staged-attribute inspection fails (#49)" { + notes setup --yes + + mkdir -p "$TARGET_DIR/notes" + printf '%s\n' "secret" > "$TARGET_DIR/notes/secret.md" + git -C "$TARGET_DIR" add notes/secret.md + + local mock_bin="$BATS_TEST_TMPDIR/failing-encryption-attr-bin" + local real_git + real_git=$(command -v git) + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = "check-attr" ]; then + printf '%s\n' "attribute inspection failed" >&2 + exit 74 +fi +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + export PATH="$mock_bin:$PATH" + export REAL_GIT="$real_git" + + run_encryption_hook + [ "$status" -eq 1 ] + [[ "$output" == *"attribute inspection failed"* ]] + [[ "$output" == *"Could not inspect staged encryption attributes"* ]] +} + + +@test "encryption hook falls back per path after a batched plaintext result (#49)" { + notes setup --yes + local fpr + fpr=$(generate_test_key "$GNUPGHOME") + notes add-user -- --gpg-key "$fpr" + + mkdir -p "$TARGET_DIR/notes" + local file blob + for file in first.md second.md; do + blob=$(printf 'PLAINTEXT-LEAK\n' | git -C "$TARGET_DIR" hash-object -w --stdin) + git -C "$TARGET_DIR" update-index --add --cacheinfo \ + 100644 "$blob" "notes/$file" + done + + local mock_bin="$BATS_TEST_TMPDIR/fallback-git-crypt-bin" + local calls="$BATS_TEST_TMPDIR/git-crypt-fallback-calls" + export GIT_CRYPT_CALLS="$calls" + mkdir -p "$mock_bin" + cat > "$mock_bin/git-crypt" <<'SH' +#!/usr/bin/env bash +printf 'call\n' >> "$GIT_CRYPT_CALLS" +PATH="${PATH#*:}" exec git-crypt "$@" +SH + chmod +x "$mock_bin/git-crypt" + export PATH="$mock_bin:$PATH" + + run_encryption_hook + [ "$status" -eq 1 ] + [[ "$output" == *"notes/first.md"* ]] + [[ "$output" == *"notes/second.md"* ]] + [ "$(wc -l < "$calls" | tr -d ' ')" -eq 3 ] +} + @test "encryption hook fails closed when git-crypt cannot inspect a staged path (#49)" { notes setup --yes local fpr diff --git a/test/obfuscation-hooks.bats b/test/obfuscation-hooks.bats index a5c4e5c..d1d4ccf 100644 --- a/test/obfuscation-hooks.bats +++ b/test/obfuscation-hooks.bats @@ -67,7 +67,11 @@ setup() { [ -x "$NOTES_CALLER_PWD/.git/hooks/pre-commit" ] grep -q "Generic hook dispatcher" "$NOTES_CALLER_PWD/.git/hooks/pre-commit" [ -x "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption" ] - grep -q "git-crypt status" "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption" + grep -q "git-crypt" "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption" + grep -qF "NOTES_TOOL_ROOT=\"$REPO_DIR\"" \ + "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption" + grep -qF 'source "$NOTES_TOOL_ROOT/lib/encryption.sh"' \ + "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/encryption" [ -x "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/obfuscation" ] grep -q "manifest" "$NOTES_CALLER_PWD/.git/hooks/pre-commit.d/obfuscation" } @@ -147,6 +151,103 @@ EOF } +@test "multi-file obfuscation guard uses one planner without per-file classifiers" { + notes setup --yes + git -C "$NOTES_CALLER_PWD" add -A + git -C "$NOTES_CALLER_PWD" commit --no-verify -q -m "setup" + + printf '%s\n' "# Delta" > "$NOTES_CALLER_PWD/notes/delta.md" + printf '%s\n' "# Epsilon" > "$NOTES_CALLER_PWD/notes/epsilon.md" + git -C "$NOTES_CALLER_PWD" add notes/delta.md notes/epsilon.md + + local mock_bin="$BATS_TEST_TMPDIR/obfuscation-classifier-bin" + local awk_calls="$BATS_TEST_TMPDIR/obfuscation-awk-calls" + local real_awk + real_awk=$(command -v awk) + mkdir -p "$mock_bin" + + for command in basename grep; do + cat > "$mock_bin/$command" <<'SH' +#!/usr/bin/env bash +printf 'unexpected per-file classifier: %s\n' "${0##*/}" >&2 +exit 97 +SH + chmod +x "$mock_bin/$command" + done + + cat > "$mock_bin/awk" <<'SH' +#!/usr/bin/env bash +printf 'call\n' >> "$AWK_CALLS" +exec "$REAL_AWK" "$@" +SH + chmod +x "$mock_bin/awk" + + run env \ + PATH="$mock_bin:$PATH" \ + AWK_CALLS="$awk_calls" \ + REAL_AWK="$real_awk" \ + NOTES_OBFUSCATE_HOOK=guard \ + bash -c 'cd "$1" && .git/hooks/pre-commit.d/obfuscation' \ + _ "$NOTES_CALLER_PWD" + + [ "$status" -eq 1 ] + [[ "$output" == *"delta.md"* ]] + [[ "$output" == *"epsilon.md"* ]] + [[ "$output" != *"unexpected per-file classifier"* ]] + [ "$(wc -l < "$awk_calls" | tr -d ' ')" -eq 1 ] +} + + +@test "obfuscation hook fails closed when staged-path inspection fails" { + notes setup --yes + git -C "$NOTES_CALLER_PWD" add -A + git -C "$NOTES_CALLER_PWD" commit --no-verify -q -m "setup" + + printf '%s\n' "# Delta" > "$NOTES_CALLER_PWD/notes/delta.md" + git -C "$NOTES_CALLER_PWD" add notes/delta.md + + local mock_bin="$BATS_TEST_TMPDIR/failing-obfuscation-git-bin" + local real_git + real_git=$(command -v git) + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = "diff" ]; then + printf '%s\n' "staged inspection failed" >&2 + exit 73 +fi +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + + run env PATH="$mock_bin:$PATH" REAL_GIT="$real_git" \ + bash -c 'cd "$1" && .git/hooks/pre-commit.d/obfuscation' \ + _ "$NOTES_CALLER_PWD" + + [ "$status" -eq 1 ] + [[ "$output" == *"staged inspection failed"* ]] + [[ "$output" == *"Could not classify staged note filenames"* ]] +} + + +@test "auto hook fails closed when a staged readable file is missing from disk" { + notes setup --yes + git -C "$NOTES_CALLER_PWD" add -A + git -C "$NOTES_CALLER_PWD" commit --no-verify -q -m "setup" + + printf '%s\n' "# Index Only" > "$NOTES_CALLER_PWD/notes/index-only.md" + git -C "$NOTES_CALLER_PWD" add notes/index-only.md + rm "$NOTES_CALLER_PWD/notes/index-only.md" + + run bash -c 'cd "$1" && .git/hooks/pre-commit.d/obfuscation' \ + _ "$NOTES_CALLER_PWD" + + [ "$status" -eq 1 ] + [[ "$output" == *"left staged non-obfuscated filenames"* ]] + [[ "$output" == *"notes/index-only.md"* ]] +} + + @test "pre-commit hook allows obfuscated files" { notes setup --yes git -C "$NOTES_CALLER_PWD" add -A