From f2bcfddd27a7e2d6997eedf9320e45d2ae2043fb Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 22 Sep 2026 08:40:53 -0700 Subject: [PATCH 1/2] test: reproduce literal path expansion RED: bash test/literal-paths.sh reported 90 passed and 150 failed. The failures cover claim, batch, read, prefix, nested, one-match, multi-match, and seed 320032 across 64 deterministic stress cases. --- Makefile | 3 +- test/literal-paths.sh | 208 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100755 test/literal-paths.sh diff --git a/Makefile b/Makefile index fede374..95f0c92 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /usr/bin/env bash # lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks). -SCRIPTS := bin/git-locks test/test.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh +SCRIPTS := bin/git-locks test/test.sh test/literal-paths.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh PREFIX ?= $(HOME)/.local .PHONY: build lint test test-docker install uninstall @@ -14,6 +14,7 @@ lint: test: bash test/test.sh + bash test/literal-paths.sh test-docker: # the same suite inside the official bash image, for a wall between the tests and your machine docker run --rm -v "$(CURDIR)":/src -w /src bash:5.2 bash -c 'apk add --no-cache git python3 py3-jsonschema >/dev/null && git config --global user.email t@example.invalid && git config --global user.name t && bash test/test.sh' diff --git a/test/literal-paths.sh b/test/literal-paths.sh new file mode 100755 index 0000000..0a27929 --- /dev/null +++ b/test/literal-paths.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# Focused regression coverage for literal glob characters in path identity. +set -uo pipefail + +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_NAMESPACE + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PATH="${HERE}/../bin:${PATH}" +PASS=0 +FAIL=0 +FAILED=() +CASE_NUMBER=0 + +check() { # label got want + if [[ "$2" == "$3" ]]; then + PASS=$((PASS + 1)) + printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)) + FAILED+=("$1") + printf ' FAIL %s\n got: %q\n want: %q\n' "$1" "$2" "$3" + fi +} + +contains() { # label haystack needle + if [[ "$2" == *"$3"* ]]; then + PASS=$((PASS + 1)) + printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)) + FAILED+=("$1") + printf ' FAIL %s\n text: %q\n lacks: %q\n' "$1" "$2" "$3" + fi +} + +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/git-locks-literal-paths.XXXXXX")" +cleanup() { + if [[ -n "${TEST_ROOT:-}" && "${TEST_ROOT}" == "${TMPDIR:-/tmp}"/git-locks-literal-paths.* ]]; then + rm -rf -- "${TEST_ROOT}" + fi +} +trap cleanup EXIT + +export HOME="${TEST_ROOT}/home" +mkdir -p "${HOME}" +export GIT_LOCKS_NOW=1000000 + +new_case() { + CASE_NUMBER=$((CASE_NUMBER + 1)) + CASE_ROOT="${TEST_ROOT}/case-${CASE_NUMBER}" + WORK_ROOT="${CASE_ROOT}/work" + mkdir -p "${WORK_ROOT}" + git -C "${WORK_ROOT}" init -q -b main + export GIT_LOCKS_STORE="${CASE_ROOT}/store.git" + cd "${WORK_ROOT}" || exit 2 +} + +assert_claim_path() { # label job literal-path + local label="$1" job="$2" path="$3" out rc + out="$(git-locks claim --job "${job}" --holder alice "${path}" 2>&1)" + rc=$? + check "${label}: claim succeeds" "${rc}" "0" + contains "${label}: claim reports the literal path" "${out}" "\"paths\":[\"${path}\"]" +} + +# Golden reproduction: a matching neighbour must not change the requested key. +new_case +touch 'report[1].md' report1.md +out="$(git-locks claim --job report --holder alice 'report[1].md' 2>&1)" +check "bracket golden path: claim succeeds" "$?" "0" +contains "bracket golden path: claim reports the literal name" "${out}" '"paths":["report[1].md"]' +out="$(git-locks show --job report 2>&1)" +contains "bracket golden path: show keeps the literal name" "${out}" '"paths":["report[1].md"]' +out="$(git-locks list 2>&1)" +contains "bracket golden path: list keeps the literal name" "${out}" '"paths":["report[1].md"]' +out="$(git-locks check 'report[1].md' 2>&1)" +check "bracket golden path: the requested name is held" "$?" "1" +contains "bracket golden path: check reports the requested name" "${out}" '"path":"report[1].md"' +git-locks check report1.md >/dev/null 2>&1 +check "bracket golden path: the matching neighbour stays free" "$?" "0" +err="$(git-locks claim --job contender --holder bob 'report[1].md' 2>&1 >/dev/null)" +check "bracket golden path: a contender for the literal name is refused" "$?" "1" +contains "bracket golden path: refusal names the literal resource" "${err}" '"path":"report[1].md"' +git-locks claim --job neighbour --holder bob report1.md >/dev/null 2>&1 +check "bracket golden path: the matching neighbour remains claimable" "$?" "0" + +# Zero, one and several filesystem matches for every glob form. +new_case +assert_claim_path "star with zero matches" star-zero 'zero*.md' + +new_case +touch one-match.md +assert_claim_path "star with one match" star-one 'one*.md' + +new_case +touch many-a.md many-b.md +assert_claim_path "star with several matches" star-many 'many*.md' + +new_case +touch question1.md +assert_claim_path "question mark with one match" question-one 'question?.md' + +new_case +assert_claim_path "brackets with zero matches" bracket-zero 'bracket[ab].md' + +new_case +touch bracketa.md +assert_claim_path "brackets with one match" bracket-one 'bracket[ab].md' + +new_case +touch bracketa.md bracketb.md +assert_claim_path "brackets with several matches" bracket-many 'bracket[ab].md' + +# Each component is lexical. Matches in the working tree cannot rewrite nested names. +new_case +mkdir -p 'source[1]' source1 +touch 'source[1]/file?.md' source1/file1.md file1.md +assert_claim_path "nested glob components" nested 'source[1]/file?.md' +git-locks check source1/file1.md >/dev/null 2>&1 +check "nested glob components: the expanded neighbour stays free" "$?" "0" + +# A trailing slash remains the prefix marker while the preceding bytes stay literal. +new_case +mkdir -p 'dist[1]' dist1 +assert_claim_path "literal prefix" prefix 'dist[1]/' +out="$(git-locks check 'dist[1]/asset.js' 2>&1)" +check "literal prefix: a child of the requested prefix is held" "$?" "1" +contains "literal prefix: check names the literal prefix" "${out}" '"via":"dist[1]/"' +git-locks check dist1/asset.js >/dev/null 2>&1 +check "literal prefix: a child of the matching neighbour stays free" "$?" "0" + +# Batch takes the same literal path contract as claim. +new_case +touch batch1.md +out="$(printf 'job: batch\nholder: alice\npaths:\nbatch[1].md\n' | git-locks batch 2>&1)" +check "batch: a literal bracket path claims" "$?" "0" +contains "batch: output keeps the literal path" "${out}" '"paths":["batch[1].md"]' +out="$(git-locks show --job batch 2>&1)" +contains "batch: the stored record keeps the literal path" "${out}" '"paths":["batch[1].md"]' +git-locks check batch1.md >/dev/null 2>&1 +check "batch: the matching neighbour stays free" "$?" "0" + +# A later filesystem match must not change how a read resolves an existing key. +new_case +git-locks claim --job late --holder alice 'late?.md' >/dev/null 2>&1 +touch late1.md +out="$(git-locks check 'late?.md' 2>&1)" +check "read after filesystem change: the literal key remains held" "$?" "1" +contains "read after filesystem change: check reports the literal key" "${out}" '"path":"late?.md"' +git-locks check late1.md >/dev/null 2>&1 +check "read after filesystem change: the new matching path is free" "$?" "0" + +# Existing lexical rules remain in force around literal glob bytes. +new_case +out="$(git-locks claim --job lexical --holder alice './dir//./file[1].md' 2>&1)" +check "lexical rules: claim succeeds" "$?" "0" +contains "lexical rules: dot and empty components are removed" "${out}" '"paths":["dir/file[1].md"]' +git-locks check 'dir/file[1].md' >/dev/null 2>&1 +check "lexical rules: the normalized literal path is held" "$?" "1" +out="$(git-locks claim --job lexical-prefix --holder alice './prefix//./' 2>&1)" +check "lexical rules: normalized prefix claim succeeds" "$?" "0" +contains "lexical rules: a trailing slash remains" "${out}" '"paths":["prefix/"]' +git-locks claim --job absolute --holder alice '/absolute?.md' >/dev/null 2>&1 +check "lexical rules: absolute paths remain refused" "$?" "2" +git-locks claim --job parent --holder alice 'a/../b?.md' >/dev/null 2>&1 +check "lexical rules: parent components remain refused" "$?" "2" + +# Deterministic property stress: each generated glob-shaped key has one matching +# neighbour on disk. Reserving the key must never reserve its neighbour. +new_case +FUZZ_SEED=320032 +FUZZ_CASES=64 +fuzz_state=${FUZZ_SEED} +for ((i = 0; i < FUZZ_CASES; i++)); do + fuzz_state=$(((1103515245 * fuzz_state + 12345) & 0x7fffffff)) + digit=$((fuzz_state % 10)) + case $((i % 3)) in + 0) + literal="fuzz-${i}[${digit}].lock" + neighbour="fuzz-${i}${digit}.lock" + ;; + 1) + literal="fuzz-${i}-*.lock" + neighbour="fuzz-${i}-${digit}.lock" + ;; + *) + literal="fuzz-${i}-?.lock" + neighbour="fuzz-${i}-${digit}.lock" + ;; + esac + touch "${neighbour}" + out="$(git-locks claim --job "fuzz-${i}" --holder stress "${literal}" 2>&1)" + check "fuzz ${i}: claim succeeds" "$?" "0" + contains "fuzz ${i}: claim preserves path identity" "${out}" "\"paths\":[\"${literal}\"]" + git-locks check "${neighbour}" >/dev/null 2>&1 + check "fuzz ${i}: matching neighbour stays free" "$?" "0" +done +out="$(git-locks doctor 2>&1)" +check "fuzz seed ${FUZZ_SEED}: ${FUZZ_CASES} claims leave a healthy store" "$?" "0" +contains "fuzz seed ${FUZZ_SEED}: doctor reports no invariant findings" "${out}" '"healthy":true' + +printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" +if ((FAIL > 0)); then + printf 'failed:' + printf ' %s;' "${FAILED[@]}" + printf '\n' + exit 1 +fi From 77b59079cc42f3f078c68d50e9c46755e78723ab Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 22 Sep 2026 08:47:27 -0700 Subject: [PATCH 2/2] fix: preserve literal path identity Split path components with read -a before applying lexical normalization so Bash never expands *, ?, or bracket expressions against the working tree. Rebuild the executable and document the contract.\n\nGREEN: bash test/literal-paths.sh reported 240 passed and 0 failed, including seed 320032 across 64 deterministic cases. make lint passed. --- CHANGELOG.md | 4 ++++ README.md | 2 +- bin/git-locks | 5 +++-- lib/030-time-refs-records.sh | 5 +++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0a60c..b29cc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project are recorded here. The format follows Keep a ## [Unreleased] +### Fixed + +- Path normalisation preserves literal `*`, `?` and bracket characters instead of expanding them against files in the working tree. + ## [0.7.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index 64cdd64..8b3416d 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,7 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat **What `parent` means.** Ownership plus lifetime, not dependency ordering. A child is admitted only under a live parent held by the same holder. Liveness and holder are checked at planning time; what the generation bump adds at commit time is that the parent's record is unchanged since that check, so a release, a renewal or another child cannot have slipped in between. The bump does not re-check the clock: a parent that expires during the microseconds between planning and commit is still bumped, and its family ends at the next sweep or claim over it. The child is released or swept whenever the parent is, by any command, including a claim that evicts an expired parent. Expiry is not inherited: a child keeps its own `expires`, and a parent's expiry ends the family. Renewing a parent (`extend`) keeps its family. Recreating a job name after its release makes a new record with a fresh family, unrelated to the old one. -**What a path identifies.** The lexical form after normalisation: leading `./`, empty segments and `.` segments are removed; absolute paths and `..` are refused. `dir//file` and `dir/./file` are one key. Case, symlinks and hard links are not resolved. A trailing `/` is kept and means a prefix: `dir/` covers every path under it, and is covered by any live lock under it, in both directions and inside the transaction (a directory token ref per level, compared-and-swapped by every claim, is what makes a stale scan fail rather than land); `dir` without the slash is the directory entry itself, a different key, and a prefix does not cover it. Before 0.7.0 the slash was stripped; that is the one normalisation rule that changed. +**What a path identifies.** The lexical form after normalisation: leading `./`, empty segments and `.` segments are removed; absolute paths and `..` are refused. `dir//file` and `dir/./file` are one key. Literal `*`, `?` and bracket characters stay unchanged; files in the working tree never expand or otherwise rewrite a requested path. Case, symlinks and hard links are not resolved. A trailing `/` is kept and means a prefix: `dir/` covers every path under it, and is covered by any live lock under it, in both directions and inside the transaction (a directory token ref per level, compared-and-swapped by every claim, is what makes a stale scan fail rather than land); `dir` without the slash is the directory entry itself, a different key, and a prefix does not cover it. Before 0.7.0 the slash was stripped; that is the one normalisation rule that changed. **What a lock does not do.** It is a cooperative, time-bounded reservation. `with` claims once, runs, and releases; it does not renew, so the reservation can expire under a long command and another claimant may take the path. Give `--ttl` the command's worst case, or renew with `extend` from inside it. A `check` that says free is an observation, not an admission; the protected write needs a claim. diff --git a/bin/git-locks b/bin/git-locks index ff04446..276533c 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -310,7 +310,7 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on # removed; absolute paths and .. segments are refused; case, symlinks and hard # links are NOT resolved. A trailing / is kept: dir/ is a prefix that covers # every path under it; dir is the directory entry itself, a different key. - local p="$1" part parts=() IFS='/' prefix='' + local p="$1" part parts=() raw_parts=() IFS='/' prefix='' [[ "${p}" == */ ]] && prefix='/' [[ "${p}" == /* ]] && { path_error "${p}: paths are repo-relative" @@ -320,7 +320,8 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on path_error 'a path with a newline is not supported' return 2 } - for part in ${p}; do + read -r -a raw_parts <<<"${p}" + for part in "${raw_parts[@]}"; do [[ -z "${part}" || "${part}" == '.' ]] && continue [[ "${part}" == '..' ]] && { path_error "${p}: no .. components" diff --git a/lib/030-time-refs-records.sh b/lib/030-time-refs-records.sh index ad316dd..091378e 100644 --- a/lib/030-time-refs-records.sh +++ b/lib/030-time-refs-records.sh @@ -56,7 +56,7 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on # removed; absolute paths and .. segments are refused; case, symlinks and hard # links are NOT resolved. A trailing / is kept: dir/ is a prefix that covers # every path under it; dir is the directory entry itself, a different key. - local p="$1" part parts=() IFS='/' prefix='' + local p="$1" part parts=() raw_parts=() IFS='/' prefix='' [[ "${p}" == */ ]] && prefix='/' [[ "${p}" == /* ]] && { path_error "${p}: paths are repo-relative" @@ -66,7 +66,8 @@ normalize_path() { # -> prints the lexical form, or returns 2 with the reason on path_error 'a path with a newline is not supported' return 2 } - for part in ${p}; do + read -r -a raw_parts <<<"${p}" + for part in "${raw_parts[@]}"; do [[ -z "${part}" || "${part}" == '.' ]] && continue [[ "${part}" == '..' ]] && { path_error "${p}: no .. components"