From 173a2ee7fe17ad090ed9f83a963d19c7c7dea82b Mon Sep 17 00:00:00 2001 From: junior Date: Tue, 28 Jul 2026 13:55:24 -0400 Subject: [PATCH 1/3] fix: fail closed on shell producer errors --- .mise/tasks/changes | 24 +++++++++++++---- .mise/tasks/commit | 28 +++++++++++++++---- .mise/tasks/deobfuscate | 10 ++++++- .mise/tasks/diff | 17 +++++++++--- .mise/tasks/obfuscate | 10 ++++++- .mise/tasks/setup | 50 ++++++++++++++++++++++------------ .mise/tasks/stage | 17 +++++++++--- .mise/tasks/status | 18 ++++++++++--- .mise/tasks/test | 12 ++++++++- README.md | 2 +- lib/changes.sh | 49 +++++++++++++++++++++++----------- lib/common.sh | 23 +++++++++++++++- lib/conflicts.sh | 50 +++++++++++++++++++++++----------- lib/obfuscate.sh | 12 ++++++++- test/changes.bats | 59 +++++++++++++++++++++++++++++++++++++++++ test/commit.bats | 30 +++++++++++++++++++++ test/common.bats | 25 +++++++++++++++++ test/conflicts.bats | 27 +++++++++++++++++++ test/deobfuscate.bats | 15 +++++++++++ test/integration.bats | 12 +++++++++ test/obfuscate.bats | 31 ++++++++++++++++++++++ test/test-task.bats | 12 +++++++++ test/test_helper.bash | 11 ++++++++ 23 files changed, 471 insertions(+), 73 deletions(-) diff --git a/.mise/tasks/changes b/.mise/tasks/changes index 53b8cd1..be82b10 100755 --- a/.mise/tasks/changes +++ b/.mise/tasks/changes @@ -23,17 +23,28 @@ fi # Parse variadic args ARGS=() if [ -n "${usage_files:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_files" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do arg="${arg#"${notes_dir}/"}" [ -z "$arg" ] && continue ARGS+=("$arg") - done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi if [ "${usage_summary:-}" = "true" ]; then # Summary mode: just list changes. An unavailable manifest is a clean result. - if ! changes=$(detect_changes "$abs_notes_dir"); then - changes="" + detect_status=0 + changes=$(detect_changes "$abs_notes_dir") || detect_status=$? + if [ "$detect_status" -ne 0 ]; then + echo "Error: failed to inspect note changes." >&2 + exit "$detect_status" fi if [ -z "$changes" ]; then @@ -80,8 +91,11 @@ if [ "${usage_summary:-}" = "true" ]; then fi else # Diff mode: show full diffs. An unavailable manifest is a clean result. - if ! changes=$(detect_changes "$abs_notes_dir"); then - changes="" + detect_status=0 + changes=$(detect_changes "$abs_notes_dir") || detect_status=$? + if [ "$detect_status" -ne 0 ]; then + echo "Error: failed to inspect note changes." >&2 + exit "$detect_status" fi if [ -z "$changes" ]; then diff --git a/.mise/tasks/commit b/.mise/tasks/commit index bb9124a..a6aef02 100755 --- a/.mise/tasks/commit +++ b/.mise/tasks/commit @@ -27,11 +27,19 @@ fi ARGS=() if [ -n "${usage_files:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_files" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do arg="${arg#"${notes_dir}/"}" [ -z "$arg" ] && continue ARGS+=("$arg") - done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi if [ -z "$message" ]; then @@ -156,11 +164,18 @@ verify_no_staged_residue() { } verify_commit_touched_only_notes() { - local changed=() bad=() path + local changed=() bad=() path changed_snapshot + changed_snapshot=$(mktemp) || return 1 + if ! git -C "$TARGET_DIR" diff-tree --no-commit-id --name-only -r HEAD -- > "$changed_snapshot"; then + echo "Error: failed to inspect committed paths." >&2 + rm -f "$changed_snapshot" + return 1 + fi while IFS= read -r path; do [ -z "$path" ] && continue changed+=("$path") - done < <(git -C "$TARGET_DIR" diff-tree --no-commit-id --name-only -r HEAD --) + done < "$changed_snapshot" + rm -f "$changed_snapshot" for path in ${changed[@]+"${changed[@]}"}; do case "$path" in @@ -180,8 +195,11 @@ verify_commit_touched_only_notes() { verify_selected_changes_clean() { local remaining bad_selected=() status relpath - if ! remaining=$(detect_changes "$abs_notes_dir"); then - remaining="" + if remaining=$(detect_changes "$abs_notes_dir"); then + : + else + echo "Error: failed to verify remaining note changes." >&2 + return 1 fi if [ -z "$remaining" ]; then diff --git a/.mise/tasks/deobfuscate b/.mise/tasks/deobfuscate index 84baa14..0ee6aa8 100755 --- a/.mise/tasks/deobfuscate +++ b/.mise/tasks/deobfuscate @@ -23,6 +23,13 @@ fi # Limitation: backslashes in filenames are treated as escape characters by xargs. ARGS=() if [ -n "${usage_files:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_files" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do # Strip notes_dir prefix if provided (e.g. "notes/abc123" → "abc123") arg="${arg#"${notes_dir}/"}" @@ -36,7 +43,8 @@ if [ -n "${usage_files:-}" ]; then done [ "$arg" = "/" ] || arg="${arg##*/}" ARGS+=("$arg") - done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi if [ "${usage_dry_run:-}" = "true" ]; then diff --git a/.mise/tasks/diff b/.mise/tasks/diff index f087871..e0f9694 100755 --- a/.mise/tasks/diff +++ b/.mise/tasks/diff @@ -19,10 +19,18 @@ repo_root=$(git -C "$TARGET_DIR" rev-parse --show-toplevel) ARGS=() if [ -n "${usage_refs:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_refs" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do [ -z "$arg" ] && continue ARGS+=("$arg") - done < <(printf '%s' "$usage_refs" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi if [ -n "$pr_number" ] && [ ${#ARGS[@]} -gt 0 ]; then @@ -37,8 +45,11 @@ if [ -z "$pr_number" ] && [ ${#ARGS[@]} -eq 0 ]; then exit 1 fi - if ! changes=$(detect_changes "$abs_notes_dir"); then - changes="" + detect_status=0 + changes=$(detect_changes "$abs_notes_dir") || detect_status=$? + if [ "$detect_status" -ne 0 ]; then + echo "Error: failed to inspect note changes." >&2 + exit "$detect_status" fi if [ -z "$changes" ]; then echo "No changes." diff --git a/.mise/tasks/obfuscate b/.mise/tasks/obfuscate index 778db39..0684d3b 100755 --- a/.mise/tasks/obfuscate +++ b/.mise/tasks/obfuscate @@ -22,12 +22,20 @@ fi # Limitation: backslashes in filenames are treated as escape characters by xargs. ARGS=() if [ -n "${usage_files:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_files" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do # Strip notes_dir prefix if provided (e.g. "notes/alpha.md" → "alpha.md") arg="${arg#"${notes_dir}/"}" [ -z "$arg" ] && continue ARGS+=("$arg") - done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi plan=$(mktemp) || { diff --git a/.mise/tasks/setup b/.mise/tasks/setup index d083cff..d6696c7 100755 --- a/.mise/tasks/setup +++ b/.mise/tasks/setup @@ -17,6 +17,39 @@ cd "$TARGET_DIR" NOTES_DIR="${usage_dir:-notes}" +keys=() +if [ -n "${usage_gpg_key:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_gpg_key" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi + while IFS= read -r key; do + [ -n "$key" ] && keys+=("$key") + done < "$args_snapshot" + rm -f "$args_snapshot" +fi + +patterns=() +if [ -n "${usage_pattern:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_pattern" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi + while IFS= read -r pattern; do + [ -n "$pattern" ] && patterns+=("$pattern") + done < "$args_snapshot" + rm -f "$args_snapshot" +fi +if [ ${#patterns[@]} -eq 0 ]; then + patterns=("$NOTES_DIR/**") +fi + setup_confirmation_message="notes setup will initialize/update git-crypt, .gitattributes, $NOTES_DIR/.manifest, and git hooks for $NOTES_DIR/ in this repo." if [ "${usage_unlock:-false}" = "true" ]; then setup_confirmation_message="$setup_confirmation_message It will also unlock/decrypt all git-crypt files after setup." @@ -32,13 +65,6 @@ else fi # --- Add GPG keys via rudi --- -keys=() -if [ -n "${usage_gpg_key:-}" ]; then - while IFS= read -r key; do - [ -n "$key" ] && keys+=("$key") - done < <(printf '%s' "$usage_gpg_key" | xargs printf '%s\n') -fi - if [ ${#keys[@]} -gt 0 ]; then echo "" echo "Adding GPG collaborators..." @@ -78,16 +104,6 @@ gitattributes_has_encrypted_pattern() { ' "$GITATTRIBUTES" 2>/dev/null } -patterns=() -if [ -n "${usage_pattern:-}" ]; then - while IFS= read -r pattern; do - [ -n "$pattern" ] && patterns+=("$pattern") - done < <(printf '%s' "$usage_pattern" | xargs printf '%s\n') -fi -if [ ${#patterns[@]} -eq 0 ]; then - patterns=("$NOTES_DIR/**") -fi - missing_patterns=() for p in "${patterns[@]}"; do [ -z "$p" ] && continue diff --git a/.mise/tasks/stage b/.mise/tasks/stage index b517d1c..bbdf02a 100755 --- a/.mise/tasks/stage +++ b/.mise/tasks/stage @@ -24,12 +24,20 @@ fi # Parse variadic args ARGS=() if [ -n "${usage_files:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_files" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r arg; do arg="${arg#"${notes_dir}/"}" # Skip empty variadic defaults after xargs unquotes "''". [ -z "$arg" ] && continue ARGS+=("$arg") - done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi scope_all="${usage_all:-false}" @@ -60,8 +68,11 @@ scope_matches() { } # Detect changes. An unavailable manifest is a clean result. -if ! changes=$(detect_changes "$abs_notes_dir"); then - changes="" +detect_status=0 +changes=$(detect_changes "$abs_notes_dir") || detect_status=$? +if [ "$detect_status" -ne 0 ]; then + echo "Error: failed to inspect note changes." >&2 + exit "$detect_status" fi if [ "$scope_all" != "true" ]; then diff --git a/.mise/tasks/status b/.mise/tasks/status index 7bae851..0417062 100755 --- a/.mise/tasks/status +++ b/.mise/tasks/status @@ -80,9 +80,16 @@ fi # --- Unmerged encrypted/obfuscated note content conflicts --- unmerged_content_conflicts="" -if ! unmerged_content_conflicts=$(notes_conflict_records "$TARGET_DIR" "$NOTES_DIR" allow-unmapped 2>/dev/null); then - unmerged_content_conflicts="" +conflict_error=$(mktemp) || { echo "Error: failed to create conflict error snapshot" >&2; exit 1; } +conflict_status=0 +unmerged_content_conflicts=$(notes_conflict_records "$TARGET_DIR" "$NOTES_DIR" allow-unmapped 2> "$conflict_error") || conflict_status=$? +if [ "$conflict_status" -ne 0 ]; then + cat "$conflict_error" >&2 + rm -f "$conflict_error" + echo "Error: failed to inspect unmerged note conflicts." >&2 + exit "$conflict_status" fi +rm -f "$conflict_error" unmerged_content_conflict_count=0 if [ -n "$unmerged_content_conflicts" ]; then unmerged_content_conflict_count=$(printf '%s\n' "$unmerged_content_conflicts" | wc -l | tr -d ' ') @@ -96,8 +103,11 @@ changes_deleted=0 changes_stale=0 if [ "$obf_status" = "deobfuscated" ]; then - if ! changes=$(detect_changes "$TARGET_DIR/$NOTES_DIR" 2>/dev/null); then - changes="" + detect_status=0 + changes=$(detect_changes "$TARGET_DIR/$NOTES_DIR") || detect_status=$? + if [ "$detect_status" -ne 0 ]; then + echo "Error: failed to inspect note changes." >&2 + exit "$detect_status" fi if [ -n "$changes" ]; then while IFS=$'\t' read -r chg_status chg_relpath; do diff --git a/.mise/tasks/test b/.mise/tasks/test index 288dad3..3960071 100755 --- a/.mise/tasks/test +++ b/.mise/tasks/test @@ -9,11 +9,21 @@ #USAGE example "mise run test --jobs 1" header="Run BATS serially for debugging" set -euo pipefail +source "$MISE_CONFIG_ROOT/lib/common.sh" + arguments=() if [ -n "${usage_args:-}" ]; then + args_snapshot=$(mktemp) || { echo "Error: failed to create argument snapshot" >&2; exit 1; } + parse_status=0 + parse_variadic_args "$usage_args" > "$args_snapshot" || parse_status=$? + if [ "$parse_status" -ne 0 ]; then + rm -f "$args_snapshot" + exit "$parse_status" + fi while IFS= read -r argument; do [ -z "$argument" ] || arguments+=("$argument") - done < <(printf '%s' "$usage_args" | xargs printf '%s\n') + done < "$args_snapshot" + rm -f "$args_snapshot" fi exec "$MISE_CONFIG_ROOT/libexec/test" ${arguments[@]+"${arguments[@]}"} diff --git a/README.md b/README.md index 3875ff0..0120ca2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 431](https://img.shields.io/badge/tests-431-brightgreen?style=flat)](test/) +[![tests: 445](https://img.shields.io/badge/tests-445-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 3c1b3ba..7118656 100644 --- a/lib/changes.sh +++ b/lib/changes.sh @@ -182,8 +182,10 @@ _classify_fallback_changes() { exec 6<&- else while IFS=$'\t' read -r id relpath head_hash; do - disk_hash=$(git -C "$repo_root" hash-object \ - --path="$notes_dir/$id" "$abs_notes_dir/$relpath" 2>/dev/null) || continue + if ! disk_hash=$(git -C "$repo_root" hash-object \ + --path="$notes_dir/$id" "$abs_notes_dir/$relpath" 2>/dev/null); then + return 1 + fi [ "$head_hash" != "$disk_hash" ] && printf 'modified\t%s\n' "$relpath" >> "$workspace/detected" done < "$workspace/fallback-meta" fi @@ -194,7 +196,8 @@ _classify_fallback_changes() { _classify_unmanaged_files() { local abs_notes_dir="$1" workspace="$2" - find "$abs_notes_dir" -type f | sort > "$workspace/all-files" || return 1 + find "$abs_notes_dir" -type f > "$workspace/all-files-unsorted" || return 1 + sort "$workspace/all-files-unsorted" > "$workspace/all-files" || return 1 awk \ -v ids_file="$workspace/manifest-ids" \ -v names_file="$workspace/manifest-names" \ @@ -229,35 +232,51 @@ _classify_unmanaged_files() { detect_changes() { local abs_notes_dir="${1:?usage: detect_changes }" local manifest="$abs_notes_dir/.manifest" - [ ! -f "$manifest" ] && return + [ ! -f "$manifest" ] && return 0 resolve_notes_dir "$abs_notes_dir" || return local repo_root="$RESOLVED_REPO_ROOT" local notes_dir="$RESOLVED_NOTES_DIR" - local workspace + local workspace rc=0 workspace=$(mktemp -d) || return : > "$workspace/detected" - if ! _prepare_change_detection_workspace "$abs_notes_dir" "$repo_root" "$notes_dir" "$workspace"; then + if _prepare_change_detection_workspace "$abs_notes_dir" "$repo_root" "$notes_dir" "$workspace"; then + : + else + rc=$? + rm -rf "$workspace" + return "$rc" + fi + if _classify_manifest_changes "$abs_notes_dir" "$notes_dir" "$workspace"; then + : + else + rc=$? rm -rf "$workspace" - return + return "$rc" fi - if ! _classify_manifest_changes "$abs_notes_dir" "$notes_dir" "$workspace"; then + if _classify_fallback_changes "$repo_root" "$notes_dir" "$abs_notes_dir" "$workspace"; then + : + else + rc=$? rm -rf "$workspace" - return + return "$rc" fi - if ! _classify_fallback_changes "$repo_root" "$notes_dir" "$abs_notes_dir" "$workspace"; then + if _classify_unmanaged_files "$abs_notes_dir" "$workspace" > "$workspace/unmanaged"; then + : + else + rc=$? rm -rf "$workspace" - return + return "$rc" fi - - cat "$workspace/detected" - if ! _classify_unmanaged_files "$abs_notes_dir" "$workspace"; then + if ! cat "$workspace/unmanaged" >> "$workspace/detected"; then rm -rf "$workspace" - return + return 1 fi + cat "$workspace/detected" || rc=$? rm -rf "$workspace" + return "$rc" } # Print ordinary diff output while preserving genuine diff execution failures. diff --git a/lib/common.sh b/lib/common.sh index 1855a68..f059a72 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -176,7 +176,28 @@ confirm_destructive() { esac } -# ── Path helpers ──────────────────────────────────────────── +# Argument helpers + +# Parse mise's shell-quoted variadic argument string. +# +# Callers must snapshot this output before consuming it. A process substitution +# would hide xargs/printf failures behind the consumer loop's exit status. +# Usage: parse_variadic_args +parse_variadic_args() { + local raw="${1:-}" status=0 + [ -n "$raw" ] || return 0 + + ( + set -o pipefail + printf '%s' "$raw" | xargs printf '%s\n' + ) || status=$? + if [ "$status" -ne 0 ]; then + echo "Error: failed to parse variadic arguments." >&2 + return "$status" + fi +} + +# Path helpers # Resolve the notes directory path relative to the repo root. # Handles macOS symlinks (/tmp → /private/tmp) by resolving real paths. diff --git a/lib/conflicts.sh b/lib/conflicts.sh index 1828dd6..7e087fc 100644 --- a/lib/conflicts.sh +++ b/lib/conflicts.sh @@ -116,11 +116,17 @@ _notes_conflict_lookup_readable_name() { notes_conflict_unmerged_paths() { local repo_root="$1" notes_dir="$2" - local meta git_path relpath tmp + local meta git_path relpath workspace rc=0 - tmp=$(mktemp) || return 1 - : > "$tmp" + workspace=$(mktemp -d) || return 1 + if ! git -C "$repo_root" ls-files -u -- "$notes_dir" \ + > "$workspace/unmerged" 2> "$workspace/git-error"; then + cat "$workspace/git-error" >&2 + rm -rf "$workspace" + return 1 + fi + : > "$workspace/paths" while IFS=$'\t' read -r meta git_path; do [ -n "$git_path" ] || continue case "$git_path" in @@ -128,17 +134,18 @@ notes_conflict_unmerged_paths() { "$notes_dir"/*) relpath="${git_path#"$notes_dir/"}" [ -n "$relpath" ] || continue - printf '%s\n' "$git_path" >> "$tmp" + printf '%s\n' "$git_path" >> "$workspace/paths" ;; esac - done < <( - if ! git -C "$repo_root" ls-files -u -- "$notes_dir" 2>/dev/null; then - : - fi - ) + done < "$workspace/unmerged" - sort -u "$tmp" - rm -f "$tmp" + if ! sort -u "$workspace/paths" > "$workspace/sorted"; then + rm -rf "$workspace" + return 1 + fi + cat "$workspace/sorted" || rc=$? + rm -rf "$workspace" + return "$rc" } # Output one row per unmerged note-content path: @@ -146,7 +153,14 @@ notes_conflict_unmerged_paths() { # The optional third argument may be "allow-unmapped" for status/reporting. notes_conflict_records() { local repo_root="$1" notes_dir="$2" mode="${3:-strict}" - local git_path id readable lookup_rc + local git_path id readable lookup_rc workspace rc=0 + + workspace=$(mktemp -d) || return 1 + if ! notes_conflict_unmerged_paths "$repo_root" "$notes_dir" > "$workspace/paths"; then + rm -rf "$workspace" + return 1 + fi + : > "$workspace/records" while IFS= read -r git_path; do [ -n "$git_path" ] || continue @@ -154,10 +168,11 @@ notes_conflict_records() { if ! _notes_conflict_guard_id "$id"; then if [ "$mode" = "allow-unmapped" ]; then - printf '%s\t__NOTES_UNMAPPED__\t%s\n' "$id" "$git_path" + printf '%s\t__NOTES_UNMAPPED__\t%s\n' "$id" "$git_path" >> "$workspace/records" continue fi echo "Error: unsupported unmerged note path: $git_path" >&2 + rm -rf "$workspace" return 1 fi @@ -172,12 +187,17 @@ notes_conflict_records() { if [ "$lookup_rc" -eq 2 ]; then echo "Error: missing manifest mapping for $git_path" >&2 fi + rm -rf "$workspace" return 1 fi fi - printf '%s\t%s\t%s\n' "$id" "$readable" "$git_path" - done < <(notes_conflict_unmerged_paths "$repo_root" "$notes_dir") + printf '%s\t%s\t%s\n' "$id" "$readable" "$git_path" >> "$workspace/records" + done < "$workspace/paths" + + cat "$workspace/records" || rc=$? + rm -rf "$workspace" + return "$rc" } notes_conflict_require_three_stages() { diff --git a/lib/obfuscate.sh b/lib/obfuscate.sh index 92cc5f1..d64a5bc 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -120,12 +120,22 @@ build_obfuscation_plan() { done else local file relpath + if ! find "$notes_dir" -type f > "$workspace/found-files"; then + echo "Error: failed to enumerate readable note candidates" >&2 + rm -rf "$workspace" + return 1 + fi + if ! sort "$workspace/found-files" > "$workspace/sorted-files"; then + echo "Error: failed to sort readable note candidates" >&2 + rm -rf "$workspace" + return 1 + fi 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) + done < "$workspace/sorted-files" fi manifest_input="$manifest" diff --git a/test/changes.bats b/test/changes.bats index c5383ef..e5fc20f 100644 --- a/test/changes.bats +++ b/test/changes.bats @@ -5,6 +5,16 @@ load test_helper load changes_test_helper +setup_failing_find_overlay() { + FAILING_FIND_BIN="$BATS_TEST_TMPDIR/failing-find-bin" + mkdir -p "$FAILING_FIND_BIN" + cat > "$FAILING_FIND_BIN/find" <<'SH' +#!/usr/bin/env bash +exit 73 +SH + chmod +x "$FAILING_FIND_BIN/find" +} + # ── detect_changes ──────────────────────────────────────────── @test "detect_changes: no changes when files match HEAD" { @@ -13,6 +23,15 @@ load changes_test_helper [ -z "$output" ] } +@test "detect_changes: missing manifest remains a clean result" { + rm -f "$NOTES_CALLER_PWD/notes/.manifest" + + run detect_changes "$NOTES_CALLER_PWD/notes" + + [ "$status" -eq 0 ] + [ -z "$output" ] +} + @test "detect_changes: detects modified file" { echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md" @@ -231,3 +250,43 @@ SH [ "$status" -eq 0 ] [[ "$output" == *"modified"*"alpha.md"* ]] } + +@test "detect_changes fails atomically when corpus enumeration fails" { + echo "# Alpha modified" > "$NOTES_CALLER_PWD/notes/alpha.md" + setup_failing_find_overlay + + PATH="$FAILING_FIND_BIN:$PATH" run detect_changes "$NOTES_CALLER_PWD/notes" + + [ "$status" -ne 0 ] + [ -z "$output" ] +} + +@test "working-tree commands propagate change detection failure" { + setup_failing_find_overlay + + PATH="$FAILING_FIND_BIN:$PATH" run notes changes --summary + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect note changes"* ]] + + PATH="$FAILING_FIND_BIN:$PATH" run notes diff + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect note changes"* ]] + + PATH="$FAILING_FIND_BIN:$PATH" run notes stage alpha.md + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect note changes"* ]] + + PATH="$FAILING_FIND_BIN:$PATH" run notes status --json + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect note changes"* ]] +} + +@test "notes changes fails instead of widening an unparsed file scope" { + local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + make_failing_xargs_overlay "$mock_bin" + + PATH="$mock_bin:$PATH" run notes changes alpha.md + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] +} diff --git a/test/commit.bats b/test/commit.bats index cb05474..d3f4953 100644 --- a/test/commit.bats +++ b/test/commit.bats @@ -255,3 +255,33 @@ HOOK run detect_changes "$NOTES_CALLER_PWD/notes" [ -z "$output" ] } + +@test "notes commit fails closed when committed-path inspection fails" { + local mock_bin="$BATS_TEST_TMPDIR/failing-diff-tree-git-bin" + local real_git before after + notes install-hooks --yes >/dev/null + printf '# Alpha changed\n' > "$NOTES_CALLER_PWD/notes/alpha.md" + before=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD) + real_git=$(command -v git) + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" diff-tree "*) + printf '%s\n' "committed path inspection failed" >&2 + exit 73 + ;; +esac +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + + PATH="$mock_bin:$PATH" REAL_GIT="$real_git" \ + run notes commit -m "update alpha" alpha.md + + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect committed paths"* ]] + [[ "$output" == *"post-commit verification failed"* ]] + after=$(git -C "$NOTES_CALLER_PWD" rev-parse HEAD) + [ "$after" != "$before" ] +} diff --git a/test/common.bats b/test/common.bats index 7a986d0..30a61d9 100644 --- a/test/common.bats +++ b/test/common.bats @@ -225,3 +225,28 @@ SH [ "$status" -eq 0 ] [ "$output" = $'abc12345\tback\\slash.md\ndef67890\tdouble"quote.md' ] } + +@test "parse_variadic_args preserves quoted argument boundaries" { + run parse_variadic_args "'alpha one.md' beta.md" + + [ "$status" -eq 0 ] + [ "$output" = $'alpha one.md\nbeta.md' ] +} + +@test "parse_variadic_args propagates parser failure" { + local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + make_failing_xargs_overlay "$mock_bin" + + PATH="$mock_bin:$PATH" run parse_variadic_args "alpha.md" + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] +} + +@test "variadic task arguments use the checked shared parser" { + local task + for task in changes commit deobfuscate diff obfuscate setup stage test; do + grep -q 'parse_variadic_args' "$REPO_DIR/.mise/tasks/$task" + ! grep -Eq 'done < <\(.*xargs' "$REPO_DIR/.mise/tasks/$task" + done +} diff --git a/test/conflicts.bats b/test/conflicts.bats index 6120dab..2d09db0 100644 --- a/test/conflicts.bats +++ b/test/conflicts.bats @@ -280,3 +280,30 @@ create_conflicted_gitcrypt_header_repo() { [ "$status" -eq 0 ] echo "$output" | jq -e '.unmerged_content_conflicts.conflicts == 1' } + +@test "conflict commands propagate unmerged-path inspection failure" { + local mock_bin="$BATS_TEST_TMPDIR/failing-conflict-git-bin" + local real_git + real_git=$(command -v git) + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" ls-files -u "*) + printf '%s\n' "unmerged path inspection failed" >&2 + exit 73 + ;; +esac +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + + PATH="$mock_bin:$PATH" REAL_GIT="$real_git" \ + run notes conflicts --out "$BATS_TEST_TMPDIR/conflict-output" + [ "$status" -ne 0 ] + [[ "$output" == *"unmerged path inspection failed"* ]] + + PATH="$mock_bin:$PATH" REAL_GIT="$real_git" run notes status --json + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect unmerged note conflicts"* ]] +} diff --git a/test/deobfuscate.bats b/test/deobfuscate.bats index a144ca3..2c0dec4 100644 --- a/test/deobfuscate.bats +++ b/test/deobfuscate.bats @@ -30,6 +30,21 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/gamma.txt" ] } +@test "deobfuscate fails instead of widening an unparsed file scope" { + local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + local alpha_id + notes obfuscate + alpha_id=$(awk -F '\t' '$2 == "alpha.md" { print $1 }' "$NOTES_CALLER_PWD/notes/.manifest") + make_failing_xargs_overlay "$mock_bin" + + PATH="$mock_bin:$PATH" run notes deobfuscate "$alpha_id" + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] + [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] + [ ! -f "$NOTES_CALLER_PWD/notes/alpha.md" ] +} + @test "full deobfuscation skips manifest lookup processes for absent IDs" { local i=1 id name restored_id="" counter_bin command real_command diff --git a/test/integration.bats b/test/integration.bats index 11525df..4ecd917 100644 --- a/test/integration.bats +++ b/test/integration.bats @@ -53,6 +53,18 @@ generate_test_key() { [ -f "$TARGET_DIR/.git-crypt/keys/default/0/$fpr.gpg" ] } +@test "setup parses variadic configuration before mutation" { + local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + make_failing_xargs_overlay "$mock_bin" + + PATH="$mock_bin:$PATH" run notes setup --yes --pattern 'notes/**' + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] + [ ! -e "$TARGET_DIR/.git-crypt" ] + [ ! -e "$TARGET_DIR/.gitattributes" ] +} + # --- lock / unlock round-trip --- @test "lock and unlock round-trip preserves file content" { diff --git a/test/obfuscate.bats b/test/obfuscate.bats index b564551..3ba3dd9 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -751,3 +751,34 @@ EOT # And real.md wasn't obfuscated either (fail-fast = no partial state) [ -f "$NOTES_CALLER_PWD/notes/real.md" ] } + +@test "obfuscate fails before mutation when corpus enumeration fails" { + local mock_bin="$BATS_TEST_TMPDIR/failing-find-bin" + mkdir -p "$mock_bin" + cat > "$mock_bin/find" <<'SH' +#!/usr/bin/env bash +exit 73 +SH + chmod +x "$mock_bin/find" + + PATH="$mock_bin:$PATH" run notes obfuscate + + [ "$status" -ne 0 ] + [[ "$output" == *"failed to enumerate readable note candidates"* ]] + [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ] + [ -f "$NOTES_CALLER_PWD/notes/beta.md" ] + [ ! -f "$NOTES_CALLER_PWD/notes/.manifest" ] +} + +@test "obfuscate fails instead of widening an unparsed file scope" { + local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + make_failing_xargs_overlay "$mock_bin" + + PATH="$mock_bin:$PATH" run notes obfuscate alpha.md + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] + [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ] + [ -f "$NOTES_CALLER_PWD/notes/beta.md" ] + [ ! -f "$NOTES_CALLER_PWD/notes/.manifest" ] +} diff --git a/test/test-task.bats b/test/test-task.bats index aff7117..268f865 100644 --- a/test/test-task.bats +++ b/test/test-task.bats @@ -144,3 +144,15 @@ logged_arguments() { [ ! -e "$bats_log" ] [ ! -e "$uv_log" ] } + +@test "test task propagates variadic parser failure" { + local failing_xargs_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" + make_failing_xargs_overlay "$failing_xargs_bin" + + PATH="$failing_xargs_bin:$PATH" run notes test changes + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to parse variadic arguments"* ]] + [ ! -e "$bats_log" ] + [ ! -e "$uv_log" ] +} diff --git a/test/test_helper.bash b/test/test_helper.bash index 9716ef2..8fb88bf 100644 --- a/test/test_helper.bash +++ b/test/test_helper.bash @@ -35,6 +35,17 @@ without_confirmation() ( ) export -f without_confirmation +make_failing_xargs_overlay() { + local bin_dir="$1" + mkdir -p "$bin_dir" + cat > "$bin_dir/xargs" <<'SH' +#!/usr/bin/env bash +exit 73 +SH + chmod +x "$bin_dir/xargs" +} +export -f make_failing_xargs_overlay + # rudi() wrapper — calls rudi against the same target repo. rudi() { if [ -z "${NOTES_CALLER_PWD:-}" ]; then From 98bcedef8cf3be7abe395d92b998e3f55f99e2af Mon Sep 17 00:00:00 2001 From: johnson Date: Tue, 28 Jul 2026 18:16:11 +0000 Subject: [PATCH 2/3] fix: propagate double-tracked inspection errors --- .mise/tasks/stage | 7 +++++-- .mise/tasks/status | 7 +++++-- README.md | 2 +- test/changes.bats | 31 +++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.mise/tasks/stage b/.mise/tasks/stage index bbdf02a..690e65d 100755 --- a/.mise/tasks/stage +++ b/.mise/tasks/stage @@ -151,8 +151,11 @@ if [ -n "$conflicting_notes" ]; then fi double_tracked="" -if ! double_tracked=$(detect_double_tracked_notes "$TARGET_DIR" "$notes_dir" 2>/dev/null); then - double_tracked="" +double_tracked_status=0 +double_tracked=$(detect_double_tracked_notes "$TARGET_DIR" "$notes_dir") || double_tracked_status=$? +if [ "$double_tracked_status" -ne 0 ]; then + echo "Error: failed to inspect double-tracked note paths." >&2 + exit "$double_tracked_status" fi if [ -n "$double_tracked" ]; then blocked_tracked=() diff --git a/.mise/tasks/status b/.mise/tasks/status index 0417062..0e83d74 100755 --- a/.mise/tasks/status +++ b/.mise/tasks/status @@ -69,8 +69,11 @@ fi # --- Double-tracked notes (readable + obfuscated both tracked) --- double_tracked="" -if ! double_tracked=$(detect_double_tracked_notes "$TARGET_DIR" "$NOTES_DIR" 2>/dev/null); then - double_tracked="" +double_tracked_status=0 +double_tracked=$(detect_double_tracked_notes "$TARGET_DIR" "$NOTES_DIR") || double_tracked_status=$? +if [ "$double_tracked_status" -ne 0 ]; then + echo "Error: failed to inspect double-tracked note paths." >&2 + exit "$double_tracked_status" fi double_tracked_count=0 if [ -n "$double_tracked" ]; then diff --git a/README.md b/README.md index 0120ca2..435d743 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 445](https://img.shields.io/badge/tests-445-brightgreen?style=flat)](test/) +[![tests: 446](https://img.shields.io/badge/tests-446-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 e5fc20f..cccd581 100644 --- a/test/changes.bats +++ b/test/changes.bats @@ -15,6 +15,22 @@ SH chmod +x "$FAILING_FIND_BIN/find" } +setup_failing_tracked_path_inspection_overlay() { + FAILING_GIT_BIN="$BATS_TEST_TMPDIR/failing-git-bin" + local real_git + real_git=$(command -v git) + mkdir -p "$FAILING_GIT_BIN" + cat > "$FAILING_GIT_BIN/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" ls-files -z -- notes ") exit 73 ;; +esac +exec "$REAL_GIT" "$@" +SH + chmod +x "$FAILING_GIT_BIN/git" + export REAL_GIT="$real_git" +} + # ── detect_changes ──────────────────────────────────────────── @test "detect_changes: no changes when files match HEAD" { @@ -281,6 +297,21 @@ SH [[ "$output" == *"failed to inspect note changes"* ]] } +@test "safety consumers propagate double-tracked path inspection failure" { + setup_failing_tracked_path_inspection_overlay + printf '# Alpha modified\n' > "$NOTES_CALLER_PWD/notes/alpha.md" + + PATH="$FAILING_GIT_BIN:$PATH" run notes stage alpha.md + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect double-tracked note paths"* ]] + run git -C "$NOTES_CALLER_PWD" diff --cached --name-only + [ -z "$output" ] + + PATH="$FAILING_GIT_BIN:$PATH" run notes status --json + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect double-tracked note paths"* ]] +} + @test "notes changes fails instead of widening an unparsed file scope" { local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" make_failing_xargs_overlay "$mock_bin" From a76821785bef06d62cf8e0ead97642836c790765 Mon Sep 17 00:00:00 2001 From: junior Date: Tue, 28 Jul 2026 14:38:27 -0400 Subject: [PATCH 3/3] fix: propagate remaining inspection errors --- .mise/tasks/setup | 11 ++++++++- .mise/tasks/stage | 8 +++++-- .mise/tasks/status | 7 ++++-- .mise/tasks/verify-blobs | 12 +++++++++- README.md | 2 +- hooks/verify-double-tracking.template | 7 ++++-- lib/changes.sh | 10 ++++++-- test/changes.bats | 29 +++++++++++++++++++++++ test/encrypt.bats | 27 ++++++++++++++++++++++ test/integration.bats | 25 ++++++++++++++++++++ test/verify-blobs.bats | 33 +++++++++++++++++++++++++++ 11 files changed, 160 insertions(+), 11 deletions(-) diff --git a/.mise/tasks/setup b/.mise/tasks/setup index d6696c7..5a1f913 100755 --- a/.mise/tasks/setup +++ b/.mise/tasks/setup @@ -158,12 +158,21 @@ else # (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 < <(find "$TARGET_DIR/$NOTES_DIR" -type f ! -name .manifest -print0 2>/dev/null) + done < "$encrypted_notes_snapshot" + rm -f "$encrypted_notes_snapshot" fi echo "" diff --git a/.mise/tasks/stage b/.mise/tasks/stage index 690e65d..a5178a7 100755 --- a/.mise/tasks/stage +++ b/.mise/tasks/stage @@ -122,8 +122,12 @@ if [ ${#stale_notes[@]} -gt 0 ]; then exit 1 fi -if ! conflicting_notes=$(detect_dual_present_conflicts "$abs_notes_dir"); then - conflicting_notes="" +conflicting_notes="" +conflicting_notes_status=0 +conflicting_notes=$(detect_dual_present_conflicts "$abs_notes_dir") || conflicting_notes_status=$? +if [ "$conflicting_notes_status" -ne 0 ]; then + echo "Error: failed to inspect dual-present note paths." >&2 + exit "$conflicting_notes_status" fi if [ -n "$conflicting_notes" ]; then blocked_conflicts=() diff --git a/.mise/tasks/status b/.mise/tasks/status index 0e83d74..39c0f3f 100755 --- a/.mise/tasks/status +++ b/.mise/tasks/status @@ -58,8 +58,11 @@ fi incomplete_deobfuscation="" incomplete_conflicts=0 if [ "$enc_status" != "locked" ] && [ -f "$NOTES_DIR/.manifest" ]; then - if ! incomplete_deobfuscation=$(detect_dual_present_conflicts "$TARGET_DIR/$NOTES_DIR" 2>/dev/null); then - incomplete_deobfuscation="" + incomplete_deobfuscation_status=0 + incomplete_deobfuscation=$(detect_dual_present_conflicts "$TARGET_DIR/$NOTES_DIR") || incomplete_deobfuscation_status=$? + if [ "$incomplete_deobfuscation_status" -ne 0 ]; then + echo "Error: failed to inspect dual-present note paths." >&2 + exit "$incomplete_deobfuscation_status" fi if [ -n "$incomplete_deobfuscation" ]; then incomplete_conflicts=$(printf '%s\n' "$incomplete_deobfuscation" | wc -l | tr -d ' ') diff --git a/.mise/tasks/verify-blobs b/.mise/tasks/verify-blobs index 60c087a..a6ee21f 100755 --- a/.mise/tasks/verify-blobs +++ b/.mise/tasks/verify-blobs @@ -69,6 +69,15 @@ else fi # ── 2. Walk all tracked blobs under notes dir ────────────────── +tracked_blob_snapshot=$(mktemp) || { echo "Error: failed to create tracked blob snapshot" >&2; exit 1; } +tracked_blob_status=0 +git ls-tree -r -z --name-only "$ref" -- "$notes_dir/" > "$tracked_blob_snapshot" || tracked_blob_status=$? +if [ "$tracked_blob_status" -ne 0 ]; then + rm -f "$tracked_blob_snapshot" + echo "Error: failed to enumerate note blobs in ref '$ref'." >&2 + exit "$tracked_blob_status" +fi + while IFS= read -r -d '' path; do [ -z "$path" ] && continue [[ "$path" == "$manifest_path" ]] && continue @@ -86,7 +95,8 @@ while IFS= read -r -d '' path; do errors=$((errors + 1)) error_paths+=("$path") fi -done < <(git ls-tree -r -z --name-only "$ref" -- "$notes_dir/" 2>/dev/null) +done < "$tracked_blob_snapshot" +rm -f "$tracked_blob_snapshot" # ── 3. Strict mode: check working tree cleanliness ──────────── if [ "$strict" = "true" ]; then diff --git a/README.md b/README.md index 435d743..308a5e6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 446](https://img.shields.io/badge/tests-446-brightgreen?style=flat)](test/) +[![tests: 450](https://img.shields.io/badge/tests-450-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/verify-double-tracking.template b/hooks/verify-double-tracking.template index 10bb647..9cfa85c 100644 --- a/hooks/verify-double-tracking.template +++ b/hooks/verify-double-tracking.template @@ -13,8 +13,11 @@ source "$NOTES_TOOL_ROOT/lib/common.sh" repo_root="$(git rev-parse --show-toplevel)" double_tracked="" -if ! double_tracked=$(detect_double_tracked_notes "$repo_root" "$NOTES_ROOT" 2>/dev/null); then - double_tracked="" +double_tracked_status=0 +double_tracked=$(detect_double_tracked_notes "$repo_root" "$NOTES_ROOT") || double_tracked_status=$? +if [ "$double_tracked_status" -ne 0 ]; then + echo "Error: failed to inspect double-tracked note paths." >&2 + exit "$double_tracked_status" fi [ -z "$double_tracked" ] && exit 0 diff --git a/lib/changes.sh b/lib/changes.sh index 7118656..92b34ed 100644 --- a/lib/changes.sh +++ b/lib/changes.sh @@ -15,14 +15,20 @@ detect_dual_present_conflicts() { local abs_notes_dir="${1:?usage: detect_dual_present_conflicts }" local manifest="$abs_notes_dir/.manifest" + local comparison_status [ ! -f "$manifest" ] && return 0 while IFS=$'\t' read -r id relpath; do [ -z "$id" ] && continue [ -f "$abs_notes_dir/$id" ] || continue [ -f "$abs_notes_dir/$relpath" ] || continue - cmp -s "$abs_notes_dir/$id" "$abs_notes_dir/$relpath" && continue - printf '%s\t%s\n' "$id" "$relpath" + comparison_status=0 + cmp -s "$abs_notes_dir/$id" "$abs_notes_dir/$relpath" || comparison_status=$? + case "$comparison_status" in + 0) continue ;; + 1) printf '%s\t%s\n' "$id" "$relpath" ;; + *) return "$comparison_status" ;; + esac done < "$manifest" } diff --git a/test/changes.bats b/test/changes.bats index cccd581..22e98bd 100644 --- a/test/changes.bats +++ b/test/changes.bats @@ -31,6 +31,17 @@ SH export REAL_GIT="$real_git" } +setup_failing_content_comparison_overlay() { + FAILING_CMP_BIN="$BATS_TEST_TMPDIR/failing-cmp-bin" + mkdir -p "$FAILING_CMP_BIN" + cat > "$FAILING_CMP_BIN/cmp" <<'SH' +#!/usr/bin/env bash +echo "content comparison failed" >&2 +exit 73 +SH + chmod +x "$FAILING_CMP_BIN/cmp" +} + # ── detect_changes ──────────────────────────────────────────── @test "detect_changes: no changes when files match HEAD" { @@ -312,6 +323,24 @@ SH [[ "$output" == *"failed to inspect double-tracked note paths"* ]] } +@test "safety consumers propagate dual-present content inspection failure" { + local alpha_id + alpha_id=$(manifest_id_for_name "$MANIFEST" "alpha.md") + cp "$NOTES_CALLER_PWD/notes/alpha.md" "$NOTES_CALLER_PWD/notes/$alpha_id" + printf '# Alpha modified\n' > "$NOTES_CALLER_PWD/notes/alpha.md" + setup_failing_content_comparison_overlay + + PATH="$FAILING_CMP_BIN:$PATH" run notes stage alpha.md + [ "$status" -eq 73 ] + [[ "$output" == *"failed to inspect dual-present note paths"* ]] + run git -C "$NOTES_CALLER_PWD" diff --cached --name-only + [ -z "$output" ] + + PATH="$FAILING_CMP_BIN:$PATH" run notes status --json + [ "$status" -eq 73 ] + [[ "$output" == *"failed to inspect dual-present note paths"* ]] +} + @test "notes changes fails instead of widening an unparsed file scope" { local mock_bin="$BATS_TEST_TMPDIR/failing-xargs-bin" make_failing_xargs_overlay "$mock_bin" diff --git a/test/encrypt.bats b/test/encrypt.bats index 8305d40..6455033 100644 --- a/test/encrypt.bats +++ b/test/encrypt.bats @@ -2,6 +2,23 @@ load test_helper +setup_failing_encrypted_note_enumeration_overlay() { + FAILING_FIND_BIN="$BATS_TEST_TMPDIR/failing-encrypted-note-find" + local real_find + real_find=$(command -v find) + mkdir -p "$FAILING_FIND_BIN" + cat > "$FAILING_FIND_BIN/find" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = "$NOTES_CALLER_PWD/notes" ] && [[ " $* " == *" -print0 "* ]]; then + echo "encrypted note enumeration failed" >&2 + exit 73 +fi +exec "$REAL_FIND" "$@" +SH + chmod +x "$FAILING_FIND_BIN/find" + export REAL_FIND="$real_find" +} + @test "is_initialized returns false on fresh repo" { run is_initialized [ "$status" -ne 0 ] @@ -233,6 +250,16 @@ generate_test_key() { echo "$output" | grep -q "already has encrypted notes" } +@test "setup fails when existing encrypted notes cannot be enumerated" { + setup_failing_encrypted_note_enumeration_overlay + + PATH="$FAILING_FIND_BIN:$PATH" run notes setup --yes + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to inspect existing encrypted notes"* ]] + [[ "$output" != *"Done! Next steps"* ]] +} + @test "setup shows standard next steps on fresh repo" { run notes setup --yes [ "$status" -eq 0 ] diff --git a/test/integration.bats b/test/integration.bats index 4ecd917..8e3905b 100644 --- a/test/integration.bats +++ b/test/integration.bats @@ -721,6 +721,31 @@ run_double_tracking_hook() { [ "$status" -eq 0 ] } +@test "double-tracking hook propagates tracked-path inspection failure" { + local mock_bin="$BATS_TEST_TMPDIR/failing-double-tracking-git" + local real_git + real_git=$(command -v git) + notes setup --yes + mkdir -p "$mock_bin" + cat > "$mock_bin/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" ls-files -z -- notes ") + echo "tracked-path inspection failed" >&2 + exit 73 + ;; +esac +exec "$REAL_GIT" "$@" +SH + chmod +x "$mock_bin/git" + export REAL_GIT="$real_git" + + PATH="$mock_bin:$PATH" run_double_tracking_hook + + [ "$status" -ne 0 ] + [[ "$output" == *"failed to inspect double-tracked note paths"* ]] +} + @test "double-tracking hook blocks commit when readable + obfuscated both tracked (#51)" { notes setup --yes printf 'aaaaaaaa\talpha.md\n' > "$TARGET_DIR/notes/.manifest" diff --git a/test/verify-blobs.bats b/test/verify-blobs.bats index e14a432..e68e573 100644 --- a/test/verify-blobs.bats +++ b/test/verify-blobs.bats @@ -8,6 +8,25 @@ load test_helper +setup_failing_ref_tree_enumeration_overlay() { + FAILING_GIT_BIN="$BATS_TEST_TMPDIR/failing-ref-tree-git" + local real_git + real_git=$(command -v git) + mkdir -p "$FAILING_GIT_BIN" + cat > "$FAILING_GIT_BIN/git" <<'SH' +#!/usr/bin/env bash +case " $* " in + *" ls-tree -r -z --name-only HEAD -- notes/ ") + echo "ref tree enumeration failed" >&2 + exit 73 + ;; +esac +exec "$REAL_GIT" "$@" +SH + chmod +x "$FAILING_GIT_BIN/git" + export REAL_GIT="$real_git" +} + @test "verify-blobs: all encrypted blobs pass" { mkdir -p "$NOTES_CALLER_PWD/notes" printf '\x00GITCRYPT\x00aaa00001\talpha.md\n' > "$NOTES_CALLER_PWD/notes/.manifest" @@ -20,6 +39,20 @@ load test_helper echo "$output" | grep -q "OK" } +@test "verify-blobs: fails when ref tree enumeration fails" { + mkdir -p "$NOTES_CALLER_PWD/notes" + printf '\x00GITCRYPT\x00aaa00001\talpha.md\n' > "$NOTES_CALLER_PWD/notes/.manifest" + printf '\x00GITCRYPT\x00encrypted alpha content' > "$NOTES_CALLER_PWD/notes/aaa00001" + git -C "$NOTES_CALLER_PWD" add -A + git -C "$NOTES_CALLER_PWD" commit -q -m "all encrypted" + setup_failing_ref_tree_enumeration_overlay + + PATH="$FAILING_GIT_BIN:$PATH" run notes verify-blobs + + [ "$status" -eq 73 ] + [[ "$output" == *"failed to enumerate note blobs"* ]] +} + @test "verify-blobs: large encrypted blob passes" { mkdir -p "$NOTES_CALLER_PWD/notes" printf '\x00GITCRYPT\x00aaa00001\talpha.md\n' > "$NOTES_CALLER_PWD/notes/.manifest"