Skip to content

Commit 83e2294

Browse files
committed
fix(state-writer): include trailing line in dedupe canonicaliser
A target left behind without a final newline (interrupted previous write, external tampering) would lose its last line in the bash while-read loop, so a proposed write that removed a tail field could falsely dedupe against the truncated remainder. The `|| [[ -n line ]]` tail picks up the unterminated last line. Regression coverage includes the no-trailing-newline case and a backslashes-plus-trailing-spaces round-trip.
1 parent c8020cb commit 83e2294

2 files changed

Lines changed: 50 additions & 2 deletions

File tree

scripts/lib/state-writer.sh

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,16 @@
5050

5151
# Print $1's content with decided_at= lines filtered out. Used by the
5252
# dedupe path so a fresh timestamp on an otherwise-identical decision
53-
# doesn't force a rename.
53+
# doesn't force a rename. The `|| [[ -n "$line" ]]` tail catches an
54+
# existing target that's missing its final newline — without it the
55+
# loop would silently drop the last line and a proposed write that
56+
# removed a tail field could falsely dedupe against the truncated
57+
# remainder. (The writer's own output always ends in \n, so this only
58+
# matters for files left behind by an interrupted write or external
59+
# tampering.)
5460
_airplanes_write_state_canonical() {
5561
local line
56-
while IFS= read -r line; do
62+
while IFS= read -r line || [[ -n "$line" ]]; do
5763
case "$line" in
5864
decided_at=*) continue ;;
5965
esac

test/test_state_writer.bats

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,45 @@ bar"
307307
[ "$status" -eq 1 ]
308308
grep -qx 'service=before' "$TARGET"
309309
}
310+
311+
@test "dedupe: existing target without trailing newline → tail field still compared" {
312+
# An external writer or an interrupted previous run could leave a
313+
# target with no final newline. Without the `|| [[ -n line ]]` tail
314+
# in the canonicaliser the last line silently disappears from the
315+
# comparison, and a proposed write that removed the trailing field
316+
# would falsely dedupe against the truncated remainder, preserving
317+
# stale content. This case asserts the canonicaliser includes the
318+
# final line so the writer detects the difference and renames.
319+
printf 'schema_version=1\nservice=mlat\nstate=disabled\nextra=v' \
320+
> "$TARGET"
321+
local mtime_before
322+
mtime_before="$(stat -c %Y "$TARGET")"
323+
sleep 1
324+
airplanes_write_state "$TARGET" \
325+
service=mlat \
326+
state=disabled
327+
local mtime_after
328+
mtime_after="$(stat -c %Y "$TARGET")"
329+
[ "$mtime_after" -gt "$mtime_before" ]
330+
! grep -qx 'extra=v' "$TARGET"
331+
}
332+
333+
@test "dedupe: values with backslashes + trailing spaces preserved verbatim" {
334+
# `IFS= read -r` + `printf '%s\n'` round-trips backslashes and
335+
# trailing whitespace; canonicalisation must not mangle them, or
336+
# else two semantically-identical writes would compare unequal and
337+
# break dedupe.
338+
airplanes_write_state "$TARGET" \
339+
"feed_bin=/usr/bin/x\\trailing " \
340+
"decided_at=2026-05-18T10:00:00Z"
341+
local mtime_before
342+
mtime_before="$(stat -c %Y "$TARGET")"
343+
sleep 1
344+
airplanes_write_state "$TARGET" \
345+
"feed_bin=/usr/bin/x\\trailing " \
346+
"decided_at=2026-05-18T10:00:30Z"
347+
local mtime_after
348+
mtime_after="$(stat -c %Y "$TARGET")"
349+
[ "$mtime_after" -eq "$mtime_before" ]
350+
grep -qFx 'feed_bin=/usr/bin/x\trailing ' "$TARGET"
351+
}

0 commit comments

Comments
 (0)