From 6239db142bd9f552f41777a84e443850c42cc53c Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 04:58:29 +0000 Subject: [PATCH 1/2] fix: reject unreadable manifest path shapes --- README.md | 2 +- lib/diff.sh | 31 +++++++++++++++++++++++++++++++ test/diff.bats | 26 ++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 15ab63f..e70606e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Collective memory, encrypted.** -[![tests: 412](https://img.shields.io/badge/tests-412-brightgreen?style=flat)](test/) +[![tests: 413](https://img.shields.io/badge/tests-413-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/diff.sh b/lib/diff.sh index 2795a8c..486449b 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -22,6 +22,32 @@ _guard_manifest_path() { esac } +_validate_manifest_readable_paths() { + local ref="$1" manifest="$2" + + awk -F '\t' -v ref="$ref" ' + { + separator = index($0, FS) + path = substr($0, separator + 1) + paths[path] = 1 + } + END { + invalid = 0 + for (path in paths) { + ancestor = path + while (sub(/\/[^/]+$/, "", ancestor)) { + if (ancestor in paths) { + printf "Error: %s readable path is both a file and directory: %s\n", ref, ancestor > "/dev/stderr" + invalid = 1 + break + } + } + } + exit invalid + } + ' "$manifest" +} + # Build and validate one ref's readable-name index without materializing note blobs. _prepare_readable_notes_ref_index() { local repo_root="$1" notes_dir="$2" ref="$3" manifest_out="$4" @@ -107,6 +133,11 @@ _prepare_readable_notes_ref_index() { } ' "$tree_ids" "$validated_manifest" + if ! _validate_manifest_readable_paths "$ref" "$manifest_out"; then + rm -f "$tree_paths" "$tree_ids" "$raw_manifest" "$validated_manifest" "$missing_manifest" "$unmapped_file" + return 1 + fi + while IFS=$'\t' read -r id relpath; do [ -z "$id" ] && continue echo "Warning: $ref manifest maps $id to $relpath, but $notes_dir/$id is missing" >&2 diff --git a/test/diff.bats b/test/diff.bats index 16d13cb..b1920af 100644 --- a/test/diff.bats +++ b/test/diff.bats @@ -172,6 +172,32 @@ commit_readable_update() { ! grep -q "a/notes/beta.md" "$out_dir/readable.patch" } +@test "notes diff rejects readable file and directory path collisions" { + local alpha_id beta_id manifest next_manifest + manifest="$NOTES_CALLER_PWD/notes/.manifest" + alpha_id=$(awk -F '\t' '$2 == "alpha.md" { print $1 }' "$manifest") + beta_id=$(awk -F '\t' '$2 == "beta.md" { print $1 }' "$manifest") + next_manifest="$BATS_TEST_TMPDIR/prefix-colliding.manifest" + + { + printf '%s\tshared\n' "$alpha_id" + printf '%s\tshared/child.md\n' "$beta_id" + } > "$next_manifest" + mv "$next_manifest" "$manifest" + git -C "$NOTES_CALLER_PWD" add notes/.manifest + git -C "$NOTES_CALLER_PWD" commit -q -m "create readable path prefix collision" + + echo "# Beta changed" > "$NOTES_CALLER_PWD/notes/$beta_id" + git -C "$NOTES_CALLER_PWD" add "notes/$beta_id" + git -C "$NOTES_CALLER_PWD" commit -q -m "edit colliding note" + + run notes diff HEAD~1 HEAD + + [ "$status" -ne 0 ] + [[ "$output" == *"readable path is both a file and directory: shared"* ]] + [[ "$output" != *"# Beta changed"* ]] +} + @test "ref diff Git process count does not grow with unchanged notes" { local i real_git counter_bin calls git_call_count rename_to_readable "$NOTES_CALLER_PWD/notes" > /dev/null From 84449ddbba5bf9afa091b27def10487b168b65e3 Mon Sep 17 00:00:00 2001 From: johnson Date: Sun, 26 Jul 2026 05:07:19 +0000 Subject: [PATCH 2/2] fix: keep path validation portable --- lib/diff.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/diff.sh b/lib/diff.sh index 486449b..94b4f2d 100644 --- a/lib/diff.sh +++ b/lib/diff.sh @@ -35,7 +35,7 @@ _validate_manifest_readable_paths() { invalid = 0 for (path in paths) { ancestor = path - while (sub(/\/[^/]+$/, "", ancestor)) { + while (sub(/\/[^\/]+$/, "", ancestor)) { if (ancestor in paths) { printf "Error: %s readable path is both a file and directory: %s\n", ref, ancestor > "/dev/stderr" invalid = 1