From cad9ebe4450e424fa61cc480d87004bc3d6766d1 Mon Sep 17 00:00:00 2001 From: junior Date: Mon, 27 Jul 2026 10:45:08 -0400 Subject: [PATCH 1/6] perf: batch note obfuscation work --- .mise/tasks/obfuscate | 150 ++++++++++++++++-------------- README.md | 2 +- lib/obfuscate.sh | 211 ++++++++++++++++++++++++++---------------- test/obfuscate.bats | 86 +++++++++++++++++ 4 files changed, 294 insertions(+), 155 deletions(-) diff --git a/.mise/tasks/obfuscate b/.mise/tasks/obfuscate index 91d1721..02b64f0 100755 --- a/.mise/tasks/obfuscate +++ b/.mise/tasks/obfuscate @@ -30,63 +30,61 @@ if [ -n "${usage_files:-}" ]; then done < <(printf '%s' "${usage_files}" | xargs printf '%s\n') fi +plan=$(mktemp) || { + echo "Error: failed to create obfuscation plan" >&2 + exit 1 +} +renamed=$(mktemp) || { + rm -f "$plan" + echo "Error: failed to create rename result" >&2 + exit 1 +} +head_manifest=$(mktemp) || { + rm -f "$plan" "$renamed" + echo "Error: failed to create manifest snapshot" >&2 + exit 1 +} +trap 'rm -f "$plan" "$renamed" "$head_manifest"' EXIT + +if ! build_obfuscation_plan "$abs_notes_dir" "$plan" ${ARGS[@]+"${ARGS[@]}"}; then + echo "Error: obfuscation planning failed" >&2 + exit 1 +fi + if [ "${usage_dry_run:-}" = "true" ]; then - # Dry-run: show what would be renamed without doing it. - # Use the same classification logic as rename_to_obfuscated. - if [ ${#ARGS[@]} -gt 0 ]; then - for relpath in ${ARGS[@]+"${ARGS[@]}"}; do - [[ "$relpath" == ".manifest" ]] && continue - [ ! -f "$abs_notes_dir/$relpath" ] && continue - base=$(basename "$relpath") - manifest_has_id "$manifest" "$base" && continue - if ! existing_id=$(manifest_id_for_name "$manifest" "$relpath"); then - existing_id="" - fi - if [ -n "$existing_id" ]; then - echo " $relpath → $existing_id" - else - echo " $relpath → (will be assigned)" - fi - done - else - while IFS= read -r f; do - [ ! -f "$f" ] && continue - relpath="${f#"$abs_notes_dir"/}" - [[ "$relpath" == ".manifest" ]] && continue - base=$(basename "$f") - manifest_has_id "$manifest" "$base" && continue - if ! existing_id=$(manifest_id_for_name "$manifest" "$relpath"); then - existing_id="" - fi - if [ -n "$existing_id" ]; then - echo " $relpath → $existing_id" - else - echo " $relpath → (will be assigned)" - fi - done < <(find "$abs_notes_dir" -type f | sort) - fi + while IFS=$'\t' read -r kind id relpath; do + if [ "$kind" = "known" ]; then + echo " $relpath → $id" + else + echo " $relpath → (will be assigned)" + fi + done < "$plan" exit 0 fi -# Clear status suppression so git sees the real state for staging. -# In scoped mode, we need to find the IDs for files being re-obfuscated. -if [ ${#ARGS[@]} -gt 0 ]; then +scoped_mode=false +[ ${#ARGS[@]} -gt 0 ] && scoped_mode=true + +# Clear status suppression so git sees the real state for staging. The plan +# already contains each known ID, avoiding another manifest classification pass. +if $scoped_mode; then scoped_ids=() - for relpath in ${ARGS[@]+"${ARGS[@]}"}; do - if ! id=$(manifest_id_for_name "$manifest" "$relpath"); then - id="" - fi - [ -n "$id" ] && scoped_ids+=("$id") - done - [ ${#scoped_ids[@]} -gt 0 ] && clear_status_suppression "$abs_notes_dir" ${scoped_ids[@]+"${scoped_ids[@]}"} + while IFS=$'\t' read -r kind id relpath; do + [ "$kind" = "known" ] && scoped_ids+=("$id") + done < "$plan" + [ ${#scoped_ids[@]} -gt 0 ] && clear_status_suppression \ + "$abs_notes_dir" ${scoped_ids[@]+"${scoped_ids[@]}"} else clear_status_suppression "$abs_notes_dir" fi -# Perform the rename via Layer 1 -# Exit codes: 0=success, 2=nothing to do, 1=error -# Use && rc=0 || rc=$? to capture exit code without triggering errexit. -output=$(rename_to_obfuscated "$abs_notes_dir" ${ARGS[@]+"${ARGS[@]}"}) && rc=0 || rc=$? +# Apply the precomputed plan via Layer 1. +# Exit codes: 0=success, 2=nothing to do, 1=error. +if apply_obfuscation_plan "$abs_notes_dir" "$plan" "$scoped_mode" > "$renamed"; then + rc=0 +else + rc=$? +fi if [ "$rc" -eq 2 ]; then echo "Nothing to obfuscate." exit 0 @@ -95,35 +93,43 @@ elif [ "$rc" -ne 0 ]; then exit "$rc" fi -manifest_entry_matches_head() { - local id="$1" relpath="$2" - git -C "$TARGET_DIR" cat-file --filters "HEAD:$notes_dir/.manifest" 2>/dev/null \ - | awk -F '\t' -v id="$id" -v relpath="$relpath" ' - $1 == id && $2 == relpath { found=1 } - END { exit found ? 0 : 1 } - ' -} - -# Stage the results: add obfuscated files, remove readable names from index. -# In full mode, stage the manifest normally. In scoped mode, stage it only when -# one of the scoped files needs a manifest update in HEAD. This prevents an -# unrelated uncommitted manifest entry (for a skipped new note) from leaking into -# a commit when the pre-commit hook obfuscates a modified known note. +# Stage all renamed paths in two Git calls instead of once per note. +obfuscated_paths=() +readable_paths=() new_count=0 -stage_manifest=false -[ ${#ARGS[@]} -eq 0 ] && stage_manifest=true while IFS=$'\t' read -r relpath id; do - git -C "$TARGET_DIR" add "$notes_dir/$id" - # A readable path can already be absent from the index on repeated runs. - if ! git -C "$TARGET_DIR" rm --cached --quiet "$notes_dir/$relpath" 2>/dev/null; then - : + obfuscated_paths+=("$notes_dir/$id") + readable_paths+=("$notes_dir/$relpath") + echo " $relpath → $id" + new_count=$((new_count + 1)) +done < "$renamed" + +git -C "$TARGET_DIR" add -- "${obfuscated_paths[@]}" +git -C "$TARGET_DIR" rm --cached --quiet --ignore-unmatch -- \ + "${readable_paths[@]}" + +# Full mode always owns the complete manifest. Scoped mode stages it only when +# a renamed mapping is absent from HEAD, preventing unrelated uncommitted +# mappings from leaking into a pre-commit operation. +stage_manifest=false +if ! $scoped_mode; then + stage_manifest=true +else + if ! git -C "$TARGET_DIR" cat-file --filters \ + "HEAD:$notes_dir/.manifest" > "$head_manifest" 2>/dev/null; then + : > "$head_manifest" fi - if ! manifest_entry_matches_head "$id" "$relpath"; then + if ! awk -F '\t' -v renamed="$renamed" ' + FILENAME != renamed { + entries[$1 FS $2] = 1 + next + } + !(($2 FS $1) in entries) { missing = 1 } + END { exit missing ? 1 : 0 } + ' "$head_manifest" "$renamed"; then stage_manifest=true fi - echo " $relpath → $id" - new_count=$((new_count + 1)) -done <<< "$output" +fi if $stage_manifest; then git -C "$TARGET_DIR" add "$manifest" diff --git a/README.md b/README.md index fbeb3bb..59f8186 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 414](https://img.shields.io/badge/tests-414-brightgreen?style=flat)](test/) +[![tests: 416](https://img.shields.io/badge/tests-416-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/obfuscate.sh b/lib/obfuscate.sh index 790b5ea..391abfe 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -5,8 +5,8 @@ # Refuse to proceed if a filename's basename looks like an obfuscated id. # An obfuscated id is 8 lowercase hex characters with no extension. # -# Callers rely on `manifest_has_id` to detect "already obfuscated, skip." If -# the manifest is inconsistent (stale, lost entries, orphan blobs), that +# The corpus planner uses manifest IDs to detect "already obfuscated, skip." +# If the manifest is inconsistent (stale, lost entries, orphan blobs), that # check can miss and the file would be re-obfuscated under a new random id, # creating a duplicate blob and masking the underlying problem. Better to # fail loudly and make the user investigate. @@ -16,7 +16,7 @@ refuse_if_hex_basename() { local relpath="$1" local base - base=$(basename "$relpath") + base="${relpath##*/}" if [[ "$base" =~ ^[a-f0-9]{8}$ ]]; then cat >&2 <\t" per renamed file (for callers to stage). -# Usage: rename_to_obfuscated [file...] -# Without files: scans notes_dir for all non-obfuscated files. -# With files: only processes the listed files (relative to notes_dir). -rename_to_obfuscated() { - local notes_dir="$1" - shift +# Build a corpus-level obfuscation plan. +# Output rows are "\t\t", where kind is +# "known" for an existing manifest mapping or "new" for a new readable name. +# Already-obfuscated IDs are omitted. The full plan is checked for orphan +# hash-shaped names before any caller mutates the working tree. +# Usage: build_obfuscation_plan [file...] +build_obfuscation_plan() { + local notes_dir="$1" plan_file="$2" + shift 2 local scoped_files=("$@") local manifest="$notes_dir/.manifest" + local workspace candidates manifest_input - local to_rename=() to_restore=() - local scoped_mode=false + workspace=$(mktemp -d) || { + echo "Error: failed to create obfuscation workspace" >&2 + return 1 + } + candidates="$workspace/candidates" + : > "$candidates" if [ ${#scoped_files[@]} -gt 0 ] && [ -n "${scoped_files[0]}" ]; then - scoped_mode=true + local relpath for relpath in "${scoped_files[@]}"; do [[ "$relpath" == ".manifest" ]] && continue - [ ! -f "$notes_dir/$relpath" ] && continue - - local base - base=$(basename "$relpath") - if manifest_has_id "$manifest" "$base"; then - continue # already obfuscated - fi - - # Refuse to obfuscate a file whose basename already looks like an - # obfuscated id (8 hex chars, no extension). This only happens when - # the manifest is inconsistent with the working tree — e.g., a stale - # manifest lost the mapping for an already-obfuscated file, or an - # orphan obfuscated blob exists without an entry. Re-obfuscating - # would create a duplicate blob under a fresh random id and hide - # the real problem (as happened on den/fold through April 2026). - refuse_if_hex_basename "$relpath" || return 1 - - local existing_id - if ! existing_id=$(manifest_id_for_name "$manifest" "$relpath"); then - existing_id="" - fi - if [ -n "$existing_id" ]; then - to_restore+=("$relpath") - else - to_rename+=("$relpath") - fi + [ -f "$notes_dir/$relpath" ] || continue + printf '%s\n' "$relpath" >> "$candidates" done else - while IFS= read -r f; do - [ ! -f "$f" ] && continue - local relpath="${f#"$notes_dir"/}" + local file relpath + while IFS= read -r file; do + [ -f "$file" ] || continue + relpath="${file#"$notes_dir"/}" [[ "$relpath" == ".manifest" ]] && continue - - local base - base=$(basename "$f") - if manifest_has_id "$manifest" "$base"; then - continue - fi - - refuse_if_hex_basename "$relpath" || return 1 - - local existing_id - if ! existing_id=$(manifest_id_for_name "$manifest" "$relpath"); then - existing_id="" - fi - if [ -n "$existing_id" ]; then - to_restore+=("$relpath") - else - to_rename+=("$relpath") - fi + printf '%s\n' "$relpath" >> "$candidates" done < <(find "$notes_dir" -type f | sort) fi - if [ ${#to_rename[@]} -eq 0 ] && [ ${#to_restore[@]} -eq 0 ]; then - return 2 # nothing to do (distinct from error) + manifest_input="$manifest" + if [ ! -f "$manifest_input" ]; then + manifest_input="$workspace/manifest" + : > "$manifest_input" fi - # Track new manifest entries - local new_entries - new_entries=$(mktemp) || { echo "Error: failed to create temp file" >&2; return 1; } + if ! awk -F '\t' -v OFS='\t' -v candidates="$candidates" ' + FILENAME != candidates { + if ($1 != "") { + ids[$1] = 1 + if (!($2 in names)) names[$2] = $1 + } + next + } + { + relpath = $0 + if (relpath == "" || seen[relpath]++) next + count = split(relpath, parts, "/") + base = parts[count] + if (base in ids) next + if (base ~ /^[a-f0-9]{8}$/) { + print "invalid", "-", relpath + } else if (relpath in names) { + print "known", names[relpath], relpath + } else { + print "new", "-", relpath + } + } + ' "$manifest_input" "$candidates" > "$plan_file"; then + rm -rf "$workspace" + return 1 + fi - # Restore files to their known IDs - for relpath in ${to_restore[@]+"${to_restore[@]}"}; do - local id - if ! id=$(manifest_id_for_name "$manifest" "$relpath"); then - id="" + local kind id + while IFS=$'\t' read -r kind id relpath; do + if [ "$kind" = "invalid" ]; then + rm -rf "$workspace" + refuse_if_hex_basename "$relpath" + return 1 fi + done < "$plan_file" + + rm -rf "$workspace" +} + +# Apply a precomputed obfuscation plan. +# Outputs "\t" per renamed file for callers to stage. +# Usage: apply_obfuscation_plan +apply_obfuscation_plan() { + local notes_dir="$1" plan_file="$2" scoped_mode="$3" + local manifest="$notes_dir/.manifest" + local new_entries kind id relpath new_count=0 + + [ -s "$plan_file" ] || return 2 + + new_entries=$(mktemp) || { + echo "Error: failed to create temp file" >&2 + return 1 + } + + while IFS=$'\t' read -r kind id relpath; do + [ "$kind" = "known" ] || continue if ! mv "$notes_dir/$relpath" "$notes_dir/$id"; then echo "Error: failed to rename $relpath → $id" >&2 rm -f "$new_entries" return 1 fi printf '%s\t%s\n' "$relpath" "$id" - done + done < "$plan_file" - # Generate IDs and rename new files - for relpath in ${to_rename[@]+"${to_rename[@]}"}; do - local id + while IFS=$'\t' read -r kind id relpath; do + [ "$kind" = "new" ] || continue id=$(openssl rand -hex 4) while manifest_has_id "$manifest" "$id" || \ grep -q "^${id}"$'\t' "$new_entries" 2>/dev/null || \ @@ -147,7 +160,8 @@ rename_to_obfuscated() { return 1 fi printf '%s\t%s\n' "$relpath" "$id" - done + new_count=$((new_count + 1)) + done < "$plan_file" # Empty-directory cleanup is cosmetic and must not invalidate completed renames. if ! find "$notes_dir" -mindepth 1 -type d -empty -delete 2>/dev/null; then @@ -158,7 +172,7 @@ rename_to_obfuscated() { # manifest at all. Re-sorting a valid but differently-ordered manifest creates # an order-only dirty worktree after the pre-commit hook, because scoped # obfuscation intentionally does not stage unchanged manifest mappings. - if $scoped_mode && [ ${#to_rename[@]} -eq 0 ]; then + if $scoped_mode && [ "$new_count" -eq 0 ]; then rm -f "$new_entries" return 0 fi @@ -166,13 +180,17 @@ rename_to_obfuscated() { # Update manifest: merge existing + new entries, sorted by name. # An entry is live if either its obfuscated id or readable name is on disk. local merged - merged=$(mktemp) || { echo "Error: failed to create temp file" >&2; rm -f "$new_entries"; return 1; } + merged=$(mktemp) || { + echo "Error: failed to create temp file" >&2 + rm -f "$new_entries" + return 1 + } if [ -f "$manifest" ]; then - while IFS=$'\t' read -r id name; do + while IFS=$'\t' read -r id relpath; do [ -z "$id" ] && continue - if [ -f "$notes_dir/$id" ] || [ -f "$notes_dir/$name" ]; then - printf '%s\t%s\n' "$id" "$name" + if [ -f "$notes_dir/$id" ] || [ -f "$notes_dir/$relpath" ]; then + printf '%s\t%s\n' "$id" "$relpath" fi done < "$manifest" > "$merged" fi @@ -181,7 +199,11 @@ rename_to_obfuscated() { # Sort to a temp file first, then mv — avoids truncating the manifest # if sort fails (sort > $manifest truncates before sort runs). local sorted - sorted=$(mktemp) || { echo "Error: failed to create temp file" >&2; rm -f "$merged" "$new_entries"; return 1; } + sorted=$(mktemp) || { + echo "Error: failed to create temp file" >&2 + rm -f "$merged" "$new_entries" + return 1 + } if ! sort -t$'\t' -k2 "$merged" > "$sorted"; then echo "Error: failed to sort manifest" >&2 rm -f "$merged" "$new_entries" "$sorted" @@ -191,6 +213,31 @@ rename_to_obfuscated() { rm -f "$merged" "$new_entries" } +# Rename readable files to obfuscated IDs. +# Outputs "\t" per renamed file (for callers to stage). +# Usage: rename_to_obfuscated [file...] +# Without files: scans notes_dir for all non-obfuscated files. +# With files: only processes the listed files (relative to notes_dir). +rename_to_obfuscated() { + local notes_dir="$1" + shift + local scoped_mode=false plan rc + [ "$#" -gt 0 ] && [ -n "${1:-}" ] && scoped_mode=true + + plan=$(mktemp) || { + echo "Error: failed to create temp file" >&2 + return 1 + } + if ! build_obfuscation_plan "$notes_dir" "$plan" "$@"; then + rm -f "$plan" + return 1 + fi + + apply_obfuscation_plan "$notes_dir" "$plan" "$scoped_mode" && rc=0 || rc=$? + rm -f "$plan" + return "$rc" +} + # Local state file recording the readable path and content hash last restored # for each ID. This lets deobfuscation distinguish a clean generated readable # file (safe to update/remove after pull/merge/checkout) from a locally-edited diff --git a/test/obfuscate.bats b/test/obfuscate.bats index 5164cdc..fabdeb4 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -174,6 +174,92 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] } +@test "full obfuscate batches Fold-scale known-entry classification and staging" { + local count=535 i=1 id name mock_bin command real_command call_log + rm -rf "$NOTES_CALLER_PWD/notes" + mkdir -p "$NOTES_CALLER_PWD/notes" + + while [ "$i" -le "$count" ]; do + id=$(printf '%08x' "$i") + name=$(printf 'note-%03d.md' "$i") + printf '%s\t%s\n' "$id" "$name" >> "$NOTES_CALLER_PWD/notes/.manifest" + printf '# Note %d\n' "$i" > "$NOTES_CALLER_PWD/notes/$id" + i=$((i + 1)) + done + git -C "$NOTES_CALLER_PWD" add -A + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated corpus" + + while IFS=$'\t' read -r id name; do + mv "$NOTES_CALLER_PWD/notes/$id" "$NOTES_CALLER_PWD/notes/$name" + done < "$NOTES_CALLER_PWD/notes/.manifest" + + mock_bin="$BATS_TEST_TMPDIR/obfuscate-count-bin" + call_log="$BATS_TEST_TMPDIR/obfuscate-calls" + mkdir -p "$mock_bin" + : > "$call_log" + for command in git grep basename awk; do + real_command=$(command -v "$command") + if [ "$command" = "git" ]; then + cat > "$mock_bin/$command" <> "\$OBFUSCATE_CALL_LOG" +exec '$real_command' "\$@" +SH + else + cat > "$mock_bin/$command" <> "\$OBFUSCATE_CALL_LOG" +exec '$real_command' "\$@" +SH + fi + chmod +x "$mock_bin/$command" + done + + PATH="$mock_bin:$PATH" OBFUSCATE_CALL_LOG="$call_log" run notes obfuscate + + local awk_calls grep_calls basename_calls add_calls rm_calls + awk_calls=$(grep -c '^awk$' "$call_log" || true) + grep_calls=$(grep -c '^grep$' "$call_log" || true) + basename_calls=$(grep -c '^basename$' "$call_log" || true) + add_calls=$(grep -c $'^git\t.* add -- notes/' "$call_log" || true) + rm_calls=$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true) + [ "$status" -eq 0 ] + [[ "$output" == *"Obfuscated 535 file(s)"* ]] + # At most one corpus planner plus one existing full-suppression index stream. + [ "$awk_calls" -le 2 ] + [ "$grep_calls" -eq 0 ] + [ "$basename_calls" -eq 0 ] + [ "$add_calls" -eq 1 ] + [ "$rm_calls" -eq 1 ] +} + +@test "scoped obfuscate reads the HEAD manifest once and batches staging" { + local mock_bin command real_command call_log + notes obfuscate + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated" + notes deobfuscate + + mock_bin="$BATS_TEST_TMPDIR/scoped-obfuscate-count-bin" + call_log="$BATS_TEST_TMPDIR/scoped-obfuscate-calls" + mkdir -p "$mock_bin" + : > "$call_log" + real_command=$(command -v git) + cat > "$mock_bin/git" <> "\$OBFUSCATE_CALL_LOG" +exec '$real_command' "\$@" +SH + chmod +x "$mock_bin/git" + + PATH="$mock_bin:$PATH" OBFUSCATE_CALL_LOG="$call_log" run \ + notes obfuscate alpha.md beta.md + + [ "$status" -eq 0 ] + [ "$(grep -c $'^git\t.* cat-file --filters HEAD:notes/.manifest$' "$call_log" || true)" -eq 1 ] + [ "$(grep -c $'^git\t.* add -- notes/' "$call_log" || true)" -eq 1 ] + [ "$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true)" -eq 1 ] +} + # --- Stale manifest cleanup --- @test "obfuscate removes stale entries for deleted files" { From 43dceb705eee8e17ca4a47038b0903a94b799b77 Mon Sep 17 00:00:00 2001 From: johnson Date: Mon, 27 Jul 2026 14:59:36 +0000 Subject: [PATCH 2/6] fix: reject obfuscation path collisions --- lib/obfuscate.sh | 18 +++++++++++++++++- test/obfuscate.bats | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/obfuscate.sh b/lib/obfuscate.sh index 391abfe..631499b 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -83,6 +83,8 @@ build_obfuscation_plan() { if ! awk -F '\t' -v OFS='\t' -v candidates="$candidates" ' FILENAME != candidates { if ($1 != "") { + if (($1 in id_names) && id_names[$1] != $2) duplicate_ids[$1] = 1 + id_names[$1] = $2 ids[$1] = 1 if (!($2 in names)) names[$2] = $1 } @@ -97,7 +99,11 @@ build_obfuscation_plan() { if (base ~ /^[a-f0-9]{8}$/) { print "invalid", "-", relpath } else if (relpath in names) { - print "known", names[relpath], relpath + if (names[relpath] in duplicate_ids) { + print "collision", names[relpath], relpath + } else { + print "known", names[relpath], relpath + } } else { print "new", "-", relpath } @@ -114,6 +120,16 @@ build_obfuscation_plan() { refuse_if_hex_basename "$relpath" return 1 fi + if [ "$kind" = "collision" ]; then + rm -rf "$workspace" + echo "Error: manifest ID '$id' maps to multiple readable paths" >&2 + return 1 + fi + if [ "$kind" = "known" ] && [ -e "$notes_dir/$id" ]; then + rm -rf "$workspace" + echo "Error: refusing to overwrite existing obfuscated path: $id" >&2 + return 1 + fi done < "$plan_file" rm -rf "$workspace" diff --git a/test/obfuscate.bats b/test/obfuscate.bats index fabdeb4..ac9f9d5 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -174,6 +174,40 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] } +@test "obfuscate refuses to overwrite an existing known-ID destination" { + notes obfuscate + local alpha_id + alpha_id=$(grep $'\talpha\.md$' "$NOTES_CALLER_PWD/notes/.manifest" | cut -f1) + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated" + + # Simulate an interrupted or stale state containing both representations. + printf 'dirty readable content\n' > "$NOTES_CALLER_PWD/notes/alpha.md" + local obfuscated_before + obfuscated_before=$(cat "$NOTES_CALLER_PWD/notes/$alpha_id") + + run notes obfuscate alpha.md + + [ "$status" -ne 0 ] + [[ "$output" == *"refusing to overwrite existing obfuscated path: $alpha_id"* ]] + [ "$(cat "$NOTES_CALLER_PWD/notes/$alpha_id")" = "$obfuscated_before" ] + [ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" = "dirty readable content" ] +} + +@test "obfuscate refuses a manifest ID shared by planned readable paths" { + notes obfuscate + local alpha_id + alpha_id=$(grep $'\talpha\.md$' "$NOTES_CALLER_PWD/notes/.manifest" | cut -f1) + notes deobfuscate + printf '%s\tbeta.md\n' "$alpha_id" >> "$NOTES_CALLER_PWD/notes/.manifest" + + run notes obfuscate alpha.md beta.md + + [ "$status" -ne 0 ] + [[ "$output" == *"manifest ID '$alpha_id' maps to multiple readable paths"* ]] + [ -f "$NOTES_CALLER_PWD/notes/alpha.md" ] + [ -f "$NOTES_CALLER_PWD/notes/beta.md" ] +} + @test "full obfuscate batches Fold-scale known-entry classification and staging" { local count=535 i=1 id name mock_bin command real_command call_log rm -rf "$NOTES_CALLER_PWD/notes" From 85029e1a83f148613c45433614d4f3d37048a003 Mon Sep 17 00:00:00 2001 From: johnson Date: Mon, 27 Jul 2026 15:05:05 +0000 Subject: [PATCH 3/6] fix: isolate scoped manifest staging --- .mise/tasks/obfuscate | 43 +++++++++++++++++++++++++++++++++++-------- README.md | 2 +- test/obfuscate.bats | 18 ++++++++++++++++-- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.mise/tasks/obfuscate b/.mise/tasks/obfuscate index 02b64f0..e1ab8bc 100755 --- a/.mise/tasks/obfuscate +++ b/.mise/tasks/obfuscate @@ -39,12 +39,17 @@ renamed=$(mktemp) || { echo "Error: failed to create rename result" >&2 exit 1 } -head_manifest=$(mktemp) || { +indexed_manifest=$(mktemp) || { rm -f "$plan" "$renamed" echo "Error: failed to create manifest snapshot" >&2 exit 1 } -trap 'rm -f "$plan" "$renamed" "$head_manifest"' EXIT +staged_manifest=$(mktemp) || { + rm -f "$plan" "$renamed" "$indexed_manifest" + echo "Error: failed to create staged manifest" >&2 + exit 1 +} +trap 'rm -f "$plan" "$renamed" "$indexed_manifest" "$staged_manifest"' EXIT if ! build_obfuscation_plan "$abs_notes_dir" "$plan" ${ARGS[@]+"${ARGS[@]}"}; then echo "Error: obfuscation planning failed" >&2 @@ -109,15 +114,16 @@ git -C "$TARGET_DIR" rm --cached --quiet --ignore-unmatch -- \ "${readable_paths[@]}" # Full mode always owns the complete manifest. Scoped mode stages it only when -# a renamed mapping is absent from HEAD, preventing unrelated uncommitted -# mappings from leaking into a pre-commit operation. +# a renamed mapping is absent from the index, preventing unrelated unstaged +# mappings from leaking while preserving manifest changes already selected by +# notes stage. stage_manifest=false if ! $scoped_mode; then stage_manifest=true else if ! git -C "$TARGET_DIR" cat-file --filters \ - "HEAD:$notes_dir/.manifest" > "$head_manifest" 2>/dev/null; then - : > "$head_manifest" + ":$notes_dir/.manifest" > "$indexed_manifest" 2>/dev/null; then + : > "$indexed_manifest" fi if ! awk -F '\t' -v renamed="$renamed" ' FILENAME != renamed { @@ -126,13 +132,34 @@ else } !(($2 FS $1) in entries) { missing = 1 } END { exit missing ? 1 : 0 } - ' "$head_manifest" "$renamed"; then + ' "$indexed_manifest" "$renamed"; then stage_manifest=true fi fi if $stage_manifest; then - git -C "$TARGET_DIR" add "$manifest" + if ! $scoped_mode; then + git -C "$TARGET_DIR" add "$manifest" + else + # Stage HEAD plus only this operation's mappings. Keep unrelated working + # manifest edits in the worktree rather than leaking them into the index. + awk -F '\t' -v OFS='\t' -v renamed="$renamed" ' + FILENAME == renamed { + target_ids[$2] = 1 + target_names[$1] = 1 + rows[++count] = $2 FS $1 + next + } + !(($1 in target_ids) || ($2 in target_names)) { print } + END { + for (i = 1; i <= count; i++) print rows[i] + } + ' "$renamed" "$indexed_manifest" > "$staged_manifest" + manifest_blob=$(git -C "$TARGET_DIR" hash-object -w \ + --path="$notes_dir/.manifest" "$staged_manifest") + git -C "$TARGET_DIR" update-index --add --cacheinfo \ + 100644 "$manifest_blob" "$notes_dir/.manifest" + fi fi echo "" diff --git a/README.md b/README.md index 59f8186..e76b896 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 416](https://img.shields.io/badge/tests-416-brightgreen?style=flat)](test/) +[![tests: 417](https://img.shields.io/badge/tests-417-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/obfuscate.bats b/test/obfuscate.bats index fabdeb4..b3a477b 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -174,6 +174,20 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] } +@test "scoped new mapping does not stage unrelated manifest edits" { + printf '33333333\tbeta.md\n' > "$NOTES_CALLER_PWD/notes/.manifest" + + notes obfuscate alpha.md + + local staged_manifest working_manifest + staged_manifest=$(git -C "$NOTES_CALLER_PWD" show :notes/.manifest) + working_manifest=$(cat "$NOTES_CALLER_PWD/notes/.manifest") + [[ "$staged_manifest" == *$'\talpha.md'* ]] + [[ "$staged_manifest" != *$'\tbeta.md'* ]] + [[ "$working_manifest" == *$'\talpha.md'* ]] + [[ "$working_manifest" == *$'33333333\tbeta.md'* ]] +} + @test "full obfuscate batches Fold-scale known-entry classification and staging" { local count=535 i=1 id name mock_bin command real_command call_log rm -rf "$NOTES_CALLER_PWD/notes" @@ -233,7 +247,7 @@ SH [ "$rm_calls" -eq 1 ] } -@test "scoped obfuscate reads the HEAD manifest once and batches staging" { +@test "scoped obfuscate reads the indexed manifest once and batches staging" { local mock_bin command real_command call_log notes obfuscate git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "obfuscated" @@ -255,7 +269,7 @@ SH notes obfuscate alpha.md beta.md [ "$status" -eq 0 ] - [ "$(grep -c $'^git\t.* cat-file --filters HEAD:notes/.manifest$' "$call_log" || true)" -eq 1 ] + [ "$(grep -c $'^git\t.* cat-file --filters :notes/.manifest$' "$call_log" || true)" -eq 1 ] [ "$(grep -c $'^git\t.* add -- notes/' "$call_log" || true)" -eq 1 ] [ "$(grep -c $'^git\t.* rm --cached --quiet --ignore-unmatch -- notes/' "$call_log" || true)" -eq 1 ] } From 559be4323493e81550a7a12b840a2fa73f355e27 Mon Sep 17 00:00:00 2001 From: johnson Date: Mon, 27 Jul 2026 15:05:43 +0000 Subject: [PATCH 4/6] docs: update test count --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59f8186..ae7a974 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 416](https://img.shields.io/badge/tests-416-brightgreen?style=flat)](test/) +[![tests: 418](https://img.shields.io/badge/tests-418-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) From 95c61d687ba3f5904123b5213d7d6d5de4e110ad Mon Sep 17 00:00:00 2001 From: junior Date: Mon, 27 Jul 2026 12:23:18 -0400 Subject: [PATCH 5/6] fix: preserve dangling obfuscated destinations --- README.md | 2 +- lib/obfuscate.sh | 2 +- test/obfuscate.bats | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ae7a974..2bf7ce3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 418](https://img.shields.io/badge/tests-418-brightgreen?style=flat)](test/) +[![tests: 419](https://img.shields.io/badge/tests-419-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/obfuscate.sh b/lib/obfuscate.sh index 631499b..1bc3eb5 100644 --- a/lib/obfuscate.sh +++ b/lib/obfuscate.sh @@ -125,7 +125,7 @@ build_obfuscation_plan() { echo "Error: manifest ID '$id' maps to multiple readable paths" >&2 return 1 fi - if [ "$kind" = "known" ] && [ -e "$notes_dir/$id" ]; then + if [ "$kind" = "known" ] && { [ -e "$notes_dir/$id" ] || [ -L "$notes_dir/$id" ]; }; then rm -rf "$workspace" echo "Error: refusing to overwrite existing obfuscated path: $id" >&2 return 1 diff --git a/test/obfuscate.bats b/test/obfuscate.bats index ac9f9d5..786ebec 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -193,6 +193,23 @@ setup() { [ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" = "dirty readable content" ] } +@test "obfuscate refuses to replace a dangling known-ID symlink" { + notes obfuscate + local alpha_id + alpha_id=$(grep $'\talpha\.md$' "$NOTES_CALLER_PWD/notes/.manifest" | cut -f1) + rm "$NOTES_CALLER_PWD/notes/$alpha_id" + printf 'readable content\n' > "$NOTES_CALLER_PWD/notes/alpha.md" + ln -s missing-target "$NOTES_CALLER_PWD/notes/$alpha_id" + + run notes obfuscate alpha.md + + [ "$status" -ne 0 ] + [[ "$output" == *"refusing to overwrite existing obfuscated path: $alpha_id"* ]] + [ -L "$NOTES_CALLER_PWD/notes/$alpha_id" ] + [ "$(readlink "$NOTES_CALLER_PWD/notes/$alpha_id")" = "missing-target" ] + [ "$(cat "$NOTES_CALLER_PWD/notes/alpha.md")" = "readable content" ] +} + @test "obfuscate refuses a manifest ID shared by planned readable paths" { notes obfuscate local alpha_id From 9d83656766f488135c22211d83e5270fc8bb19d4 Mon Sep 17 00:00:00 2001 From: junior Date: Mon, 27 Jul 2026 12:32:29 -0400 Subject: [PATCH 6/6] fix: preserve canonical scoped manifest order --- .mise/tasks/obfuscate | 8 +++++--- test/obfuscate.bats | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.mise/tasks/obfuscate b/.mise/tasks/obfuscate index e1ab8bc..778db39 100755 --- a/.mise/tasks/obfuscate +++ b/.mise/tasks/obfuscate @@ -141,8 +141,9 @@ if $stage_manifest; then if ! $scoped_mode; then git -C "$TARGET_DIR" add "$manifest" else - # Stage HEAD plus only this operation's mappings. Keep unrelated working - # manifest edits in the worktree rather than leaking them into the index. + # Stage the indexed manifest plus only this operation's mappings. Keep + # unrelated working edits out of the index, and use the same canonical + # name ordering as the working manifest. awk -F '\t' -v OFS='\t' -v renamed="$renamed" ' FILENAME == renamed { target_ids[$2] = 1 @@ -154,7 +155,8 @@ if $stage_manifest; then END { for (i = 1; i <= count; i++) print rows[i] } - ' "$renamed" "$indexed_manifest" > "$staged_manifest" + ' "$renamed" "$indexed_manifest" \ + | sort -t$'\t' -k2 > "$staged_manifest" manifest_blob=$(git -C "$TARGET_DIR" hash-object -w \ --path="$notes_dir/.manifest" "$staged_manifest") git -C "$TARGET_DIR" update-index --add --cacheinfo \ diff --git a/test/obfuscate.bats b/test/obfuscate.bats index b3a477b..74ae97b 100644 --- a/test/obfuscate.bats +++ b/test/obfuscate.bats @@ -174,18 +174,32 @@ setup() { [ -f "$NOTES_CALLER_PWD/notes/$alpha_id" ] } -@test "scoped new mapping does not stage unrelated manifest edits" { - printf '33333333\tbeta.md\n' > "$NOTES_CALLER_PWD/notes/.manifest" +@test "scoped new mapping stages canonical order without unrelated manifest edits" { + # Select a later-sorting mapping in the index, then leave another mapping + # only in the working manifest. + printf '22222222\tgamma.txt\n' > "$NOTES_CALLER_PWD/notes/.manifest" + git -C "$NOTES_CALLER_PWD" add -f notes/.manifest + printf '33333333\tbeta.md\n' >> "$NOTES_CALLER_PWD/notes/.manifest" notes obfuscate alpha.md - local staged_manifest working_manifest + local staged_manifest staged_names working_manifest staged_manifest=$(git -C "$NOTES_CALLER_PWD" show :notes/.manifest) + staged_names=$(printf '%s\n' "$staged_manifest" | cut -f2) working_manifest=$(cat "$NOTES_CALLER_PWD/notes/.manifest") - [[ "$staged_manifest" == *$'\talpha.md'* ]] + [ "$staged_names" = $'alpha.md\ngamma.txt' ] [[ "$staged_manifest" != *$'\tbeta.md'* ]] [[ "$working_manifest" == *$'\talpha.md'* ]] [[ "$working_manifest" == *$'33333333\tbeta.md'* ]] + + # After committing and removing the intentionally unstaged mapping, no + # order-only manifest difference remains. + git -C "$NOTES_CALLER_PWD" commit -q --no-verify -m "scoped obfuscation" + grep -v $'33333333\tbeta.md' "$NOTES_CALLER_PWD/notes/.manifest" \ + > "$NOTES_CALLER_PWD/notes/.manifest.filtered" + mv "$NOTES_CALLER_PWD/notes/.manifest.filtered" \ + "$NOTES_CALLER_PWD/notes/.manifest" + git -C "$NOTES_CALLER_PWD" diff --quiet -- notes/.manifest } @test "full obfuscate batches Fold-scale known-entry classification and staging" {