From 1b175686ad749d30dab7ed3b79b1cff6413e578a Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 22 Sep 2026 08:43:13 -0700 Subject: [PATCH 1/3] fix: reject malformed authoritative reservation records --- CHANGELOG.md | 4 + README.md | 2 +- bin/git-locks | 252 +++++++++++++++++++++++++---------- lib/050-the-snapshot.sh | 22 ++- lib/055-record-validation.sh | 151 +++++++++++++++++++++ lib/175-doctor.sh | 78 ++--------- lib/990-main.sh | 1 + test/test.sh | 150 +++++++++++++++++++++ 8 files changed, 513 insertions(+), 147 deletions(-) create mode 100644 lib/055-record-validation.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0a60c..509248e 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 + +- Validate authoritative lock and semaphore records before normal reads or planning (#33). Corrupt records now produce a structured `store-read` error with exit 2 before any success output or mutation. Doctor shares the decoder and safely reports malformed numeric fields; generation tokens remain opaque. Stored decimal fields normalize leading zeros and reject values outside the nonnegative signed 64-bit range. + ## [0.7.0] - 2026-09-16 ### Added diff --git a/README.md b/README.md index 64cdd64..44be662 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat **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. -**What a failed read is.** An error, never a free path. If `for-each-ref` or `cat-file` fails, or an object does not parse, the command exits 2 with `{"event":"error","reason":"store-read"}` and reports nothing as free or held. +**What a failed read is.** An error, never a free path. If `for-each-ref` or `cat-file` fails, or an object does not parse, the command exits 2 with `{"event":"error","reason":"store-read"}` and reports nothing as free or held. Each refreshed snapshot validates authoritative job/path records, semaphore metadata, and semaphore slots before normal commands use them. Missing required fields, duplicate headers, invalid identities, and unsafe numeric fields are store-read errors. Decimal fields accept leading zeros on disk and normalize them before arithmetic or JSON output; values must fit a nonnegative signed 64-bit integer, and capacity must be positive. Directory and semaphore generation tokens remain opaque. `doctor` uses the same record validation to report findings instead of refusing a decodable snapshot. **What the invariants are, and how to see them hold.** `git locks doctor` reads one snapshot and checks it, writing nothing: every job record decodes and names its own job; every path a record lists has a path ref pointing at that record; every path ref points at a record some job ref points at, and that record lists the path; every child's parent exists, is live and has the same holder, and no parent chain cycles; every semaphore has its meta and gen refs, its records decode, and its live slots fit its capacity. Each broken invariant is one `finding` line as it is found, and the last line states the basis it was checked against, the refs and records of that one snapshot and the clock, so a clean report says what was clean. An unreadable store is an error, never healthy. Repair is not a mode of this command; when a finding needs a hand, the fix is a `release`, a `sweep`, or an explicit `update-ref` on the store by someone who has read the finding. diff --git a/bin/git-locks b/bin/git-locks index ff04446..2e9ee7c 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -400,12 +400,14 @@ g() { git --git-dir="${STORE}" "$@"; } # cached read taken under one for-each-ref, not a proof of a consistent cut; # every write below carries the expectations that make a stale read fail. -declare -A REF_OID=() # ref -> oid -declare -A BLOB=() # oid -> record text -declare -A R_PARSED=() # oid -> 1 once parsed -declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter -declare -A R_PATHS=() # oid -> the path lines, newline separated +declare -A REF_OID=() # ref -> oid +declare -A BLOB=() # oid -> record text +declare -A R_PARSED=() # oid -> 1 once parsed +declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter +declare -A R_PATHS=() # oid -> the path lines, newline separated +declare -A R_INVALID=() # oid -> structural parse error, retained for doctor SNAP_LOADED=0 +DIAGNOSTIC_READ=0 parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], once per shell; parameter expansion only, no fork [[ -n "${R_PARSED[$1]+x}" ]] && return 0 @@ -419,7 +421,13 @@ parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], on in_paths=1 elif [[ "${line}" == *': '* ]]; then key="${line%%: *}" - [[ -n "${R_FIELD["$1 ${key}"]+x}" ]] || R_FIELD["$1 ${key}"]="${line#*: }" + if [[ -n "${R_FIELD["$1 ${key}"]+x}" ]]; then + R_INVALID["$1"]="duplicate field ${key}" + else + R_FIELD["$1 ${key}"]="${line#*: }" + fi + else + R_INVALID["$1"]='invalid header line' fi done R_PARSED["$1"]=1 @@ -467,9 +475,11 @@ snapshot() { R_PARSED=() R_FIELD=() R_PATHS=() + R_INVALID=() for ref in "${!refs[@]}"; do REF_OID["${ref}"]="${refs[${ref}]}"; done for oid in "${!blobs[@]}"; do BLOB["${oid}"]="${blobs[${oid}]}"; done SNAP_LOADED=1 + ((DIAGNOSTIC_READ)) || validate_snapshot [[ -n "${GIT_LOCKS_TRACE:-}" ]] && printf 'snapshot %s\n' "${#refs[@]}" >>"${GIT_LOCKS_TRACE}" test_gate "${GIT_LOCKS_PAUSE_AFTER_READ:-}" # tests force an interleaving between a read and what follows it } @@ -568,6 +578,157 @@ write_blob() { # VAR CONTENT: write CONTENT as a blob, seed the snapshot with i BLOB["${written}"]="$2"$'\n' printf -v "$1" '%s' "${written}" } +# ---------------------------------------------------------------- record validation +# Validate the authority attached to a ref, not every blob: directory and +# semaphore generation tokens are opaque. Doctor uses the same decoder but +# reports findings instead of refusing the snapshot. + +record_uint() { # VAR text: decimal in the nonnegative signed 64-bit range + [[ "$2" =~ ^[0-9]+$ ]] || return 1 + local digits="$2" limit=9223372036854775808 + while [[ "${digits}" == 0?* ]]; do digits="${digits#0}"; done + ((${#digits} < 19)) || { + ((${#digits} == 19)) && [[ "x${digits}" < "x${limit}" ]] || return 1 + } + printf -v "$1" '%s' "${digits}" +} + +RECORD_ERROR='' +record_invalid() { + RECORD_ERROR="$1" + return 1 +} + +validate_record() { # oid lock|meta|slot: parsed values are safe before arithmetic + local oid="$1" role="$2" schema key value numbers paths p + parse_record "${oid}" + RECORD_ERROR="${R_INVALID[${oid}]:-}" + [[ -z "${RECORD_ERROR}" ]] || return 1 + case "${role}" in + lock) + schema="${SCHEMA}" + numbers='claimed expires family' + ;; + meta) + schema="${SEM_SCHEMA}" + numbers='capacity created' + ;; + slot) + schema="${SLOT_SCHEMA}" + numbers='claimed expires' + ;; + *) + record_invalid 'unknown record role' + return 1 + ;; + esac + [[ "${R_FIELD["${oid} schema"]:-}" == "${schema}" ]] || { + record_invalid "expected schema ${schema}" + return 1 + } + for key in ${numbers}; do + value="${R_FIELD["${oid} ${key}"]:-}" + # Older lock records may omit family; no membership changes means zero. + [[ "${role}" == lock && "${key}" == family && -z "${R_FIELD["${oid} family"]+x}" ]] && value=0 + record_uint value "${value}" || { + record_invalid "invalid ${key}" + return 1 + } + [[ "${key}" != capacity || "${value}" != 0 ]] || { + record_invalid 'capacity must be positive' + return 1 + } + R_FIELD["${oid} ${key}"]="${value}" + done + if [[ "${role}" != lock ]]; then + valid_job "${R_FIELD["${oid} semaphore"]:-}" || { + record_invalid 'invalid semaphore' + return 1 + } + fi + if [[ "${role}" != meta ]]; then + valid_job "${R_FIELD["${oid} job"]:-}" || { + record_invalid 'invalid job' + return 1 + } + valid_holder "${R_FIELD["${oid} holder"]:-}" || { + record_invalid 'invalid holder' + return 1 + } + valid_holder "${R_FIELD["${oid} acquisition"]:-}" || { + record_invalid 'invalid acquisition' + return 1 + } + fi + if [[ "${role}" == lock ]]; then + value="${R_FIELD["${oid} parent"]:-}" + [[ -z "${value}" ]] || valid_job "${value}" || { + record_invalid 'invalid parent' + return 1 + } + valid_note "${R_FIELD["${oid} note"]:-}" || { + record_invalid 'invalid note' + return 1 + } + paths="${R_PATHS[${oid}]:-}" + [[ -n "${paths}" ]] || { + record_invalid 'no paths' + return 1 + } + while IFS= read -r p; do + # Stored paths must already be lexical keys; do not glob or normalize + # them against the reader's working directory. + case "/${p}/" in + //* | *//*/* | */./* | */../*) + record_invalid 'invalid stored path' + return 1 + ;; + *) ;; + esac + [[ -n "${p}" ]] || { + record_invalid 'empty stored path' + return 1 + } + done <<<"${paths}" + elif [[ -n "${R_PATHS[${oid}]:-}" ]]; then + record_invalid 'unexpected paths' + return 1 + fi + return 0 +} + +validate_snapshot() { + local ref oid role rest name job + local -A checked=() + for ref in "${!REF_OID[@]}"; do + oid="${REF_OID[${ref}]}" + case "${ref}" in + "${NS}"/jobs/* | "${NS}"/paths/*) role=lock ;; + "${NS}"/sem/*/meta) role=meta ;; + "${NS}"/sem/*/slots/*) role=slot ;; + *) continue ;; + esac + if [[ -z "${checked["${oid} ${role}"]+x}" ]]; then + validate_record "${oid}" "${role}" || store_error "${ref}: record ${oid}: ${RECORD_ERROR}" + checked["${oid} ${role}"]=1 + fi + case "${ref}" in + "${NS}"/jobs/*) + [[ "${R_FIELD["${oid} job"]}" == "${ref#"${NS}"/jobs/}" ]] || store_error "${ref}: record names a different job" + ;; + "${NS}"/sem/*) + rest="${ref#"${NS}"/sem/}" + name="${rest%%/*}" + [[ "${R_FIELD["${oid} semaphore"]}" == "${name}" ]] || store_error "${ref}: record names a different semaphore" + if [[ "${role}" == slot ]]; then + job="${rest#*/slots/}" + [[ "${R_FIELD["${oid} job"]}" == "${job}" ]] || store_error "${ref}: record names a different job" + fi + ;; + *) ;; + esac + done +} # ---------------------------------------------------------------- the transition plan # # One final transition per ref. Every writer says what it expects a ref to hold @@ -2111,8 +2272,6 @@ finding() { # check subject detail -> one finding line on stdout DOC_FINDINGS=$((DOC_FINDINGS + 1)) } -is_int() { [[ "$1" =~ ^[0-9]+$ ]]; } - doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: each path becomes a file, hash-object hashes them all local dir p i=0 files=() todo=() out h rc for p in "$@"; do @@ -2141,53 +2300,18 @@ doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: ((i == ${#todo[@]})) || store_error "hash-object returned ${i} hashes for ${#todo[@]} paths" } -doctor_lock_record() { # subject oid -> 0 when the record decodes as a lock record, else findings and 1 - local schema job holder claimed expires acq paths bad=0 - field_v schema "$2" schema - if [[ "${schema}" != "${SCHEMA}" ]]; then - finding record-decodes "$1" "record ${2} has schema '${schema}', not ${SCHEMA}" - return 1 - fi - field_v job "$2" job - valid_job "${job}" || { - finding record-decodes "$1" "record ${2} has no valid job id" - bad=1 - } - field_v holder "$2" holder - [[ -n "${holder}" ]] || { - finding record-decodes "$1" "record ${2} has no holder" - bad=1 - } - field_v claimed "$2" claimed - is_int "${claimed}" || { - finding record-decodes "$1" "record ${2} has no numeric claimed" - bad=1 - } - field_v expires "$2" expires - is_int "${expires}" || { - finding record-decodes "$1" "record ${2} has no numeric expires" - bad=1 - } - field_v acq "$2" acquisition - [[ -n "${acq}" ]] || { - finding record-decodes "$1" "record ${2} has no acquisition id" - bad=1 - } - record_paths_v paths "$2" - [[ -n "${paths}" ]] || { - finding record-decodes "$1" "record ${2} lists no paths" - bad=1 - } - return "${bad}" +doctor_lock_record() { # subject oid -> decoded lock or a diagnostic finding + validate_record "$2" lock && return 0 + finding record-decodes "$1" "record ${2}: ${RECORD_ERROR}" + return 1 } doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a slot of that semaphore for that job - local schema v bad=0 - field_v schema "$2" schema - if [[ "${schema}" != "${SLOT_SCHEMA}" ]]; then - finding sem-record "$1" "slot record ${2} has schema '${schema}', not ${SLOT_SCHEMA}" + local v bad=0 + validate_record "$2" slot || { + finding sem-record "$1" "slot record ${2}: ${RECORD_ERROR}" return 1 - fi + } field_v v "$2" semaphore [[ "${v}" == "$3" ]] || { finding sem-record "$1" "slot record ${2} names semaphore '${v}'" @@ -2198,21 +2322,6 @@ doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a finding sem-record "$1" "slot record ${2} names job '${v}'" bad=1 } - field_v v "$2" holder - [[ -n "${v}" ]] || { - finding sem-record "$1" "slot record ${2} has no holder" - bad=1 - } - field_v v "$2" claimed - is_int "${v}" || { - finding sem-record "$1" "slot record ${2} has no numeric claimed" - bad=1 - } - field_v v "$2" expires - is_int "${v}" || { - finding sem-record "$1" "slot record ${2} has no numeric expires" - bad=1 - } return "${bad}" } @@ -2318,6 +2427,7 @@ cmd_doctor() { finding parent-missing "${job}" "names parent '${parent}', which has no job ref: a child cannot outlive its parent" continue fi + [[ -n "${JOB_OK[${parent}]+x}" ]] || continue # its decoder already reported the corrupt parent field_v pexp "${JOB_OID[${parent}]}" expires ((${pexp:-0} > at)) || finding parent-expired "${job}" "parent '${parent}' expired at ${pexp:-0}; sweep removes both" field_v holder "${oid}" holder @@ -2342,16 +2452,11 @@ cmd_doctor() { finding sem-meta "${name}" 'no meta ref: the semaphore has no capacity' cap='' else - field_v rest "${SEM_META[${name}]}" schema - if [[ "${rest}" != "${SEM_SCHEMA}" ]]; then - finding sem-record "${name}" "meta record ${SEM_META[${name}]} has schema '${rest}', not ${SEM_SCHEMA}" + if ! validate_record "${SEM_META[${name}]}" meta; then + finding sem-record "${name}" "meta record ${SEM_META[${name}]}: ${RECORD_ERROR}" cap='' else field_v cap "${SEM_META[${name}]}" capacity - if ! is_int "${cap}" || ((cap < 1)); then - finding sem-record "${name}" "meta record ${SEM_META[${name}]} has capacity '${cap}'" - cap='' - fi field_v rest "${SEM_META[${name}]}" semaphore [[ "${rest}" == "${name}" ]] || finding sem-record "${name}" "meta record names semaphore '${rest}'" fi @@ -2427,6 +2532,7 @@ main() { exit 0 fi done + [[ "${cmd}" == doctor ]] && DIAGNOSTIC_READ=1 resolve_store case "${cmd}" in store) ;; *) ensure_snapshot ;; esac # once, in this shell: subshells inherit it instead of re-reading "cmd_${cmd}" "$@" diff --git a/lib/050-the-snapshot.sh b/lib/050-the-snapshot.sh index 4fcdc4f..44c2692 100644 --- a/lib/050-the-snapshot.sh +++ b/lib/050-the-snapshot.sh @@ -7,12 +7,14 @@ # cached read taken under one for-each-ref, not a proof of a consistent cut; # every write below carries the expectations that make a stale read fail. -declare -A REF_OID=() # ref -> oid -declare -A BLOB=() # oid -> record text -declare -A R_PARSED=() # oid -> 1 once parsed -declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter -declare -A R_PATHS=() # oid -> the path lines, newline separated +declare -A REF_OID=() # ref -> oid +declare -A BLOB=() # oid -> record text +declare -A R_PARSED=() # oid -> 1 once parsed +declare -A R_FIELD=() # "oid key" -> value, for the header lines before paths: (first occurrence wins); values are stored whole, so no byte in one can read as a delimiter +declare -A R_PATHS=() # oid -> the path lines, newline separated +declare -A R_INVALID=() # oid -> structural parse error, retained for doctor SNAP_LOADED=0 +DIAGNOSTIC_READ=0 parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], once per shell; parameter expansion only, no fork [[ -n "${R_PARSED[$1]+x}" ]] && return 0 @@ -26,7 +28,13 @@ parse_record() { # oid -> R_FIELD["oid key"] and R_PATHS[oid] from BLOB[oid], on in_paths=1 elif [[ "${line}" == *': '* ]]; then key="${line%%: *}" - [[ -n "${R_FIELD["$1 ${key}"]+x}" ]] || R_FIELD["$1 ${key}"]="${line#*: }" + if [[ -n "${R_FIELD["$1 ${key}"]+x}" ]]; then + R_INVALID["$1"]="duplicate field ${key}" + else + R_FIELD["$1 ${key}"]="${line#*: }" + fi + else + R_INVALID["$1"]='invalid header line' fi done R_PARSED["$1"]=1 @@ -74,9 +82,11 @@ snapshot() { R_PARSED=() R_FIELD=() R_PATHS=() + R_INVALID=() for ref in "${!refs[@]}"; do REF_OID["${ref}"]="${refs[${ref}]}"; done for oid in "${!blobs[@]}"; do BLOB["${oid}"]="${blobs[${oid}]}"; done SNAP_LOADED=1 + ((DIAGNOSTIC_READ)) || validate_snapshot [[ -n "${GIT_LOCKS_TRACE:-}" ]] && printf 'snapshot %s\n' "${#refs[@]}" >>"${GIT_LOCKS_TRACE}" test_gate "${GIT_LOCKS_PAUSE_AFTER_READ:-}" # tests force an interleaving between a read and what follows it } diff --git a/lib/055-record-validation.sh b/lib/055-record-validation.sh new file mode 100644 index 0000000..868d3d8 --- /dev/null +++ b/lib/055-record-validation.sh @@ -0,0 +1,151 @@ +# ---------------------------------------------------------------- record validation +# Validate the authority attached to a ref, not every blob: directory and +# semaphore generation tokens are opaque. Doctor uses the same decoder but +# reports findings instead of refusing the snapshot. + +record_uint() { # VAR text: decimal in the nonnegative signed 64-bit range + [[ "$2" =~ ^[0-9]+$ ]] || return 1 + local digits="$2" limit=9223372036854775808 + while [[ "${digits}" == 0?* ]]; do digits="${digits#0}"; done + ((${#digits} < 19)) || { + ((${#digits} == 19)) && [[ "x${digits}" < "x${limit}" ]] || return 1 + } + printf -v "$1" '%s' "${digits}" +} + +RECORD_ERROR='' +record_invalid() { + RECORD_ERROR="$1" + return 1 +} + +validate_record() { # oid lock|meta|slot: parsed values are safe before arithmetic + local oid="$1" role="$2" schema key value numbers paths p + parse_record "${oid}" + RECORD_ERROR="${R_INVALID[${oid}]:-}" + [[ -z "${RECORD_ERROR}" ]] || return 1 + case "${role}" in + lock) + schema="${SCHEMA}" + numbers='claimed expires family' + ;; + meta) + schema="${SEM_SCHEMA}" + numbers='capacity created' + ;; + slot) + schema="${SLOT_SCHEMA}" + numbers='claimed expires' + ;; + *) + record_invalid 'unknown record role' + return 1 + ;; + esac + [[ "${R_FIELD["${oid} schema"]:-}" == "${schema}" ]] || { + record_invalid "expected schema ${schema}" + return 1 + } + for key in ${numbers}; do + value="${R_FIELD["${oid} ${key}"]:-}" + # Older lock records may omit family; no membership changes means zero. + [[ "${role}" == lock && "${key}" == family && -z "${R_FIELD["${oid} family"]+x}" ]] && value=0 + record_uint value "${value}" || { + record_invalid "invalid ${key}" + return 1 + } + [[ "${key}" != capacity || "${value}" != 0 ]] || { + record_invalid 'capacity must be positive' + return 1 + } + R_FIELD["${oid} ${key}"]="${value}" + done + if [[ "${role}" != lock ]]; then + valid_job "${R_FIELD["${oid} semaphore"]:-}" || { + record_invalid 'invalid semaphore' + return 1 + } + fi + if [[ "${role}" != meta ]]; then + valid_job "${R_FIELD["${oid} job"]:-}" || { + record_invalid 'invalid job' + return 1 + } + valid_holder "${R_FIELD["${oid} holder"]:-}" || { + record_invalid 'invalid holder' + return 1 + } + valid_holder "${R_FIELD["${oid} acquisition"]:-}" || { + record_invalid 'invalid acquisition' + return 1 + } + fi + if [[ "${role}" == lock ]]; then + value="${R_FIELD["${oid} parent"]:-}" + [[ -z "${value}" ]] || valid_job "${value}" || { + record_invalid 'invalid parent' + return 1 + } + valid_note "${R_FIELD["${oid} note"]:-}" || { + record_invalid 'invalid note' + return 1 + } + paths="${R_PATHS[${oid}]:-}" + [[ -n "${paths}" ]] || { + record_invalid 'no paths' + return 1 + } + while IFS= read -r p; do + # Stored paths must already be lexical keys; do not glob or normalize + # them against the reader's working directory. + case "/${p}/" in + //* | *//*/* | */./* | */../*) + record_invalid 'invalid stored path' + return 1 + ;; + *) ;; + esac + [[ -n "${p}" ]] || { + record_invalid 'empty stored path' + return 1 + } + done <<<"${paths}" + elif [[ -n "${R_PATHS[${oid}]:-}" ]]; then + record_invalid 'unexpected paths' + return 1 + fi + return 0 +} + +validate_snapshot() { + local ref oid role rest name job + local -A checked=() + for ref in "${!REF_OID[@]}"; do + oid="${REF_OID[${ref}]}" + case "${ref}" in + "${NS}"/jobs/* | "${NS}"/paths/*) role=lock ;; + "${NS}"/sem/*/meta) role=meta ;; + "${NS}"/sem/*/slots/*) role=slot ;; + *) continue ;; + esac + if [[ -z "${checked["${oid} ${role}"]+x}" ]]; then + validate_record "${oid}" "${role}" || store_error "${ref}: record ${oid}: ${RECORD_ERROR}" + checked["${oid} ${role}"]=1 + fi + case "${ref}" in + "${NS}"/jobs/*) + [[ "${R_FIELD["${oid} job"]}" == "${ref#"${NS}"/jobs/}" ]] || store_error "${ref}: record names a different job" + ;; + "${NS}"/sem/*) + rest="${ref#"${NS}"/sem/}" + name="${rest%%/*}" + [[ "${R_FIELD["${oid} semaphore"]}" == "${name}" ]] || store_error "${ref}: record names a different semaphore" + if [[ "${role}" == slot ]]; then + job="${rest#*/slots/}" + [[ "${R_FIELD["${oid} job"]}" == "${job}" ]] || store_error "${ref}: record names a different job" + fi + ;; + *) ;; + esac + done +} diff --git a/lib/175-doctor.sh b/lib/175-doctor.sh index 54b8dac..c6bb402 100644 --- a/lib/175-doctor.sh +++ b/lib/175-doctor.sh @@ -18,8 +18,6 @@ finding() { # check subject detail -> one finding line on stdout DOC_FINDINGS=$((DOC_FINDINGS + 1)) } -is_int() { [[ "$1" =~ ^[0-9]+$ ]]; } - doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: each path becomes a file, hash-object hashes them all local dir p i=0 files=() todo=() out h rc for p in "$@"; do @@ -48,53 +46,18 @@ doctor_hash_paths() { # path... -> PATH_HASH for every path, in one git process: ((i == ${#todo[@]})) || store_error "hash-object returned ${i} hashes for ${#todo[@]} paths" } -doctor_lock_record() { # subject oid -> 0 when the record decodes as a lock record, else findings and 1 - local schema job holder claimed expires acq paths bad=0 - field_v schema "$2" schema - if [[ "${schema}" != "${SCHEMA}" ]]; then - finding record-decodes "$1" "record ${2} has schema '${schema}', not ${SCHEMA}" - return 1 - fi - field_v job "$2" job - valid_job "${job}" || { - finding record-decodes "$1" "record ${2} has no valid job id" - bad=1 - } - field_v holder "$2" holder - [[ -n "${holder}" ]] || { - finding record-decodes "$1" "record ${2} has no holder" - bad=1 - } - field_v claimed "$2" claimed - is_int "${claimed}" || { - finding record-decodes "$1" "record ${2} has no numeric claimed" - bad=1 - } - field_v expires "$2" expires - is_int "${expires}" || { - finding record-decodes "$1" "record ${2} has no numeric expires" - bad=1 - } - field_v acq "$2" acquisition - [[ -n "${acq}" ]] || { - finding record-decodes "$1" "record ${2} has no acquisition id" - bad=1 - } - record_paths_v paths "$2" - [[ -n "${paths}" ]] || { - finding record-decodes "$1" "record ${2} lists no paths" - bad=1 - } - return "${bad}" +doctor_lock_record() { # subject oid -> decoded lock or a diagnostic finding + validate_record "$2" lock && return 0 + finding record-decodes "$1" "record ${2}: ${RECORD_ERROR}" + return 1 } doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a slot of that semaphore for that job - local schema v bad=0 - field_v schema "$2" schema - if [[ "${schema}" != "${SLOT_SCHEMA}" ]]; then - finding sem-record "$1" "slot record ${2} has schema '${schema}', not ${SLOT_SCHEMA}" + local v bad=0 + validate_record "$2" slot || { + finding sem-record "$1" "slot record ${2}: ${RECORD_ERROR}" return 1 - fi + } field_v v "$2" semaphore [[ "${v}" == "$3" ]] || { finding sem-record "$1" "slot record ${2} names semaphore '${v}'" @@ -105,21 +68,6 @@ doctor_slot_record() { # subject oid name job -> 0 when the record decodes as a finding sem-record "$1" "slot record ${2} names job '${v}'" bad=1 } - field_v v "$2" holder - [[ -n "${v}" ]] || { - finding sem-record "$1" "slot record ${2} has no holder" - bad=1 - } - field_v v "$2" claimed - is_int "${v}" || { - finding sem-record "$1" "slot record ${2} has no numeric claimed" - bad=1 - } - field_v v "$2" expires - is_int "${v}" || { - finding sem-record "$1" "slot record ${2} has no numeric expires" - bad=1 - } return "${bad}" } @@ -225,6 +173,7 @@ cmd_doctor() { finding parent-missing "${job}" "names parent '${parent}', which has no job ref: a child cannot outlive its parent" continue fi + [[ -n "${JOB_OK[${parent}]+x}" ]] || continue # its decoder already reported the corrupt parent field_v pexp "${JOB_OID[${parent}]}" expires ((${pexp:-0} > at)) || finding parent-expired "${job}" "parent '${parent}' expired at ${pexp:-0}; sweep removes both" field_v holder "${oid}" holder @@ -249,16 +198,11 @@ cmd_doctor() { finding sem-meta "${name}" 'no meta ref: the semaphore has no capacity' cap='' else - field_v rest "${SEM_META[${name}]}" schema - if [[ "${rest}" != "${SEM_SCHEMA}" ]]; then - finding sem-record "${name}" "meta record ${SEM_META[${name}]} has schema '${rest}', not ${SEM_SCHEMA}" + if ! validate_record "${SEM_META[${name}]}" meta; then + finding sem-record "${name}" "meta record ${SEM_META[${name}]}: ${RECORD_ERROR}" cap='' else field_v cap "${SEM_META[${name}]}" capacity - if ! is_int "${cap}" || ((cap < 1)); then - finding sem-record "${name}" "meta record ${SEM_META[${name}]} has capacity '${cap}'" - cap='' - fi field_v rest "${SEM_META[${name}]}" semaphore [[ "${rest}" == "${name}" ]] || finding sem-record "${name}" "meta record names semaphore '${rest}'" fi diff --git a/lib/990-main.sh b/lib/990-main.sh index beeecf3..013765e 100644 --- a/lib/990-main.sh +++ b/lib/990-main.sh @@ -36,6 +36,7 @@ main() { exit 0 fi done + [[ "${cmd}" == doctor ]] && DIAGNOSTIC_READ=1 resolve_store case "${cmd}" in store) ;; *) ensure_snapshot ;; esac # once, in this shell: subshells inherit it instead of re-reading "cmd_${cmd}" "$@" diff --git a/test/test.sh b/test/test.sh index 22657a5..f43b94e 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1571,6 +1571,156 @@ check "sibling prefixes in one batch are not an overlap" "$?" "0" lines n "${out}" check "and both records claimed" "${n}" "2" +# ---------------------------------------------------------------- malformed authoritative records fail before decisions +# Removing snapshot validation must turn these structured errors into an unsafe +# observation or mutation. Literal records are independent of the CLI writer. +R="$(mkrepo)" +cd "${R}" || exit 2 +store="${R}/records.git" +export GIT_LOCKS_STORE="${store}" +git-locks claim --job held --holder alice x.md >/dev/null +path_oid="$(printf x.md | git --git-dir="${store}" hash-object --stdin)" +lock_record=$'schema: git-locks/1\njob: held\nholder: alice\nclaimed: 1000000\nexpires: 1014400\nfamily: 0\nacquisition: original\npaths:\nx.md' +meta_record=$'schema: git-locks-sem/1\nsemaphore: gpu\ncapacity: 2\ncreated: 1000000' +slot_record=$'schema: git-locks-slot/1\nsemaphore: gpu\njob: held\nholder: alice\nclaimed: 1000000\nexpires: 1014400\nacquisition: original' +bad_oid="$(printf 'not a record\n' | git --git-dir="${store}" hash-object -w --stdin)" +git --git-dir="${store}" update-ref refs/locks/jobs/held "${bad_oid}" +git --git-dir="${store}" update-ref "refs/locks/paths/${path_oid}" "${bad_oid}" +before="$(git --git-dir="${store}" for-each-ref --format='%(refname) %(objectname)')" +commands=(check list show ttl claim batch release extend sweep with sem) +for verb in "${commands[@]}"; do + case "${verb}" in + check) args=(check x.md) ;; + list | sweep) args=("${verb}") ;; + show | ttl | release) args=("${verb}" --job held) ;; + claim) args=(claim --job taker --holder bob x.md) ;; + batch) args=(batch) ;; + extend) args=(extend --job held --ttl 10) ;; + with) args=(with --job taker --holder bob x.md -- touch "${R}/ran") ;; + sem) args=(sem create gpu --capacity 1) ;; + *) exit 2 ;; + esac + out="$(printf 'job: taker\nholder: bob\npaths:\nx.md\n' | git-locks "${args[@]}" 2>"${ERR_PRE}")" + check "${verb} rejects malformed authority with exit 2" "$?" 2 + check "${verb} emits no success or path output" "${out}" '' + err="$(cat "${ERR_PRE}")" + jfields "${verb} reports store-read" "${err}" 'event="error"' 'reason="store-read"' + valid "${verb} malformed-record error" "${err}" + after="$(git --git-dir="${store}" for-each-ref --format='%(refname) %(objectname)')" + check "${verb} leaves authoritative refs unchanged" "${after}" "${before}" +done +check 'with does not execute after corrupt admission' "$([[ -e "${R}/ran" ]] && printf ran || true)" '' +out="$(git-locks doctor 2>&1)" +check 'doctor still diagnoses an undecodable lock' "$?" 1 +contains 'doctor preserves record-decodes finding' "${out}" '"check":"record-decodes"' + +# A fixed adversarial corpus checks missing, duplicate, unsafe arithmetic, and +# role-confused fields. Case order is deterministic, seed 33, no random sleeps. +corruptions=('' '-1' '1+1' '08x' '9223372036854775808' '18446744073709551616' 'a[0]' '1.5') +case_count=0 +fuzz_state=33 +for role in lock meta slot; do + case "${role}" in + lock) + template="${lock_record}" + numeric=(claimed expires family) + required=(schema job holder claimed expires acquisition) + target=refs/locks/jobs/held + ;; + meta) + template="${meta_record}" + numeric=(capacity created) + required=(schema semaphore capacity created) + target=refs/locks/sem/gpu/meta + ;; + slot) + template="${slot_record}" + numeric=(claimed expires) + required=(schema semaphore job holder claimed expires acquisition) + target=refs/locks/sem/gpu/slots/held + ;; + *) exit 2 ;; + esac + good_oid="$(printf '%s\n' "${lock_record}" | git --git-dir="${store}" hash-object -w --stdin)" + git --git-dir="${store}" update-ref refs/locks/jobs/held "${good_oid}" + git --git-dir="${store}" update-ref "refs/locks/paths/${path_oid}" "${good_oid}" + cases=('not a record' "${template/schema: /schema: wrong-}") + if [[ "${role}" == lock ]]; then + cases+=("${template/$'paths:\nx.md'/paths:}" "${template/x.md//absolute}" "${template/x.md/../escape}") + fi + for key in "${required[@]}"; do + value="${template#*"${key}: "}" + value="${value%%$'\n'*}" + cases+=("${template/"${key}: ${value}"/"${key}: "}") + cases+=("${template/"${key}: ${value}"/}") + cases+=("${key}: other"$'\n'"${template}") + done + for key in "${numeric[@]}"; do + value="${template#*"${key}: "}" + value="${value%%$'\n'*}" + for corrupt in "${corruptions[@]}"; do + cases+=("${template/"${key}: ${value}"/"${key}: ${corrupt}"}") + done + done + # Seeded mutations produce digit-prefixed junk, which must never reach + # shell arithmetic. The expected outcome is independent of parsed values. + for ((sample = 0; sample < 12; sample++)); do + fuzz_state=$(((fuzz_state * 1103515245 + 12345) % 2147483648)) + key="${numeric[$((fuzz_state % ${#numeric[@]}))]}" + value="${template#*"${key}: "}" + value="${value%%$'\n'*}" + cases+=("${template/"${key}: ${value}"/"${key}: ${fuzz_state}x"}") + done + for record in "${cases[@]}"; do + bad_oid="$(printf '%s\n' "${record}" | git --git-dir="${store}" hash-object -w --stdin)" + git --git-dir="${store}" update-ref "${target}" "${bad_oid}" + out="$(git-locks check x.md 2>"${ERR_PRE}")" + rc=$? + err="$(cat "${ERR_PRE}")" + case_count=$((case_count + 1)) + diagnostic="$(git-locks doctor 2>"${ERR_PRE}")" + doctor_rc=$? + if [[ "${role}" == lock ]]; then finding_kind='record-decodes'; else finding_kind='sem-record'; fi + contains "doctor identifies corrupt ${role} case ${case_count}" "${diagnostic}" "\"check\":\"${finding_kind}\"" + doctor_err="$(cat "${ERR_PRE}")" + check "doctor diagnoses ${role} case ${case_count} safely" "${doctor_rc}:${doctor_err}" '1:' + check "corrupt ${role} case ${case_count} fails closed" "${rc}:${out}:$([[ "${err}" == *'"reason":"store-read"'* ]] && printf store-read || true)" '2::store-read' + done + git --git-dir="${store}" update-ref -d "${target}" +done +printf ' info record corruption corpus: %s cases, fixed seed 33\n' "${case_count}" +# Legacy decimal spellings stay readable and serialize as decimal JSON. +legacy="${lock_record/claimed: 1000000/claimed: 01000000}" +legacy="${legacy/expires: 1014400/expires: 01014400}" +legacy="${legacy/family: 0/family: 000}" +good_oid="$(printf '%s\n' "${legacy}" | git --git-dir="${store}" hash-object -w --stdin)" +git --git-dir="${store}" update-ref refs/locks/jobs/held "${good_oid}" +git --git-dir="${store}" update-ref "refs/locks/paths/${path_oid}" "${good_oid}" +out="$(git-locks show --job held 2>&1)" +check 'legacy leading-zero timestamps remain readable' "$?" 0 +jfields 'legacy timestamps emit decimal JSON numbers' "${out}" 'claimed=1000000' 'expires=1014400' 'remaining=14400' +valid 'legacy timestamp output' "${out}" +for capacity in 02 9223372036854775807; do + legacy="${meta_record/capacity: 2/capacity: ${capacity}}" + good_oid="$(printf '%s\n' "${legacy}" | git --git-dir="${store}" hash-object -w --stdin)" + git --git-dir="${store}" update-ref refs/locks/sem/gpu/meta "${good_oid}" + out="$(git-locks sem show gpu 2>&1)" + check "stored capacity ${capacity} remains readable" "$?" 0 + valid "stored capacity ${capacity} output" "${out}" +done +git --git-dir="${store}" update-ref -d refs/locks/sem/gpu/meta + +# Directory and semaphore generations can contain arbitrary text, even when +# all locks have gone. They must not be treated as active lock records. +git --git-dir="${store}" update-ref -d refs/locks/jobs/held +git --git-dir="${store}" update-ref -d "refs/locks/paths/${path_oid}" +git --git-dir="${store}" update-ref refs/locks/dirs/opaque "${bad_oid}" +git --git-dir="${store}" update-ref refs/locks/sem/gpu/gen "${bad_oid}" +out="$(git-locks check x.md 2>&1)" +check 'opaque generation records do not block a free path' "$?" 0 +jfields 'free path stays free beside opaque tokens' "${out}" 'state="free"' +unset GIT_LOCKS_STORE + printf '\n%d passed, %d failed\n' "${PASS}" "${FAIL}" if ((FAIL > 0)); then printf 'failed: %s\n' "${FAILED[@]}" From ef47b7bbb2b51c2b0024a138fbf262801358d9aa Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 22 Sep 2026 08:47:55 -0700 Subject: [PATCH 2/3] fix: refuse exhausted family generation before admission --- CHANGELOG.md | 2 +- README.md | 2 +- bin/git-locks | 1 + lib/080-families.sh | 1 + test/test.sh | 15 +++++++++++++++ 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 509248e..4d6c72c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project are recorded here. The format follows Keep a ### Fixed -- Validate authoritative lock and semaphore records before normal reads or planning (#33). Corrupt records now produce a structured `store-read` error with exit 2 before any success output or mutation. Doctor shares the decoder and safely reports malformed numeric fields; generation tokens remain opaque. Stored decimal fields normalize leading zeros and reject values outside the nonnegative signed 64-bit range. +- Validate authoritative lock and semaphore records before normal reads or planning (#33). Corrupt records now produce a structured `store-read` error with exit 2 before any success output or mutation. Doctor shares the decoder and safely reports malformed numeric fields; generation tokens remain opaque. Stored decimal fields normalize leading zeros and reject values outside the nonnegative signed 64-bit range. Child admission refuses a parent whose family generation cannot advance without overflow. ## [0.7.0] - 2026-09-16 diff --git a/README.md b/README.md index 44be662..2dc0c3a 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ An outside review of 0.2.1 found the guarantees running ahead of the implementat **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. -**What a failed read is.** An error, never a free path. If `for-each-ref` or `cat-file` fails, or an object does not parse, the command exits 2 with `{"event":"error","reason":"store-read"}` and reports nothing as free or held. Each refreshed snapshot validates authoritative job/path records, semaphore metadata, and semaphore slots before normal commands use them. Missing required fields, duplicate headers, invalid identities, and unsafe numeric fields are store-read errors. Decimal fields accept leading zeros on disk and normalize them before arithmetic or JSON output; values must fit a nonnegative signed 64-bit integer, and capacity must be positive. Directory and semaphore generation tokens remain opaque. `doctor` uses the same record validation to report findings instead of refusing a decodable snapshot. +**What a failed read is.** An error, never a free path. If `for-each-ref` or `cat-file` fails, or an object does not parse, the command exits 2 with `{"event":"error","reason":"store-read"}` and reports nothing as free or held. Each refreshed snapshot validates authoritative job/path records, semaphore metadata, and semaphore slots before normal commands use them. Missing required fields, duplicate headers, invalid identities, and unsafe numeric fields are store-read errors. Decimal fields accept leading zeros on disk and normalize them before arithmetic or JSON output; values must fit a nonnegative signed 64-bit integer, and capacity must be positive. A parent at the maximum family generation can still be read or released; child admission fails before its generation would overflow. Directory and semaphore generation tokens remain opaque. `doctor` uses the same record validation to report findings instead of refusing a decodable snapshot. **What the invariants are, and how to see them hold.** `git locks doctor` reads one snapshot and checks it, writing nothing: every job record decodes and names its own job; every path a record lists has a path ref pointing at that record; every path ref points at a record some job ref points at, and that record lists the path; every child's parent exists, is live and has the same holder, and no parent chain cycles; every semaphore has its meta and gen refs, its records decode, and its live slots fit its capacity. Each broken invariant is one `finding` line as it is found, and the last line states the basis it was checked against, the refs and records of that one snapshot and the clock, so a clean report says what was clean. An unreadable store is an error, never healthy. Repair is not a mode of this command; when a finding needs a hand, the fix is a `release`, a `sweep`, or an explicit `update-ref` on the store by someone who has read the finding. diff --git a/bin/git-locks b/bin/git-locks index 2e9ee7c..b211e29 100755 --- a/bin/git-locks +++ b/bin/git-locks @@ -943,6 +943,7 @@ bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite wit local pjob="$1" poid="$2" fam newfam claimed expires holder parent paths record newoid p ref have acq note fam="$(field "${poid}" family)" acq="$(field "${poid}" acquisition)" + [[ "${fam}" != 9223372036854775807 ]] || store_error "parent ${pjob}: family generation is exhausted" newfam=$((${fam:-0} + 1)) holder="$(field "${poid}" holder)" claimed="$(field "${poid}" claimed)" diff --git a/lib/080-families.sh b/lib/080-families.sh index ae391db..1041125 100644 --- a/lib/080-families.sh +++ b/lib/080-families.sh @@ -100,6 +100,7 @@ bump_parent() { # parent-job parent-oid -> plans the parent's blob rewrite wit local pjob="$1" poid="$2" fam newfam claimed expires holder parent paths record newoid p ref have acq note fam="$(field "${poid}" family)" acq="$(field "${poid}" acquisition)" + [[ "${fam}" != 9223372036854775807 ]] || store_error "parent ${pjob}: family generation is exhausted" newfam=$((${fam:-0} + 1)) holder="$(field "${poid}" holder)" claimed="$(field "${poid}" claimed)" diff --git a/test/test.sh b/test/test.sh index f43b94e..04eb4aa 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1710,6 +1710,21 @@ for capacity in 02 9223372036854775807; do done git --git-dir="${store}" update-ref -d refs/locks/sem/gpu/meta +# A valid maximum generation can be read, but advancing it must fail before +# the child or a wrapped negative generation reaches any authoritative ref. +record="${lock_record/family: 0/family: 9223372036854775807}" +good_oid="$(printf '%s\n' "${record}" | git --git-dir="${store}" hash-object -w --stdin)" +git --git-dir="${store}" update-ref refs/locks/jobs/held "${good_oid}" +git --git-dir="${store}" update-ref "refs/locks/paths/${path_oid}" "${good_oid}" +before="$(git --git-dir="${store}" for-each-ref --format='%(refname) %(objectname)')" +out="$(git-locks claim --job child --holder alice --parent held child.md 2>"${ERR_PRE}")" +check 'child admission refuses an exhausted family generation' "$?" 2 +check 'exhausted family admission prints no success' "${out}" '' +err="$(cat "${ERR_PRE}")" +jfields 'exhausted family admission is a store-read error' "${err}" 'event="error"' 'reason="store-read"' +after="$(git --git-dir="${store}" for-each-ref --format='%(refname) %(objectname)')" +check 'exhausted family admission preserves all refs' "${after}" "${before}" + # Directory and semaphore generations can contain arbitrary text, even when # all locks have gone. They must not be treated as active lock records. git --git-dir="${store}" update-ref -d refs/locks/jobs/held From 3584fe634879307c4bd003aca6d5fdb9e9ee4f43 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 22 Sep 2026 08:56:33 -0700 Subject: [PATCH 3/3] test: use explicit conditionals for portable lint --- test/test.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test.sh b/test/test.sh index 04eb4aa..ac65f2a 100755 --- a/test/test.sh +++ b/test/test.sh @@ -1609,7 +1609,9 @@ for verb in "${commands[@]}"; do after="$(git --git-dir="${store}" for-each-ref --format='%(refname) %(objectname)')" check "${verb} leaves authoritative refs unchanged" "${after}" "${before}" done -check 'with does not execute after corrupt admission' "$([[ -e "${R}/ran" ]] && printf ran || true)" '' +ran='' +if [[ -e "${R}/ran" ]]; then ran=yes; fi +check 'with does not execute after corrupt admission' "${ran}" '' out="$(git-locks doctor 2>&1)" check 'doctor still diagnoses an undecodable lock' "$?" 1 contains 'doctor preserves record-decodes finding' "${out}" '"check":"record-decodes"' @@ -1684,7 +1686,9 @@ for role in lock meta slot; do contains "doctor identifies corrupt ${role} case ${case_count}" "${diagnostic}" "\"check\":\"${finding_kind}\"" doctor_err="$(cat "${ERR_PRE}")" check "doctor diagnoses ${role} case ${case_count} safely" "${doctor_rc}:${doctor_err}" '1:' - check "corrupt ${role} case ${case_count} fails closed" "${rc}:${out}:$([[ "${err}" == *'"reason":"store-read"'* ]] && printf store-read || true)" '2::store-read' + error_kind='' + if [[ "${err}" == *'"reason":"store-read"'* ]]; then error_kind='store-read'; fi + check "corrupt ${role} case ${case_count} fails closed" "${rc}:${out}:${error_kind}" '2::store-read' done git --git-dir="${store}" update-ref -d "${target}" done