diff --git a/.github/actions/term-wall/CONTRACT.md b/.github/actions/term-wall/CONTRACT.md index a81af96..e54e6f1 100644 --- a/.github/actions/term-wall/CONTRACT.md +++ b/.github/actions/term-wall/CONTRACT.md @@ -95,3 +95,7 @@ no dependence on the caller's cwd, environment, or git identity (configure user.name/user.email in each temp repo). Every test asserts the exit code and the output shape. Push and pull-request events are simulated with an event JSON file and the `GITHUB_*` variables. + +## Content surface, pinned + +The content surface is every blob the scanned commit's tree tracks, read from the object store, never from the working tree. Every blob is scanned bytewise; nothing tracked is unscannable. A symlink entry is scanned as the blob it is, its target path text, and is never followed. A blob that is not valid UTF-8 reports each hit with the location ` line `, `n` counting newline-separated segments from 1, and the third field `[binary blob]` in place of the line. A blob the wall cannot read is a refusal of class `git work tree`: `git work tree: expected a readable blob at ; found ; needed the object`. diff --git a/.github/actions/term-wall/term-wall.sh b/.github/actions/term-wall/term-wall.sh index f7b123f..64b8bbe 100755 --- a/.github/actions/term-wall/term-wall.sh +++ b/.github/actions/term-wall/term-wall.sh @@ -10,7 +10,12 @@ # may not. # # Surfaces, in order: -# 1. tracked file content (case-insensitive, binaries skipped) +# 1. tracked file content — every blob the scanned commit's tree +# tracks, read from the object store, never from the working tree, +# and scanned bytewise (case-insensitive): nothing tracked is +# unscannable. A symlink entry is scanned as the blob it is — its +# target path text — and never followed. A blob that is not valid +# UTF-8 reports each hit with "[binary blob]" in place of the line. # 2. tracked file paths # 3. the commit messages of the change — pull_request: base..head; # push: before..head, or only the head commit when before is all @@ -77,13 +82,41 @@ scan_name() { # scan_name — for surfaces whose location emit "$surface" "$masked" "$masked" } -# 1. tracked content -content=$(git ls-files -z 2>/dev/null | xargs -0 -r grep -I -H -i -n -E -- "$pat" 2>/dev/null | mask || true) -if [[ -n $content ]]; then - while IFS= read -r line; do - file=${line%%:*}; rest=${line#*:} - emit "content" "$file line ${rest%%:*}" "${rest#*:}" - done <<< "$content" +# 1. tracked content — every blob of the scanned commit's tree, from the +# object store, bytewise (grep -a, never -I). A symlink entry (mode +# 120000) is a blob holding its target path and is never followed; a +# gitlink (type commit) is not a blob and has no content here. A blob +# the wall cannot read is a refusal — could-not-look is never a pass. +if git rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + git ls-tree -r HEAD >/dev/null 2>&1 \ + || refuse 'git work tree: expected a readable tree at HEAD; found git ls-tree cannot read it; needed the object' + while IFS= read -r -d '' entry; do + meta=${entry%%$'\t'*} + path=${entry#*$'\t'} + read -r _mode type oid <<< "$meta" + [[ $type == blob ]] || continue + if ! err=$(git cat-file -e "$oid" 2>&1); then + err=$(printf '%s' "${err:-git cat-file cannot read $oid}" | tr '\n' ' ') + refuse "$(printf 'git work tree: expected a readable blob at %s; found %s; needed the object' "$path" "$err" | mask)" + fi + if git cat-file blob "$oid" 2>/dev/null | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1; then + # NUL is valid UTF-8 (U+0000) but a shell variable cannot hold + # it — drop it before masking, never after. + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | tr -d '\000' | mask || true) + [[ -n $found ]] || continue + while IFS= read -r line; do + emit "content" "$path line ${line%%:*}" "${line#*:}" + done <<< "$found" + else + # Not valid UTF-8: never echo its bytes — only line numbers + # (newline-separated segments, from 1) and "[binary blob]". + found=$(git cat-file blob "$oid" 2>/dev/null | grep -a -i -n -E -- "$pat" | cut -d: -f1 || true) + [[ -n $found ]] || continue + while IFS= read -r n; do + emit "content" "$path line $n" "[binary blob]" + done <<< "$found" + fi + done < <(git ls-tree -r -z HEAD 2>/dev/null) fi # 2. tracked paths diff --git a/.github/actions/term-wall/tests/test_term_wall.py b/.github/actions/term-wall/tests/test_term_wall.py index b3448b7..bdd2e43 100644 --- a/.github/actions/term-wall/tests/test_term_wall.py +++ b/.github/actions/term-wall/tests/test_term_wall.py @@ -119,11 +119,62 @@ def test_tracked_content_hit_is_masked(self): self.commit("add fixture") self.assert_hit(self.run_wall(), "content") - def test_binary_tracked_content_is_skipped(self): - (self.repo / "fixture.bin").write_bytes(b"\x00" + PLANT.encode("ascii") + b"\xff") - self.commit("add binary fixture") + def test_non_utf8_committed_blob_hit_uses_binary_placeholder(self): + (self.repo / "fixture.bin").write_bytes( + b"ordinary first line\ncontains " + PLANT.encode("ascii") + b" \xff\n" + ) + self.commit("add non-UTF-8 fixture") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: fixture.bin line 2: [binary blob]\n", + ) + + def test_dangling_symlink_target_text_is_scanned_as_content(self): + os.symlink(f"absent-{PLANT}-target", self.repo / "dangling-link") + self.commit("add dangling symlink") + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: dangling-link line 1: absent-[forbidden name]-target\n", + ) + + def test_clean_symlink_to_committed_clean_file_is_clean(self): + os.symlink("clean.txt", self.repo / "clean-link") + self.commit("add clean symlink") + + self.assert_clean(self.run_wall()) + + def test_uncommitted_working_tree_content_is_not_scanned(self): + self.write("tracked.txt", "committed clean content\n") + self.commit("add clean tracked file") + self.write("tracked.txt", f"working tree contains {PLANT}\n") + self.assert_clean(self.run_wall()) + def test_committed_blob_deleted_only_from_working_tree_is_scanned(self): + self.write("deleted.txt", f"committed content contains {PLANT}\n") + self.commit("add planted tracked file") + (self.repo / "deleted.txt").unlink() + + result = self.run_wall() + + self.assertEqual(result.returncode, 1, result) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + "content: deleted.txt line 1: committed content contains " + "[forbidden name]\n", + ) + def test_tracked_path_hit_is_masked(self): self.write(f"notes-{PLANT}.txt", "ordinary text\n") self.commit("add fixture") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e66a322..1f688ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: t=$(mktemp -d); cd "$t"; git init -q; git config user.email ci@ci.invalid; git config user.name ci printf 'clean\n' > c.txt; git add c.txt; git commit -qm clean env -u GITHUB_EVENT_PATH bash "$wall" - printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt + printf 'x %s y\n' "$TERM_WALL_PLANT" > p.txt; git add p.txt; git commit -qm plant rc=0; out=$(env -u GITHUB_EVENT_PATH bash "$wall") || rc=$? [ "$rc" -eq 1 ] || { echo "::error::the wall did not exit 1 on a planted fault (exit $rc)"; exit 1; } case $out in *"$TERM_WALL_PLANT"*) echo "::error::the wall printed the raw plant"; exit 1;; esac