testmark-bash

parse: open the file once, and check it's not a directory through that fd.

commit 23989549e75a655d789d8183de724a92e70a201b · parent ecf703ab46 · browse this tree
Ximianth, 2026-10-23 23:33:08 UTC

Testing the path with -f and -r, then opening it, is a time-of-check-time-of-use gap.
Now the redirect is the check for missing and unreadable files,
and `-d /dev/stdin` inside the redirected block catches a directory,
which would otherwise read as empty input with no error.

Those failures now print bash's own message (file and reason) before the library's line.
Bash prefixes its message with the library file and the function's first line,
which moves with any edit above the function,
so expect_fail cuts that prefix and the tests pin the rest.
diff --git a/lib/testmark/document.bash b/lib/testmark/document.bash
index d6f8899..54b9264 100644
--- a/lib/testmark/document.bash
+++ b/lib/testmark/document.bash
@@ -34,14 +34,10 @@ _testmark_parse() {
 	local h=$1 file=$2 kind
 	_testmark_handle "$h" || return 1
 	for kind in "${_testmark_kinds[@]}"; do unset -v "_testmark_${kind}_$h"; done
-	# A directory would read as zero lines, so it's rejected here.
-	[[ -f $file && -r $file ]] || {
-		_testmark_err "$file: not a readable file"
-		return 1
-	}
 	local -a lines
-	mapfile lines <"$file" || {
-		_testmark_err "$file: read failed"
+	# See "use blocks to open an fd and check properties on it without TOCTOU" in docs/hacking/bash-pitfalls.md.
+	{ [[ ! -d /dev/stdin ]] && mapfile lines; } <"$file" || {
+		_testmark_err "$file: not a readable file"
 		return 1
 	}
 
diff --git a/tests/test-api.sh b/tests/test-api.sh
index d91b3c7..55d45f6 100755
--- a/tests/test-api.sh
+++ b/tests/test-api.sh
@@ -6,17 +6,19 @@ cd "$(dirname "$0")"
 source ./lib.sh
 
 ## Runs a command that should fail, and checks it printed exactly the given error and nothing on stdout.
+## A message from bash itself starts with the library file and line it came from; that prefix is cut, so library edits don't move it.
 ##
 ## - @param $1 description
 ## - @param $2 the expected stderr, without its final newline
 ## - @param $3... the command
 expect_fail() {
-	local rc=0
+	local rc=0 err
 	"${@:3}" >"$work/out" 2>"$work/err" || rc=$?
 	if [[ $rc == 0 ]]; then
 		_fail "$1: succeeded; want a failure"
 	else
-		expect_eq "$1" "$(<"$work/err")$(<"$work/out")" "$2"
+		err=$(<"$work/err")
+		expect_eq "$1" "${err#"$ROOT/lib/"*": line "*": "}$(<"$work/out")" "$2"
 	fi
 }
 
@@ -60,7 +62,7 @@ expect_fail "re-parse drops the old doc's names" 'testmark: a: no hunk named "ol
 expect_eq "re-parse leaves other handles alone" "$(testmark_body b x)" "two"
 expect_ok "re-parse of the same file" testmark_parse b b.md
 expect_eq "and it reads" "$(testmark_body b x)" "two"
-expect_fail "a failed re-parse fails" 'testmark: missing.md: not a readable file' testmark_parse b missing.md
+expect_fail "a failed re-parse fails" 'missing.md: No such file or directory'$'\n''testmark: missing.md: not a readable file' testmark_parse b missing.md
 expect_fail "and leaves its handle unusable" 'testmark: unknown handle "b"' testmark_names b
 
 # --- what a parse doesn't keep is gone: white-box, comparing every library global and its value ---