diff --git a/.mise/tasks/setup b/.mise/tasks/setup index 5a1f913..95ed689 100755 --- a/.mise/tasks/setup +++ b/.mise/tasks/setup @@ -9,6 +9,8 @@ set -euo pipefail source "$MISE_CONFIG_ROOT/lib/common.sh" source "$MISE_CONFIG_ROOT/lib/hooks.sh" +source "$MISE_CONFIG_ROOT/lib/encryption.sh" +source "$MISE_CONFIG_ROOT/lib/setup.sh" require_git require_rudi @@ -50,19 +52,59 @@ if [ ${#patterns[@]} -eq 0 ]; then patterns=("$NOTES_DIR/**") fi +# Inspect the pre-setup index before attributes or the manifest can change how +# tracked notes are interpreted. +tracked_readable_snapshot=$(mktemp) || { + echo "Error: failed to create tracked note snapshot" >&2 + exit 1 +} +if ! write_tracked_readable_notes \ + "$TARGET_DIR" "$NOTES_DIR" "$tracked_readable_snapshot"; then + rm -f "$tracked_readable_snapshot" + echo "Error: failed to inspect tracked notes before setup." >&2 + exit 1 +fi +tracked_readable_count=$(tracked_readable_note_count "$tracked_readable_snapshot") + +repo_was_initialized=false +is_initialized && repo_was_initialized=true +unlock_requested=${usage_unlock:-false} +if ! require_tracked_plaintext_setup_ready \ + "$TARGET_DIR" "$tracked_readable_snapshot" "$tracked_readable_count" \ + "$repo_was_initialized" "$unlock_requested"; then + rm -f "$tracked_readable_snapshot" + exit 1 +fi +rm -f "$tracked_readable_snapshot" + setup_confirmation_message="notes setup will initialize/update git-crypt, .gitattributes, $NOTES_DIR/.manifest, and git hooks for $NOTES_DIR/ in this repo." +if [ "$tracked_readable_count" -gt 0 ]; then + setup_confirmation_message="$setup_confirmation_message It will prepare $tracked_readable_count tracked plaintext note(s) for forward encryption." +fi if [ "${usage_unlock:-false}" = "true" ]; then setup_confirmation_message="$setup_confirmation_message It will also unlock/decrypt all git-crypt files after setup." fi confirm_destructive "$setup_confirmation_message Continue?" # --- Initialize via rudi --- -if is_initialized || [ -d "$TARGET_DIR/.git/git-crypt" ]; then +if $repo_was_initialized; then echo "git-crypt already initialized — updating auxiliary files..." else echo "Initializing git-crypt..." rudi init --no-user fi +if [ "$tracked_readable_count" -gt 0 ]; then + echo "Preparing $tracked_readable_count tracked plaintext note(s) for forward encryption..." +fi + +# Existing repositories must unlock before setup dirties .gitattributes. +unlocked_before_mutation=false +if [ "${usage_unlock:-false}" = "true" ] && $repo_was_initialized; then + echo "" + echo "Unlocking..." + cd "$MISE_CONFIG_ROOT" && NOTES_CALLER_PWD="$TARGET_DIR" mise run -q unlock + unlocked_before_mutation=true +fi # --- Add GPG keys via rudi --- if [ ${#keys[@]} -gt 0 ]; then @@ -90,46 +132,7 @@ if [ ${#keys[@]} -gt 0 ]; then fi # --- Configure .gitattributes via rudi --- -GITATTRIBUTES="$TARGET_DIR/.gitattributes" - -gitattributes_has_encrypted_pattern() { - local pattern="$1" - awk -v pattern="$pattern" ' - $1 == pattern { - for (i = 2; i <= NF; i++) { - if ($i == "filter=git-crypt") found = 1 - } - } - END { exit(found ? 0 : 1) } - ' "$GITATTRIBUTES" 2>/dev/null -} - -missing_patterns=() -for p in "${patterns[@]}"; do - [ -z "$p" ] && continue - if ! gitattributes_has_encrypted_pattern "$p"; then - missing_patterns+=("$p") - fi -done - -if [ ${#missing_patterns[@]} -gt 0 ]; then - echo "" - echo "Configuring encrypted patterns..." - for p in "${missing_patterns[@]}"; do - rudi assign "$p" - if ! gitattributes_has_encrypted_pattern "$p"; then - # rudi treats an existing pattern with disabled attributes (for example - # "notes/** -filter=git-crypt") as already assigned. Append an explicit - # positive assignment so the requested pattern is actually encrypted. - # Fixed width here serializes .gitattributes; it is not a rendered table. - printf '%-40s filter=git-crypt diff=git-crypt\n' "$p" >> "$GITATTRIBUTES" # codebase:ignore - fi - done - echo " Updated .gitattributes" -else - echo "" - echo " Requested encrypted patterns already configured" -fi +configure_setup_encrypted_patterns "$TARGET_DIR" "${patterns[@]}" # --- Bootstrap obfuscation manifest --- if [ ! -f "$TARGET_DIR/$NOTES_DIR/.manifest" ]; then @@ -148,31 +151,23 @@ echo " Installed hooks" # --- Unlock if requested --- if [ "${usage_unlock:-false}" = "true" ]; then - echo "" - echo "Unlocking..." - cd "$MISE_CONFIG_ROOT" && NOTES_CALLER_PWD="$TARGET_DIR" mise run -q unlock + if ! $unlocked_before_mutation; then + echo "" + echo "Unlocking..." + cd "$MISE_CONFIG_ROOT" && NOTES_CALLER_PWD="$TARGET_DIR" mise run -q unlock + fi echo "" echo "Done! Repo is set up and unlocked." else # Detect if this is an existing repo with encrypted notes # (i.e., joining an existing repo vs creating a new one). - has_encrypted_notes=false - if [ -d "$TARGET_DIR/$NOTES_DIR" ]; then - encrypted_notes_snapshot=$(mktemp) || { echo "Error: failed to create encrypted note snapshot" >&2; exit 1; } - encrypted_notes_status=0 - find "$TARGET_DIR/$NOTES_DIR" -type f ! -name .manifest -print0 > "$encrypted_notes_snapshot" || encrypted_notes_status=$? - if [ "$encrypted_notes_status" -ne 0 ]; then - rm -f "$encrypted_notes_snapshot" - echo "Error: failed to inspect existing encrypted notes." >&2 - exit "$encrypted_notes_status" - fi - while IFS= read -r -d '' f; do - if head -c 10 "$f" 2>/dev/null | grep -q "GITCRYPT"; then - has_encrypted_notes=true - break - fi - done < "$encrypted_notes_snapshot" - rm -f "$encrypted_notes_snapshot" + has_encrypted_notes="" + encrypted_notes_status=0 + has_encrypted_notes=$(notes_tree_encryption_state "$TARGET_DIR/$NOTES_DIR") \ + || encrypted_notes_status=$? + if [ "$encrypted_notes_status" -ne 0 ]; then + echo "Error: failed to inspect existing encrypted notes." >&2 + exit "$encrypted_notes_status" fi echo "" @@ -184,6 +179,13 @@ else echo " This repo already has encrypted notes." echo " Run 'notes unlock' to decrypt them (requires your GPG key)." echo " Or re-run setup with --unlock: notes setup --unlock" + elif [ "$tracked_readable_count" -gt 0 ]; then + echo " 1. Stage attributes: git add .gitattributes" + echo " 2. Stage notes: notes stage --all" + echo " 3. Commit setup and encrypted notes" + echo "" + echo "Existing tracked notes need a one-time Notes stage so Git reapplies" + echo "the new encryption filter and the commit hook obfuscates their names." else echo " 1. Check status: notes status" echo " 2. Commit the setup: git add .gitattributes .git-crypt && git commit" diff --git a/.mise/tasks/stage b/.mise/tasks/stage index 0503c10..6cb8486 100755 --- a/.mise/tasks/stage +++ b/.mise/tasks/stage @@ -11,6 +11,8 @@ source "$MISE_CONFIG_ROOT/lib/obfuscate.sh" source "$MISE_CONFIG_ROOT/lib/suppress.sh" source "$MISE_CONFIG_ROOT/lib/changes.sh" source "$MISE_CONFIG_ROOT/lib/hooks.sh" +source "$MISE_CONFIG_ROOT/lib/encryption.sh" +source "$MISE_CONFIG_ROOT/lib/stage.sh" require_git notes_dir="${usage_dir:-notes}" @@ -228,34 +230,9 @@ for relpath in ${to_stage[@]+"${to_stage[@]}"}; do local_file="$abs_notes_dir/$relpath" if [ -f "$local_file" ]; then - # Modified or new: force-add the readable file. - git -C "$TARGET_DIR" add -f "$notes_dir/$relpath" - echo " staged: $relpath" + stage_readable_note "$TARGET_DIR" "$notes_dir" "$relpath" else - # Deleted: remove the obfuscated ID from the index. - id=$(manifest_id_for_name "$manifest" "$relpath") - if [ -n "$id" ]; then - # First remove the tracked blob when it is still present in the index. - # If the index cannot be updated (for example, an index lock exists), - # fail before mutating the working manifest. If a previous interrupted - # run already staged/removed the blob, keep going and only stage the - # manifest repair. - if git -C "$TARGET_DIR" ls-files --error-unmatch -- "$notes_dir/$id" >/dev/null 2>&1; then - git -C "$TARGET_DIR" rm --cached --quiet "$notes_dir/$id" - fi - - tmp_manifest=$(mktemp) || exit 1 - manifest_backup=$(mktemp) || exit 1 - cp "$manifest" "$manifest_backup" - awk -F '\t' -v path="$relpath" '$2 != path { print $0 }' "$manifest" > "$tmp_manifest" - mv -f "$tmp_manifest" "$manifest" - if ! git -C "$TARGET_DIR" add -f "$notes_dir/.manifest"; then - mv -f "$manifest_backup" "$manifest" - exit 1 - fi - rm -f "$manifest_backup" - echo " staged (delete): $relpath" - fi + stage_deleted_note "$TARGET_DIR" "$notes_dir" "$manifest" "$relpath" fi staged=$((staged + 1)) done diff --git a/README.md b/README.md index 0f65267..ac1207b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 467](https://img.shields.io/badge/tests-467-brightgreen?style=flat)](test/) +[![tests: 475](https://img.shields.io/badge/tests-475-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/lib/encryption.sh b/lib/encryption.sh index e3d3e54..dbd47be 100644 --- a/lib/encryption.sh +++ b/lib/encryption.sh @@ -1,5 +1,29 @@ #!/usr/bin/env bash -# encryption.sh — staged encrypted-path validation +# encryption.sh — Git blob and staged encrypted-path validation + +GITCRYPT_HEADER_HEX="00474954435259505400" + +# Print encrypted or plaintext for a raw Git blob. Backend failures preserve +# their exit status. The producer is fully drained so large blobs do not cause +# SIGPIPE under pipefail. +# Usage: git_blob_encryption_state +git_blob_encryption_state() { + local repo="$1" object="$2" header_hex blob_status=0 + + header_hex=$( + git -C "$repo" cat-file blob "$object" 2>/dev/null | + { dd bs=1 count=10 2>/dev/null; cat >/dev/null; } | + od -An -tx1 | + tr -d ' \n' + ) || blob_status=$? + [ "$blob_status" -eq 0 ] || return "$blob_status" + + if [ "$header_hex" = "$GITCRYPT_HEADER_HEX" ]; then + printf 'encrypted\n' + else + printf 'plaintext\n' + fi +} # Verify that indexed blobs for encrypted paths are encrypted. # The common path checks all paths in one git-crypt call. If that call reports diff --git a/lib/setup.sh b/lib/setup.sh new file mode 100644 index 0000000..715b84b --- /dev/null +++ b/lib/setup.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# setup.sh — repository setup inspection and configuration + +# Write tracked readable note paths as NUL-delimited repo-relative paths. +# Manifest-owned root IDs are the normal managed state and are omitted. +# Usage: write_tracked_readable_notes +write_tracked_readable_notes() { + local repo="$1" notes_dir="$2" output="$3" + local manifest="$repo/$notes_dir/.manifest" + local tracked_snapshot tracked_path relpath blob_state blob_status + local inspection_status=0 + + tracked_snapshot=$(mktemp) || return 1 + if ! git -C "$repo" ls-files -z -- "$notes_dir" > "$tracked_snapshot"; then + rm -f "$tracked_snapshot" + return 1 + fi + + : > "$output" + while IFS= read -r -d '' tracked_path; do + relpath="${tracked_path#"$notes_dir"/}" + [ "$relpath" = ".manifest" ] && continue + + blob_state="" + blob_status=0 + blob_state=$(git_blob_encryption_state "$repo" ":$tracked_path") \ + || blob_status=$? + if [ "$blob_status" -ne 0 ]; then + echo "Error: failed to inspect indexed note blob: $tracked_path" >&2 + inspection_status=$blob_status + break + fi + [ "$blob_state" = "encrypted" ] && continue + + case "$relpath" in + */*) ;; + *) + if [ "$relpath" != "$tracked_path" ] \ + && [ -f "$manifest" ] \ + && awk -F '\t' -v id="$relpath" \ + '$1 == id { found = 1 } END { exit(found ? 0 : 1) }' "$manifest"; then + continue + fi + ;; + esac + + printf '%s\0' "$tracked_path" >> "$output" + done < "$tracked_snapshot" + + rm -f "$tracked_snapshot" + return "$inspection_status" +} + +tracked_readable_note_count() { + local snapshot="$1" count=0 _path + while IFS= read -r -d '' _path; do + count=$((count + 1)) + done < "$snapshot" + printf '%s\n' "$count" +} + +# A tracked-plaintext migration must begin with every candidate available as a +# regular worktree file. Sparse or otherwise absent candidates cannot be staged +# and would leave plaintext blobs behind the new encryption attributes. +# Existing locked repositories must also unlock before setup changes attributes. +require_tracked_plaintext_setup_ready() { + local repo="$1" tracked_snapshot="$2" tracked_count="$3" + local initialized="$4" unlock_requested="$5" + local tracked_path status_snapshot + + [ "$tracked_count" -gt 0 ] || return 0 + + while IFS= read -r -d '' tracked_path; do + if [ ! -f "$repo/$tracked_path" ] || [ -L "$repo/$tracked_path" ]; then + echo "Error: tracked plaintext note is not available as a regular worktree file: $tracked_path" >&2 + echo "Materialize all tracked plaintext notes (for example, expand or disable sparse checkout), then rerun notes setup." >&2 + return 1 + fi + done < "$tracked_snapshot" + + status_snapshot=$(mktemp) || return 1 + if ! git -C "$repo" status --porcelain > "$status_snapshot"; then + rm -f "$status_snapshot" + echo "Error: failed to inspect worktree before tracked plaintext onboarding." >&2 + return 1 + fi + if [ -s "$status_snapshot" ]; then + rm -f "$status_snapshot" + echo "Error: tracked plaintext note onboarding requires a clean worktree." >&2 + echo "Commit or preserve current changes, then rerun notes setup." >&2 + return 1 + fi + rm -f "$status_snapshot" + + if $initialized && ! encryption_unlocked && ! $unlock_requested; then + echo "Error: tracked plaintext note onboarding requires git-crypt to be unlocked before setup changes the worktree." >&2 + echo "Rerun with: notes setup --yes --unlock" >&2 + return 1 + fi +} + +setup_gitattributes_has_encrypted_pattern() { + local gitattributes="$1" pattern="$2" + awk -v pattern="$pattern" ' + $1 == pattern { + for (i = 2; i <= NF; i++) { + if ($i == "filter=git-crypt") found = 1 + } + } + END { exit(found ? 0 : 1) } + ' "$gitattributes" 2>/dev/null +} + +# Configure requested patterns and preserve rudi's disabled-attribute repair. +# Usage: configure_setup_encrypted_patterns +configure_setup_encrypted_patterns() { + local repo="$1" + shift + local gitattributes="$repo/.gitattributes" + local pattern + local missing_patterns=() + + for pattern in "$@"; do + [ -z "$pattern" ] && continue + if ! setup_gitattributes_has_encrypted_pattern "$gitattributes" "$pattern"; then + missing_patterns+=("$pattern") + fi + done + + if [ ${#missing_patterns[@]} -eq 0 ]; then + echo "" + echo " Requested encrypted patterns already configured" + return 0 + fi + + echo "" + echo "Configuring encrypted patterns..." + for pattern in "${missing_patterns[@]}"; do + (cd "$repo" && rudi assign "$pattern") + if ! setup_gitattributes_has_encrypted_pattern "$gitattributes" "$pattern"; then + # rudi can treat a disabled existing assignment as already configured. + printf '%-40s filter=git-crypt diff=git-crypt\n' "$pattern" >> "$gitattributes" # codebase:ignore + fi + done + echo " Updated .gitattributes" +} + +# Print true when a file under the notes directory has a git-crypt header, +# otherwise false. Inspection failures preserve the backend exit status. +notes_tree_encryption_state() { + local notes_dir="$1" snapshot file find_status=0 found=false + if [ ! -d "$notes_dir" ]; then + printf 'false\n' + return 0 + fi + + snapshot=$(mktemp) || return 1 + find "$notes_dir" -type f ! -name .manifest -print0 > "$snapshot" || find_status=$? + if [ "$find_status" -ne 0 ]; then + rm -f "$snapshot" + return "$find_status" + fi + + while IFS= read -r -d '' file; do + if head -c 10 "$file" 2>/dev/null | grep -q "GITCRYPT"; then + found=true + break + fi + done < "$snapshot" + + rm -f "$snapshot" + printf '%s\n' "$found" +} diff --git a/lib/stage.sh b/lib/stage.sh new file mode 100644 index 0000000..c37cdf4 --- /dev/null +++ b/lib/stage.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# stage.sh — Git index mutations for readable note staging + +# Restore one tracked path to the exact index entry present before staging. +restore_tracked_note_index() { + local repo="$1" readable_path="$2" mode="$3" blob="$4" + if ! git -C "$repo" update-index --cacheinfo \ + "$mode" "$blob" "$readable_path"; then + echo "Error: failed to restore index entry after staging refusal: $readable_path" >&2 + return 1 + fi +} + +# Stage one readable note. Existing tracked plaintext paths are renormalized so +# a newly-added clean filter replaces their unchanged plaintext index blobs. +# Restore the prior entry if post-staging ciphertext verification refuses it. +stage_readable_note() { + local repo="$1" notes_dir="$2" relpath="$3" + local readable_path="$notes_dir/$relpath" + local tracked_readable=false + local original_entry original_metadata original_mode original_blob original_stage + + if git -C "$repo" ls-files --error-unmatch -- "$readable_path" >/dev/null 2>&1; then + tracked_readable=true + original_entry=$(git -C "$repo" ls-files --stage -- "$readable_path") || return 1 + original_metadata="${original_entry%%$'\t'*}" + read -r original_mode original_blob original_stage <<< "$original_metadata" + if [ "$original_stage" != "0" ] || [ -z "$original_blob" ]; then + echo "Error: tracked readable note has no ordinary index entry: $relpath" >&2 + return 1 + fi + git -C "$repo" add -f --renormalize -- "$readable_path" + else + git -C "$repo" add -f -- "$readable_path" + fi + + local index_blob head_blob="" blob_state blob_status=0 + if ! index_blob=$(git -C "$repo" rev-parse ":$readable_path" 2>/dev/null); then + echo "Error: staging did not create an index entry for: $relpath" >&2 + if $tracked_readable; then + restore_tracked_note_index \ + "$repo" "$readable_path" "$original_mode" "$original_blob" || return 1 + fi + return 1 + fi + if git -C "$repo" cat-file -e "HEAD:$readable_path" 2>/dev/null; then + head_blob=$(git -C "$repo" rev-parse "HEAD:$readable_path") + fi + if $tracked_readable && [ -n "$head_blob" ] && [ "$index_blob" = "$head_blob" ]; then + echo "Error: staging produced no index change for tracked readable note: $relpath" >&2 + echo "Ensure setup configured an active encryption filter, then retry." >&2 + restore_tracked_note_index \ + "$repo" "$readable_path" "$original_mode" "$original_blob" || return 1 + return 1 + fi + + if $tracked_readable; then + blob_state=$(git_blob_encryption_state "$repo" ":$readable_path") \ + || blob_status=$? + if [ "$blob_status" -ne 0 ]; then + echo "Error: failed to inspect staged note blob: $relpath" >&2 + restore_tracked_note_index \ + "$repo" "$readable_path" "$original_mode" "$original_blob" || return 1 + return "$blob_status" + fi + if [ "$blob_state" != "encrypted" ]; then + echo "Error: staged tracked readable note is not git-crypt encrypted: $relpath" >&2 + echo "Ensure setup configured an active encryption filter, then retry." >&2 + restore_tracked_note_index \ + "$repo" "$readable_path" "$original_mode" "$original_blob" || return 1 + return 1 + fi + fi + + echo " staged: $relpath" +} + +# Remove one deleted note's indexed ID and stage its manifest repair. Preserve +# the working manifest if staging the repair fails. +stage_deleted_note() { + local repo="$1" notes_dir="$2" manifest="$3" relpath="$4" + local id tmp_manifest manifest_backup + + id=$(manifest_id_for_name "$manifest" "$relpath") + [ -n "$id" ] || return 0 + + if git -C "$repo" ls-files --error-unmatch -- "$notes_dir/$id" >/dev/null 2>&1; then + git -C "$repo" rm --cached --quiet "$notes_dir/$id" + fi + + tmp_manifest=$(mktemp) || return 1 + manifest_backup=$(mktemp) || { + rm -f "$tmp_manifest" + return 1 + } + cp "$manifest" "$manifest_backup" + awk -F '\t' -v path="$relpath" '$2 != path { print $0 }' \ + "$manifest" > "$tmp_manifest" + mv -f "$tmp_manifest" "$manifest" + + if ! git -C "$repo" add -f "$notes_dir/.manifest"; then + mv -f "$manifest_backup" "$manifest" + return 1 + fi + + rm -f "$manifest_backup" + echo " staged (delete): $relpath" +} diff --git a/test/encrypt.bats b/test/encrypt.bats index cc10d8e..befb8fd 100644 --- a/test/encrypt.bats +++ b/test/encrypt.bats @@ -2,6 +2,43 @@ load test_helper +commit_tracked_plaintext_notes() { + mkdir -p "$TARGET_DIR/notes" + printf '# Existing alpha\n' > "$TARGET_DIR/notes/alpha.md" + printf '# Existing beta\n' > "$TARGET_DIR/notes/beta.md" + git -C "$TARGET_DIR" add notes + git -C "$TARGET_DIR" commit -q -m "add plaintext notes" +} + +setup_locked_rudi_overlay() { + LOCKED_RUDI_BIN="$BATS_TEST_TMPDIR/locked-rudi-bin" + mkdir -p "$LOCKED_RUDI_BIN" + cat > "$LOCKED_RUDI_BIN/rudi" <<'SH' +#!/usr/bin/env bash +case "${1:-}" in + status) + printf '{"unlocked":false}\n' + ;; + unlock) + if [ -n "$(git -C "${RUDI_CALLER_PWD:?}" status --porcelain)" ]; then + echo "unlock saw dirty worktree" >&2 + exit 73 + fi + touch "$RUDI_CALLER_PWD/.unlock-ran-clean" + ;; + assign) + printf '%-40s filter=git-crypt diff=git-crypt\n' "$2" \ + >> "$RUDI_CALLER_PWD/.gitattributes" + ;; + *) + echo "unexpected rudi command: $*" >&2 + exit 99 + ;; +esac +SH + chmod +x "$LOCKED_RUDI_BIN/rudi" +} + setup_failing_encrypted_note_enumeration_overlay() { FAILING_FIND_BIN="$BATS_TEST_TMPDIR/failing-encrypted-note-find" local real_find @@ -63,6 +100,22 @@ SH [[ "$output" == *"Requested encrypted patterns already configured"* ]] } +@test "setup rerun does not classify locked managed blobs as plaintext" { + mkdir -p "$TARGET_DIR/.git/git-crypt" "$TARGET_DIR/notes" + printf '\0GITCRYPT\0aaa00001\talpha.md\n' > "$TARGET_DIR/notes/.manifest" + printf '\0GITCRYPT\0encrypted alpha\n' > "$TARGET_DIR/notes/aaa00001" + git -C "$TARGET_DIR" add notes + printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$TARGET_DIR/.gitattributes" + git -C "$TARGET_DIR" add .gitattributes + git -C "$TARGET_DIR" commit -q -m "add locked managed notes" + + run notes setup --yes + + [ "$status" -eq 0 ] + [[ "$output" != *"tracked plaintext note(s)"* ]] + [[ "$output" == *"This repo already has encrypted notes."* ]] +} + @test "setup adds notes pattern when other git-crypt patterns already exist" { git -C "$TARGET_DIR" crypt init echo ".modules/manifest filter=git-crypt diff=git-crypt" > "$TARGET_DIR/.gitattributes" @@ -93,6 +146,49 @@ SH grep -Eq "^notes/private/\*\*[[:space:]]+filter=git-crypt" "$TARGET_DIR/.gitattributes" } +@test "setup refuses dirty tracked plaintext onboarding before mutation" { + commit_tracked_plaintext_notes + printf '# Local edit\n' >> "$TARGET_DIR/notes/alpha.md" + + run notes setup --yes + + [ "$status" -ne 0 ] + [[ "$output" == *"requires a clean worktree"* ]] + [ ! -d "$TARGET_DIR/.git/git-crypt" ] + [ ! -f "$TARGET_DIR/.gitattributes" ] + [ ! -f "$TARGET_DIR/notes/.manifest" ] +} + +@test "setup refuses sparse tracked plaintext onboarding before mutation" { + commit_tracked_plaintext_notes + git -C "$TARGET_DIR" sparse-checkout init --no-cone + git -C "$TARGET_DIR" sparse-checkout set --no-cone \ + '/*' '!/*/' '/notes/alpha.md' + [ -f "$TARGET_DIR/notes/alpha.md" ] + [ ! -e "$TARGET_DIR/notes/beta.md" ] + + run notes setup --yes + + [ "$status" -ne 0 ] + [[ "$output" == *"not available as a regular worktree file"* ]] + [[ "$output" == *"expand or disable sparse checkout"* ]] + [ ! -d "$TARGET_DIR/.git/git-crypt" ] + [ ! -f "$TARGET_DIR/.gitattributes" ] + [ ! -f "$TARGET_DIR/notes/.manifest" ] +} + +@test "setup --unlock unlocks existing repo before setup mutation" { + commit_tracked_plaintext_notes + mkdir -p "$TARGET_DIR/.git/git-crypt" + setup_locked_rudi_overlay + + PATH="$LOCKED_RUDI_BIN:$PATH" run notes setup --yes --unlock + + [ "$status" -eq 0 ] + [ -f "$TARGET_DIR/.unlock-ran-clean" ] + grep -Eq "^notes/\*\*[[:space:]]+filter=git-crypt" "$TARGET_DIR/.gitattributes" +} + @test "setup with custom dir writes manifest and default encrypted pattern there" { run notes setup --yes --dir private-notes [ "$status" -eq 0 ] @@ -216,6 +312,69 @@ generate_test_key() { grep -q "existing.md" "$TARGET_DIR/notes/.manifest" } +@test "setup and stage forward-encrypt tracked plaintext notes" { + commit_tracked_plaintext_notes + + run notes setup --yes + [ "$status" -eq 0 ] + [[ "$output" == *"Preparing 2 tracked plaintext note(s)"* ]] + [[ "$output" == *"notes stage --all"* ]] + + git -C "$TARGET_DIR" add .gitattributes + run notes stage --all + [ "$status" -eq 0 ] + [[ "$output" == *"staged: alpha.md"* ]] + [[ "$output" == *"staged: beta.md"* ]] + + git -C "$TARGET_DIR" show :notes/alpha.md > "$BATS_TEST_TMPDIR/staged-alpha" + grep -a -q "GITCRYPT" "$BATS_TEST_TMPDIR/staged-alpha" + + git -C "$TARGET_DIR" commit -q -m "onboard encrypted notes" + + local alpha_id + alpha_id=$(manifest_id_for_name "$TARGET_DIR/notes/.manifest" "alpha.md") + [ -n "$alpha_id" ] + ! git -C "$TARGET_DIR" ls-files --error-unmatch notes/alpha.md >/dev/null 2>&1 + git -C "$TARGET_DIR" ls-files --error-unmatch "notes/$alpha_id" >/dev/null + + run notes verify-blobs --ref HEAD --strict + [ "$status" -eq 0 ] + run git -C "$TARGET_DIR" show HEAD~1:notes/alpha.md + [ "$output" = "# Existing alpha" ] +} + +@test "setup onboards plaintext notes beside existing encrypted infrastructure" { + local fpr="1111111111111111111111111111111111111111" + + run notes setup --yes --pattern ".modules/manifest" + [ "$status" -eq 0 ] + rm -f "$TARGET_DIR/notes/.manifest" + mkdir -p "$TARGET_DIR/.git-crypt/keys/default/0" + printf 'existing resident key record\n' \ + > "$TARGET_DIR/.git-crypt/keys/default/0/$fpr.gpg" + + mkdir -p "$TARGET_DIR/.modules" "$TARGET_DIR/notes" + printf 'fold = main\n' > "$TARGET_DIR/.modules/manifest" + printf '# Existing home note\n' > "$TARGET_DIR/notes/home.md" + git -C "$TARGET_DIR" add .gitattributes .git-crypt .modules/manifest notes/home.md + git -C "$TARGET_DIR" commit -q --no-verify -m "add existing home state" + + run notes setup --yes + [ "$status" -eq 0 ] + [[ "$output" == *"Preparing 1 tracked plaintext note(s)"* ]] + + git -C "$TARGET_DIR" add .gitattributes + run notes stage --all + [ "$status" -eq 0 ] + [[ "$output" == *"staged: home.md"* ]] + git -C "$TARGET_DIR" commit -q -m "onboard home notes" + + run notes verify-blobs --ref HEAD --strict + [ "$status" -eq 0 ] + run git -C "$TARGET_DIR" show HEAD~1:notes/home.md + [ "$output" = "# Existing home note" ] +} + # --- setup next-steps --- @test "setup shows unlock hint when repo has encrypted notes" { diff --git a/test/stage.bats b/test/stage.bats index f7e4047..213b3ef 100644 --- a/test/stage.bats +++ b/test/stage.bats @@ -29,6 +29,51 @@ setup() { [[ "$output" == *"alpha.md"* ]] } +@test "notes stage fails when a tracked readable path produces no index change" { + echo "# Legacy plaintext" > "$NOTES_CALLER_PWD/notes/legacy.md" + git -C "$NOTES_CALLER_PWD" add -f notes/legacy.md + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "legacy plaintext note" + + run notes stage legacy.md + + [ "$status" -ne 0 ] + [[ "$output" == *"staging produced no index change"* ]] + [[ "$output" == *"legacy.md"* ]] +} + +@test "notes stage restores a changed tracked blob that remains plaintext" { + echo "# Legacy plaintext" > "$NOTES_CALLER_PWD/notes/legacy.md" + git -C "$NOTES_CALLER_PWD" add -f notes/legacy.md + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "legacy plaintext note" + + local original_blob + original_blob=$(git -C "$NOTES_CALLER_PWD" rev-parse :notes/legacy.md) + local clean_filter="$BATS_TEST_TMPDIR/plaintext-clean-filter" + cat > "$clean_filter" <<'SH' +#!/usr/bin/env bash +printf 'FILTERED: ' +cat +SH + chmod +x "$clean_filter" + git -C "$NOTES_CALLER_PWD" config filter.plaintext-clean.clean "$clean_filter" + git -C "$NOTES_CALLER_PWD" config filter.plaintext-clean.smudge cat + git -C "$NOTES_CALLER_PWD" config filter.plaintext-clean.required true + printf 'notes/legacy.md filter=plaintext-clean\n' > "$NOTES_CALLER_PWD/.gitattributes" + printf '# Changed legacy plaintext\n' > "$NOTES_CALLER_PWD/notes/legacy.md" + + run notes stage legacy.md + + [ "$status" -ne 0 ] + [[ "$output" == *"not git-crypt encrypted"* ]] + [[ "$output" == *"legacy.md"* ]] + run git -C "$NOTES_CALLER_PWD" rev-parse :notes/legacy.md + [ "$output" = "$original_blob" ] + run git -C "$NOTES_CALLER_PWD" diff --cached --name-only -- notes/legacy.md + [ -z "$output" ] + run git -C "$NOTES_CALLER_PWD" show :notes/legacy.md + [ "$output" = "# Legacy plaintext" ] +} + @test "notes stage: no args requires explicit scope" { echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md" echo "# Gamma" > "$NOTES_CALLER_PWD/notes/gamma.md"