From 0ce0909f4087ed88478f30eb470a3227d8d2f1aa Mon Sep 17 00:00:00 2001 From: johnson Date: Wed, 29 Jul 2026 13:52:15 -0400 Subject: [PATCH 1/2] fix: refuse change inspection while locked --- .mise/tasks/changes | 16 +++++++++++++++ README.md | 2 +- test/changes.bats | 48 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/.mise/tasks/changes b/.mise/tasks/changes index be82b10..515be80 100755 --- a/.mise/tasks/changes +++ b/.mise/tasks/changes @@ -20,6 +20,22 @@ if [ ! -f "$manifest" ]; then exit 1 fi +# A locked manifest is encrypted binary data. Do not interpret it as a readable +# ID-to-path map or report its opaque files as new notes. Check the exact path: +# rudi can report initialized=false for a locked clone that has attributes but +# does not carry a local key. +manifest_attr=$(git -C "$TARGET_DIR" check-attr filter -- "$notes_dir/.manifest") +manifest_filter="${manifest_attr##*: }" +if [ "$manifest_filter" = "git-crypt" ]; then + require_rudi + rudi_json=$(cd "$TARGET_DIR" && rudi status --json) + enc_unlocked=$(printf '%s\n' "$rudi_json" | jq -r '.unlocked') + if [ "$enc_unlocked" != "true" ]; then + echo "Error: git-crypt is locked. Run 'notes unlock' first." >&2 + exit 1 + fi +fi + # Parse variadic args ARGS=() if [ -n "${usage_files:-}" ]; then diff --git a/README.md b/README.md index fca40a0..fe83986 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 459](https://img.shields.io/badge/tests-459-brightgreen?style=flat)](test/) +[![tests: 461](https://img.shields.io/badge/tests-461-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/test/changes.bats b/test/changes.bats index 22e98bd..81e8475 100644 --- a/test/changes.bats +++ b/test/changes.bats @@ -42,8 +42,56 @@ SH chmod +x "$FAILING_CMP_BIN/cmp" } +setup_rudi_status_overlay() { + RUDI_STATUS_BIN="$BATS_TEST_TMPDIR/rudi-status-bin" + mkdir -p "$RUDI_STATUS_BIN" + cat > "$RUDI_STATUS_BIN/rudi" <<'SH' +#!/usr/bin/env bash +if [ "$#" -eq 2 ] && [ "$1" = "status" ] && [ "$2" = "--json" ]; then + printf '%s\n' "${RUDI_STATUS_JSON:?}" + exit 0 +fi +echo "unexpected rudi invocation: $*" >&2 +exit 73 +SH + chmod +x "$RUDI_STATUS_BIN/rudi" +} + # ── detect_changes ──────────────────────────────────────────── +@test "notes changes refuses locked encrypted content before classifying paths" { + setup_rudi_status_overlay + printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes" + printf '\0GITCRYPT\0encrypted manifest bytes\n' > "$MANIFEST" + printf '\0GITCRYPT\0encrypted note bytes\n' > "$NOTES_CALLER_PWD/notes/aaaaaaaa" + + RUDI_STATUS_JSON='{"initialized":true,"unlocked":false}' \ + PATH="$RUDI_STATUS_BIN:$PATH" run notes changes --summary + [ "$status" -ne 0 ] + [[ "$output" == *"git-crypt is locked"* ]] + [[ "$output" == *"notes unlock"* ]] + [[ "$output" != *"aaaaaaaa"* ]] + + # A keyless clone can report initialized=false even though the encrypted + # manifest is present and unreadable. + RUDI_STATUS_JSON='{"initialized":false,"unlocked":false}' \ + PATH="$RUDI_STATUS_BIN:$PATH" run notes changes + [ "$status" -ne 0 ] + [[ "$output" == *"git-crypt is locked"* ]] + [[ "$output" != *"aaaaaaaa"* ]] +} + +@test "notes changes preserves clean output when encryption is unlocked" { + setup_rudi_status_overlay + printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes" + + RUDI_STATUS_JSON='{"initialized":true,"unlocked":true}' \ + PATH="$RUDI_STATUS_BIN:$PATH" run notes changes --summary + + [ "$status" -eq 0 ] + [ "$output" = "No changes." ] +} + @test "detect_changes: no changes when files match HEAD" { run detect_changes "$NOTES_CALLER_PWD/notes" [ "$status" -eq 0 ] From 53fd27a1fd6f20cbc86f5cb6ad996dcf8d29cc96 Mon Sep 17 00:00:00 2001 From: junior Date: Wed, 29 Jul 2026 17:22:04 -0400 Subject: [PATCH 2/2] fix: guard readable note state centrally --- .mise/tasks/audit | 8 +++ .mise/tasks/changes | 16 ----- .mise/tasks/commit | 1 + .mise/tasks/conflicts | 1 + .mise/tasks/diff | 1 + .mise/tasks/list | 2 + .mise/tasks/merge | 3 +- .mise/tasks/new | 3 + .mise/tasks/parse | 8 +++ .mise/tasks/pull | 11 +++- .mise/tasks/search | 2 + .mise/tasks/show | 2 + README.md | 2 +- lib/changes.sh | 1 + lib/common.sh | 1 + lib/obfuscate.sh | 3 + lib/readable-state.sh | 100 ++++++++++++++++++++++++++++++ lib/suppress.sh | 4 ++ test/changes.bats | 26 +------- test/readable-state.bats | 131 +++++++++++++++++++++++++++++++++++++++ 20 files changed, 282 insertions(+), 44 deletions(-) create mode 100755 lib/readable-state.sh create mode 100644 test/readable-state.bats diff --git a/.mise/tasks/audit b/.mise/tasks/audit index 21f532e..1c01fd7 100755 --- a/.mise/tasks/audit +++ b/.mise/tasks/audit @@ -9,6 +9,7 @@ from __future__ import annotations import os +import subprocess import sys from pathlib import Path @@ -25,6 +26,13 @@ def main() -> int: print(f"Error: notes directory not found: {notes_dir}", file=sys.stderr) return 1 + readable = subprocess.run( + [str(repo_dir / "lib" / "readable-state.sh"), "require", str(notes_dir)], + check=False, + ) + if readable.returncode != 0: + return readable.returncode + try: top_n = parse_top(os.environ.get("usage_top", "10")) except ValueError as exc: diff --git a/.mise/tasks/changes b/.mise/tasks/changes index 515be80..be82b10 100755 --- a/.mise/tasks/changes +++ b/.mise/tasks/changes @@ -20,22 +20,6 @@ if [ ! -f "$manifest" ]; then exit 1 fi -# A locked manifest is encrypted binary data. Do not interpret it as a readable -# ID-to-path map or report its opaque files as new notes. Check the exact path: -# rudi can report initialized=false for a locked clone that has attributes but -# does not carry a local key. -manifest_attr=$(git -C "$TARGET_DIR" check-attr filter -- "$notes_dir/.manifest") -manifest_filter="${manifest_attr##*: }" -if [ "$manifest_filter" = "git-crypt" ]; then - require_rudi - rudi_json=$(cd "$TARGET_DIR" && rudi status --json) - enc_unlocked=$(printf '%s\n' "$rudi_json" | jq -r '.unlocked') - if [ "$enc_unlocked" != "true" ]; then - echo "Error: git-crypt is locked. Run 'notes unlock' first." >&2 - exit 1 - fi -fi - # Parse variadic args ARGS=() if [ -n "${usage_files:-}" ]; then diff --git a/.mise/tasks/commit b/.mise/tasks/commit index a6aef02..711a9d0 100755 --- a/.mise/tasks/commit +++ b/.mise/tasks/commit @@ -24,6 +24,7 @@ if [ ! -f "$manifest" ]; then echo "No manifest found. Run 'notes obfuscate' first." >&2 exit 1 fi +require_readable_notes_state "$abs_notes_dir" ARGS=() if [ -n "${usage_files:-}" ]; then diff --git a/.mise/tasks/conflicts b/.mise/tasks/conflicts index 9183c2f..9cd8125 100755 --- a/.mise/tasks/conflicts +++ b/.mise/tasks/conflicts @@ -11,6 +11,7 @@ require_git notes_dir="${usage_dir:-notes}" out_arg="${usage_out:-}" repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel) +require_readable_notes_state "$repo_root/$notes_dir" if ! records=$(notes_conflict_records "$repo_root" "$notes_dir"); then exit 1 diff --git a/.mise/tasks/diff b/.mise/tasks/diff index e0f9694..d6e1a5d 100755 --- a/.mise/tasks/diff +++ b/.mise/tasks/diff @@ -16,6 +16,7 @@ pr_number="${usage_pr:-}" out_arg="${usage_out:-}" abs_notes_dir="$TARGET_DIR/$notes_dir" repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel) +require_readable_notes_state "$abs_notes_dir" ARGS=() if [ -n "${usage_refs:-}" ]; then diff --git a/.mise/tasks/list b/.mise/tasks/list index becae19..ff940b9 100755 --- a/.mise/tasks/list +++ b/.mise/tasks/list @@ -9,6 +9,7 @@ set -euo pipefail REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}" +source "$REPO_DIR/lib/readable-state.sh" TARGET_DIR="${NOTES_CALLER_PWD:-.}" notes_dir="$TARGET_DIR/${usage_dir:-notes}" @@ -16,6 +17,7 @@ if [ ! -d "$notes_dir" ]; then echo "Error: notes directory not found: $notes_dir" >&2 exit 1 fi +require_readable_notes_state "$notes_dir" python3 - "$REPO_DIR" "$notes_dir" "${usage_json:-false}" "${usage_recent:-}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" <<'PY' import json diff --git a/.mise/tasks/merge b/.mise/tasks/merge index 6335a35..abb1aea 100755 --- a/.mise/tasks/merge +++ b/.mise/tasks/merge @@ -14,7 +14,8 @@ if [ "${usage_dry_run:-false}" != "true" ]; then exit 1 fi -args=(conflicts --dir "${usage_dir:-notes}") +notes_dir="${usage_dir:-notes}" +args=(conflicts --dir "$notes_dir") if [ -n "${usage_out:-}" ]; then args+=(--out "${usage_out}") fi diff --git a/.mise/tasks/new b/.mise/tasks/new index 015ab72..35e5182 100755 --- a/.mise/tasks/new +++ b/.mise/tasks/new @@ -10,6 +10,8 @@ #USAGE flag "--dir " default="notes" help="Notes directory relative to repo root" set -euo pipefail +REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}" +source "$REPO_DIR/lib/readable-state.sh" TARGET_DIR="${NOTES_CALLER_PWD:-.}" notes_dir="$TARGET_DIR/${usage_dir:-notes}" today=$(date +%Y-%m-%d) @@ -18,6 +20,7 @@ if [ ! -d "$notes_dir" ]; then echo "Error: notes directory not found: $notes_dir" >&2 exit 1 fi +require_readable_notes_state "$notes_dir" file="$notes_dir/${usage_slug}.md" if [ -f "$file" ]; then diff --git a/.mise/tasks/parse b/.mise/tasks/parse index 8e02fd0..b800395 100755 --- a/.mise/tasks/parse +++ b/.mise/tasks/parse @@ -9,6 +9,7 @@ from __future__ import annotations import json import os +import subprocess import sys from pathlib import Path @@ -45,6 +46,13 @@ def existing_path_candidates(selector: str) -> list[Path]: def main() -> int: + readable = subprocess.run( + [str(repo_dir / "lib" / "readable-state.sh"), "require", str(notes_dir)], + check=False, + ) + if readable.returncode != 0: + return readable.returncode + note_path = None for candidate in existing_path_candidates(selector): if candidate.is_file(): diff --git a/.mise/tasks/pull b/.mise/tasks/pull index 72332a7..c3e4475 100755 --- a/.mise/tasks/pull +++ b/.mise/tasks/pull @@ -16,12 +16,16 @@ abs_notes_dir="$(cd "$TARGET_DIR/$notes_dir" 2>/dev/null && pwd)" || { exit 1 } manifest="$abs_notes_dir/.manifest" +readable_state=$(notes_readable_state "$abs_notes_dir") +if [ "$readable_state" = "locked" ]; then + echo "Notes are locked; skipping readable manifest checks and reconciliation." +fi # --- Check for assume-unchanged manifest --- # git pull can fail when notes/.manifest is assume-unchanged but differs # between local HEAD and upstream. Detect this early and repair. -if [ -f "$manifest" ]; then +if [ "$readable_state" = "readable" ] && [ -f "$manifest" ]; then manifest_rc=0 NOTES_QUIET_MANIFEST_CHECK=1 detect_assume_unchanged_manifest "$notes_dir" || manifest_rc=$? @@ -48,8 +52,9 @@ fi # --- Determine obfuscation state --- # We need to know if we're deobfuscated so we can re-apply suppression after pull. -obf_status="unknown" -if [ -f "$manifest" ]; then +obf_status="$readable_state" +if [ "$readable_state" = "readable" ] && [ -f "$manifest" ]; then + obf_status="unknown" first_name=$(head -1 "$manifest" | cut -f2) first_hash=$(head -1 "$manifest" | cut -f1) if [ -n "$first_name" ] && [ -f "$abs_notes_dir/$first_name" ]; then diff --git a/.mise/tasks/search b/.mise/tasks/search index eb4b2ac..93cdc3b 100755 --- a/.mise/tasks/search +++ b/.mise/tasks/search @@ -10,6 +10,7 @@ set -euo pipefail REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}" +source "$REPO_DIR/lib/readable-state.sh" TARGET_DIR="${NOTES_CALLER_PWD:-.}" notes_dir="$TARGET_DIR/${usage_dir:-notes}" @@ -17,6 +18,7 @@ if [ ! -d "$notes_dir" ]; then echo "Error: notes directory not found: $notes_dir" >&2 exit 1 fi +require_readable_notes_state "$notes_dir" python3 - "$REPO_DIR" "$notes_dir" "${usage_query:-}" "${usage_json:-false}" "${usage_tag:-}" "${usage_type:-}" "${usage_status:-}" "${usage_limit:-50}" <<'PY' import json diff --git a/.mise/tasks/show b/.mise/tasks/show index a50401c..f0812de 100755 --- a/.mise/tasks/show +++ b/.mise/tasks/show @@ -6,6 +6,7 @@ set -euo pipefail REPO_DIR="${MISE_CONFIG_ROOT:?MISE_CONFIG_ROOT not set}" +source "$REPO_DIR/lib/readable-state.sh" TARGET_DIR="${NOTES_CALLER_PWD:-.}" notes_dir="$TARGET_DIR/${usage_dir:-notes}" @@ -13,6 +14,7 @@ if [ ! -d "$notes_dir" ]; then echo "Error: notes directory not found: $notes_dir" >&2 exit 1 fi +require_readable_notes_state "$notes_dir" python3 - "$REPO_DIR" "$TARGET_DIR" "$notes_dir" "${usage_note:-}" "${usage_json:-false}" <<'PY' import json diff --git a/README.md b/README.md index fe83986..0f65267 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 461](https://img.shields.io/badge/tests-461-brightgreen?style=flat)](test/) +[![tests: 467](https://img.shields.io/badge/tests-467-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/changes.sh b/lib/changes.sh index 92b34ed..9295129 100644 --- a/lib/changes.sh +++ b/lib/changes.sh @@ -243,6 +243,7 @@ detect_changes() { resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" local notes_dir="$RESOLVED_NOTES_DIR" + require_readable_notes_state "$abs_notes_dir" || return local workspace rc=0 workspace=$(mktemp -d) || return : > "$workspace/detected" diff --git a/lib/common.sh b/lib/common.sh index f059a72..737fb0d 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -15,6 +15,7 @@ TARGET_DIR="${NOTES_CALLER_PWD:-.}" NOTES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" NOTES_REPO_DIR="$(cd "$NOTES_LIB_DIR/.." && pwd)" HOOKS_DIR="$NOTES_REPO_DIR/hooks" +source "$NOTES_LIB_DIR/readable-state.sh" # ── Require checks ──────────────────────────────────────────── diff --git a/lib/obfuscate.sh b/lib/obfuscate.sh index d64a5bc..5a11ad8 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -104,6 +104,7 @@ build_obfuscation_plan() { local manifest="$notes_dir/.manifest" local workspace candidates manifest_input rc=0 + require_readable_notes_state "$notes_dir" || return workspace=$(mktemp -d) || { echo "Error: failed to create obfuscation workspace" >&2 return 1 @@ -158,6 +159,7 @@ apply_obfuscation_plan() { local manifest="$notes_dir/.manifest" local new_entries kind id relpath new_count=0 + require_readable_notes_state "$notes_dir" || return [ -s "$plan_file" ] || return 2 new_entries=$(mktemp) || { @@ -511,6 +513,7 @@ rename_to_readable() { local manifest="$notes_dir/.manifest" local count=0 dirty_count=0 + require_readable_notes_state "$notes_dir" || return [ ! -f "$manifest" ] && return 1 # _rename_one_to_readable returns: 0=renamed, 2=skipped, diff --git a/lib/readable-state.sh b/lib/readable-state.sh new file mode 100755 index 0000000..0908028 --- /dev/null +++ b/lib/readable-state.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# readable-state.sh — classify whether managed note content is readable + +_GIT_CRYPT_HEADER_HEX="00474954435259505400" + +# Print "readable" or "locked" for a notes directory. +# Missing/non-Git/plaintext note directories remain readable so their owning +# commands can preserve their existing validation and initialization behavior. +notes_readable_state() { + local notes_dir="${1:?usage: notes_readable_state }" + local abs_notes_dir repo_root rel_notes_dir manifest attr filter header + + if [ ! -d "$notes_dir" ]; then + printf '%s\n' "readable" + return 0 + fi + if ! abs_notes_dir=$(cd "$notes_dir" 2>/dev/null && pwd -P); then + echo "Error: failed to resolve notes directory: $notes_dir" >&2 + return 2 + fi + if ! repo_root=$(git -C "$abs_notes_dir" rev-parse --show-toplevel 2>/dev/null); then + printf '%s\n' "readable" + return 0 + fi + + case "$abs_notes_dir" in + "$repo_root"/*) rel_notes_dir=${abs_notes_dir#"$repo_root"/} ;; + *) + echo "Error: notes directory is outside its Git repository: $abs_notes_dir" >&2 + return 2 + ;; + esac + + manifest="$abs_notes_dir/.manifest" + if [ ! -f "$manifest" ]; then + printf '%s\n' "readable" + return 0 + fi + + if ! attr=$(git -C "$repo_root" check-attr filter -- "$rel_notes_dir/.manifest"); then + echo "Error: failed to inspect encryption attributes for $rel_notes_dir/.manifest" >&2 + return 2 + fi + filter=${attr##*: } + if [ "$filter" != "git-crypt" ]; then + printf '%s\n' "readable" + return 0 + fi + + if ! header=$(LC_ALL=C od -An -tx1 -N10 "$manifest" 2>/dev/null); then + echo "Error: failed to inspect encrypted manifest header: $manifest" >&2 + return 2 + fi + header=$(printf '%s' "$header" | tr -d '[:space:]') + + if [ "$header" = "$_GIT_CRYPT_HEADER_HEX" ]; then + printf '%s\n' "locked" + else + printf '%s\n' "readable" + fi +} + +require_readable_notes_state() { + local notes_dir="${1:?usage: require_readable_notes_state }" + local state status=0 + + state=$(notes_readable_state "$notes_dir") || status=$? + if [ "$status" -ne 0 ]; then + return "$status" + fi + if [ "$state" = "locked" ]; then + echo "Error: git-crypt is locked. Run 'notes unlock' first." >&2 + return 1 + fi +} + +_readable_state_main() { + local command="${1:-}" + local notes_dir="${2:-}" + + case "$command" in + probe) + [ -n "$notes_dir" ] || { echo "usage: readable-state.sh probe " >&2; return 64; } + notes_readable_state "$notes_dir" + ;; + require) + [ -n "$notes_dir" ] || { echo "usage: readable-state.sh require " >&2; return 64; } + require_readable_notes_state "$notes_dir" + ;; + *) + echo "usage: readable-state.sh " >&2 + return 64 + ;; + esac +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + set -uo pipefail + _readable_state_main "$@" +fi diff --git a/lib/suppress.sh b/lib/suppress.sh index 8b39b54..2ea40b1 100644 --- a/lib/suppress.sh +++ b/lib/suppress.sh @@ -232,6 +232,7 @@ set_status_suppression() { local scoped_ids=("$@") local manifest="$abs_notes_dir/.manifest" [ ! -f "$manifest" ] && return + require_readable_notes_state "$abs_notes_dir" || return resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" @@ -265,6 +266,7 @@ clear_status_suppression() { local scoped_ids=("$@") local manifest="$abs_notes_dir/.manifest" [ ! -f "$manifest" ] && return + require_readable_notes_state "$abs_notes_dir" || return resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" @@ -346,6 +348,7 @@ detect_stale_readable_notes() { local abs_notes_dir="${1:?usage: detect_stale_readable_notes }" local manifest="$abs_notes_dir/.manifest" [ -f "$manifest" ] || return 0 + require_readable_notes_state "$abs_notes_dir" || return resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" @@ -495,6 +498,7 @@ rebuild_status_suppression() { local abs_notes_dir="${1:?usage: rebuild_status_suppression }" local manifest="$abs_notes_dir/.manifest" [ -f "$manifest" ] || return 0 + require_readable_notes_state "$abs_notes_dir" || return resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" diff --git a/test/changes.bats b/test/changes.bats index 81e8475..e8808d6 100644 --- a/test/changes.bats +++ b/test/changes.bats @@ -42,31 +42,14 @@ SH chmod +x "$FAILING_CMP_BIN/cmp" } -setup_rudi_status_overlay() { - RUDI_STATUS_BIN="$BATS_TEST_TMPDIR/rudi-status-bin" - mkdir -p "$RUDI_STATUS_BIN" - cat > "$RUDI_STATUS_BIN/rudi" <<'SH' -#!/usr/bin/env bash -if [ "$#" -eq 2 ] && [ "$1" = "status" ] && [ "$2" = "--json" ]; then - printf '%s\n' "${RUDI_STATUS_JSON:?}" - exit 0 -fi -echo "unexpected rudi invocation: $*" >&2 -exit 73 -SH - chmod +x "$RUDI_STATUS_BIN/rudi" -} - # ── detect_changes ──────────────────────────────────────────── @test "notes changes refuses locked encrypted content before classifying paths" { - setup_rudi_status_overlay printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes" printf '\0GITCRYPT\0encrypted manifest bytes\n' > "$MANIFEST" printf '\0GITCRYPT\0encrypted note bytes\n' > "$NOTES_CALLER_PWD/notes/aaaaaaaa" - RUDI_STATUS_JSON='{"initialized":true,"unlocked":false}' \ - PATH="$RUDI_STATUS_BIN:$PATH" run notes changes --summary + run notes changes --summary [ "$status" -ne 0 ] [[ "$output" == *"git-crypt is locked"* ]] [[ "$output" == *"notes unlock"* ]] @@ -74,19 +57,16 @@ SH # A keyless clone can report initialized=false even though the encrypted # manifest is present and unreadable. - RUDI_STATUS_JSON='{"initialized":false,"unlocked":false}' \ - PATH="$RUDI_STATUS_BIN:$PATH" run notes changes + run notes changes [ "$status" -ne 0 ] [[ "$output" == *"git-crypt is locked"* ]] [[ "$output" != *"aaaaaaaa"* ]] } @test "notes changes preserves clean output when encryption is unlocked" { - setup_rudi_status_overlay printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes" - RUDI_STATUS_JSON='{"initialized":true,"unlocked":true}' \ - PATH="$RUDI_STATUS_BIN:$PATH" run notes changes --summary + run notes changes --summary [ "$status" -eq 0 ] [ "$output" = "No changes." ] diff --git a/test/readable-state.bats b/test/readable-state.bats new file mode 100644 index 0000000..2fdabbd --- /dev/null +++ b/test/readable-state.bats @@ -0,0 +1,131 @@ +#!/usr/bin/env bats + +load test_helper +source "$REPO_DIR/lib/changes.sh" + +setup() { + export NOTES_CALLER_PWD="$BATS_TEST_TMPDIR/locked-repo" + git -C "$BATS_TEST_TMPDIR" init -q -b main locked-repo + git -C "$NOTES_CALLER_PWD" config user.name "Notes tests" + git -C "$NOTES_CALLER_PWD" config user.email "notes-tests@example.invalid" + git -C "$NOTES_CALLER_PWD" config filter.git-crypt.clean cat + git -C "$NOTES_CALLER_PWD" config filter.git-crypt.smudge cat + mkdir -p "$NOTES_CALLER_PWD/notes" + printf 'notes/** filter=git-crypt diff=git-crypt\n' > "$NOTES_CALLER_PWD/.gitattributes" + printf '\0GITCRYPT\0encrypted manifest bytes\n' > "$NOTES_CALLER_PWD/notes/.manifest" + printf '\0GITCRYPT\0encrypted note bytes\n' > "$NOTES_CALLER_PWD/notes/aaaaaaaa" + git -C "$NOTES_CALLER_PWD" add .gitattributes notes/.manifest notes/aaaaaaaa + git -C "$NOTES_CALLER_PWD" commit -q -m "locked fixture" +} + +assert_locked_failure() { + [ "$status" -ne 0 ] + [[ "$output" == *"git-crypt is locked"* ]] + [[ "$output" == *"notes unlock"* ]] + [[ "$output" != *"aaaaaaaa"* ]] +} + +@test "readable-state classifies encrypted and plaintext managed manifests" { + run "$REPO_DIR/lib/readable-state.sh" probe "$NOTES_CALLER_PWD/notes" + [ "$status" -eq 0 ] + [ "$output" = "locked" ] + + printf 'aaaaaaaa\talpha.md\n' > "$NOTES_CALLER_PWD/notes/.manifest" + run "$REPO_DIR/lib/readable-state.sh" probe "$NOTES_CALLER_PWD/notes" + [ "$status" -eq 0 ] + [ "$output" = "readable" ] + + printf '\0GITCRYPT\0encrypted manifest bytes\n' > "$NOTES_CALLER_PWD/notes/.manifest" + printf 'notes/** -filter\n' > "$NOTES_CALLER_PWD/.gitattributes" + run "$REPO_DIR/lib/readable-state.sh" probe "$NOTES_CALLER_PWD/notes" + [ "$status" -eq 0 ] + [ "$output" = "readable" ] +} + +@test "readable-state fails closed when an encrypted manifest cannot be inspected" { + local failing_bin="$BATS_TEST_TMPDIR/failing-bin" + mkdir -p "$failing_bin" + cat > "$failing_bin/od" <<'SH' +#!/usr/bin/env bash +exit 73 +SH + chmod +x "$failing_bin/od" + + PATH="$failing_bin:$PATH" run "$REPO_DIR/lib/readable-state.sh" probe "$NOTES_CALLER_PWD/notes" + [ "$status" -eq 2 ] + [[ "$output" == *"failed to inspect encrypted manifest header"* ]] +} + +@test "shared readable-state boundaries cannot be bypassed by direct consumers" { + local plan="$BATS_TEST_TMPDIR/obfuscation-plan" + + run detect_changes "$NOTES_CALLER_PWD/notes" + assert_locked_failure + + run build_obfuscation_plan "$NOTES_CALLER_PWD/notes" "$plan" + assert_locked_failure + [ ! -e "$plan" ] + + run rename_to_readable "$NOTES_CALLER_PWD/notes" + assert_locked_failure + + run rebuild_status_suppression "$NOTES_CALLER_PWD/notes" + assert_locked_failure +} + +@test "read-only note consumers refuse locked content without exposing opaque paths" { + while IFS= read -r invocation; do + [ -n "$invocation" ] || continue + # shellcheck disable=SC2086 + run notes $invocation + assert_locked_failure + done <<'EOF' +changes --summary +diff +list --json +search orientation --json +audit --json +show aaaaaaaa +parse notes/aaaaaaaa +conflicts +merge --dry-run +EOF +} + +@test "mutating note consumers refuse locked content without changing index or worktree" { + local before_status before_index invocation + before_status=$(git -C "$NOTES_CALLER_PWD" status --porcelain=v1 -uall) + before_index=$(git -C "$NOTES_CALLER_PWD" write-tree) + + while IFS= read -r invocation; do + [ -n "$invocation" ] || continue + # shellcheck disable=SC2086 + run notes $invocation + assert_locked_failure + [ "$(git -C "$NOTES_CALLER_PWD" status --porcelain=v1 -uall)" = "$before_status" ] + [ "$(git -C "$NOTES_CALLER_PWD" write-tree)" = "$before_index" ] + done <<'EOF' +stage --all +commit --all -m locked +new --slug locked --title Locked +obfuscate +deobfuscate +suppress-refresh +EOF + + [ ! -e "$NOTES_CALLER_PWD/notes/locked.md" ] +} + +@test "pull remains available while locked and skips readable reconciliation" { + local remote="$BATS_TEST_TMPDIR/remote.git" + git init -q --bare "$remote" + git -C "$NOTES_CALLER_PWD" remote add origin "$remote" + git -C "$NOTES_CALLER_PWD" push -q -u origin main + + run notes pull + + [ "$status" -eq 0 ] + [[ "$output" == *"Notes are locked; skipping readable manifest checks and reconciliation."* ]] + [[ "$output" == *"Pull complete."* ]] + [ -z "$(git -C "$NOTES_CALLER_PWD" status --porcelain=v1 -uall)" ] +}