From d2e03ce993c93c0c0cfb182a08294c9cb43fac7c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 11:27:55 +0000 Subject: [PATCH] fix: read a manifest's final line when the file has no trailing newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest_value, manifest_has and manifest_foreign each read the file with a bare `while read`, which stops at EOF without ever handing over a populated partial line. An unterminated final line therefore read as ABSENT — and absent is exactly the input both convergence rules key off, so the file's last line was the one least able to survive the miss. Three failures, in descending order of how much they cost: - A file truncated mid-write ends AT bootstrapped_at, so the unreadable line is the birth stamp itself. Rule 1 saw no at-stamp and regenerated the pair as now() — overwriting the one field that can never be reconstructed. A fixture born 2020-01-01 came back stamped with the current clock. - A whole file whose last line is converged_at read as empty, so Rule 2's one-time repair re-fired on EVERY run: the render stopped being a function of (existing file, running version) and the clock reached the file after all. This is the crux property of the feature, broken by a missing byte. - manifest_foreign dropped an unterminated foreign line entirely, so the writer ate a later command's provenance — the exact preservation contract the function exists to keep. The idiom is the repo's own: lib/users-config.sh:49 reads `|| [ -n "$line" ]` for the same reason. Reading the line correctly also repairs the file, since the rewritten copy is newline-terminated — asserted as "the source plus the newline it was missing, and nothing else", because a plain ends-in-newline check stays green on an implementation that drops the final line. 7 tests, each observed RED against the unfixed reader. Co-Authored-By: Claude Opus 4.8 --- commands/lib/manifest.sh | 6 ++--- test/cli.sh | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/commands/lib/manifest.sh b/commands/lib/manifest.sh index 939f966..ebc933b 100644 --- a/commands/lib/manifest.sh +++ b/commands/lib/manifest.sh @@ -49,7 +49,7 @@ manifest_path() { manifest_value() { [ -r "$1" ] || return 0 local k v - while IFS='=' read -r k v; do + while IFS='=' read -r k v || [ -n "$k" ]; do [ "$k" = "$2" ] || continue printf '%s' "$v" return 0 @@ -65,7 +65,7 @@ manifest_value() { manifest_has() { [ -r "$1" ] || return 1 local k - while IFS='=' read -r k _; do + while IFS='=' read -r k _ || [ -n "$k" ]; do [ "$k" = "$2" ] && return 0 done < "$1" return 1 @@ -79,7 +79,7 @@ manifest_has() { manifest_foreign() { [ -r "$1" ] || return 0 local line key - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue key="${line%%=*}" case " $MANIFEST_KEYS " in diff --git a/test/cli.sh b/test/cli.sh index d15183f..f7b6695 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -2054,6 +2054,60 @@ check "manifest: a converged_by with no converged_at is repaired once" 0 "conver check "manifest: the repair settles — it does not re-fire on the next run" 0 "" \ reproduces_itself "$REPAIRED" 0.4.0 +# -- a final line with NO trailing newline ---------------------------------- +# A bare `while read` stops at EOF without ever handing over a populated +# partial line, so the last record of an unterminated file reads as ABSENT. +# That is not a cosmetic parse miss here: absent is exactly the input both +# rules key off, so the file's last line is the one least able to survive it. +# A hand-edit with an editor that adds no final newline, or a truncated write, +# is enough to produce one. The reader idiom is the repo's own — +# lib/users-config.sh:49 reads `|| [ -n "$line" ]` for the same reason. +NONL="$MF/nonl-owned" +printf 'schema=1\nbootstrapped_by=0.4.0\nbootstrapped_at=2020-01-01T00:00:00Z\nconverged_by=0.4.0\nconverged_at=2020-01-01T00:00:00Z' > "$NONL" +# The crux assertion, applied to the case that broke it: with converged_at +# unreadable, Rule 2 saw an empty at-stamp and re-fired the "one-time" repair +# on EVERY run, so the clock reached the file after all. +check "manifest: an unterminated final line does not let the clock back in" 0 "" \ + clock_cannot_reach "$NONL" 0.4.0 +check "manifest: an unterminated converged_at is read, not re-stamped" 0 "converged_at=2020-01-01T00:00:00Z" \ + render "$NONL" 0.4.0 2026-07-19T14:24:51Z +# Rule 1 on the field that can never be reconstructed: a file truncated mid-way +# ends AT bootstrapped_at, so the unterminated line is the birth stamp itself — +# and regenerating it is the one loss no later run can undo. +printf 'schema=1\nbootstrapped_by=0.4.0\nbootstrapped_at=2020-01-01T00:00:00Z' > "$MF/nonl-birth" +check "manifest: an unterminated birth stamp stays pinned, not reborn today" 0 "bootstrapped_at=2020-01-01T00:00:00Z" \ + render "$MF/nonl-birth" 0.4.0 2026-07-19T14:24:51Z +# And the preservation contract, whose whole subject is the file's tail: a +# later command's line is very often the last one written. +NONLF="$MF/nonl-foreign" +printf 'schema=1\nbootstrapped_by=0.4.0\nbootstrapped_at=2020-01-01T00:00:00Z\nconverged_by=0.4.0\nconverged_at=2020-01-01T00:00:00Z\nrunner_installed_at=2026-07-19T16:10:00Z' > "$NONLF" +check "manifest: an unterminated FOREIGN final line is not eaten by the rewrite" 0 "runner_installed_at=2026-07-19T16:10:00Z" \ + render "$NONLF" 0.4.0 2026-07-19T14:24:51Z +# Reading it correctly also REPAIRS it: the rewritten copy is newline-terminated, +# so an unterminated file converges to a terminated one exactly once and then +# reproduces itself like any other. Asserted as "the source, plus the newline it +# was missing, and NOTHING else" — a plain does-it-end-in-\n check would stay +# green on an implementation that dropped the final line, since a file with the +# tail eaten is newline-terminated too. +NORMALIZED="$MF/normalized" +render "$NONLF" 0.4.0 2026-07-19T14:24:51Z > "$NORMALIZED" +adds_only_the_newline() { # adds_only_the_newline + diff <(cat "$1"; printf '\n') "$2" +} +check "manifest: the rewrite adds the missing final newline and changes nothing else" 0 "" \ + adds_only_the_newline "$NONLF" "$NORMALIZED" +check "manifest: ...and the normalized file then settles" 0 "" \ + reproduces_itself "$NORMALIZED" 0.4.0 +# Presence, not just value: manifest_has answers the absent-vs-empty question +# `rig manifest ` puts in its exit code, and it read the same short file. +has_key() { # has_key + ( set -euo pipefail + . "$ROOT/commands/lib/manifest.sh" + manifest_has "$1" "$2" ) +} +check "manifest: an unterminated final key is PRESENT, not absent" 0 "" \ + has_key "$NONL" converged_at + # -- the version that RAN, not the one installed now ------------------------ # The whole point: a machine outlives the rig that built it, so this is read # from the tree at run time and never re-derived from `rig --version` later.