From ba28663e2ebcfd4cfdb5609a03f8c4b3b73a8d9e Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 09:35:08 -0700 Subject: [PATCH 1/2] fix(watch): make hash_pane's last-resort tier content-sensitive Address 3 CodeRabbit findings on merged PR #21 (hash_pane PATH-gap fallback chain): - MAJOR: the last-resort tier returned wc -c's raw byte count, so two panes of equal size but different content collided and change detection missed real updates. Replace it with a pure-shell content digest (od byte dump piped through an awk rolling hash) so same-size differing content can no longer collide. - MAJOR: the sbin-md5 test depended on the runner's ambient /sbin/md5 or PATH md5, which is absent on non-BSD hosts. Replace it with a self-contained fake md5 -q stand-in script so the test no longer depends on host tooling. - MINOR: add direct test coverage for the cksum tier. make_fakebin's baseline symlink set grows to include od/awk/cat, which the new last-resort tier and the fake sbin-md5 script both need. --- bin/fm-watch.sh | 11 ++++-- tests/fm-hash-pane.test.sh | 72 ++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 14 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 9947e261b5..8cabd0c713 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -180,8 +180,10 @@ hash_pane() { # interchangeable here. The chain degrades through progressively more # universal tools rather than hard-erroring when a watcher's PATH is # missing md5/md5sum (observed on a secondmate whose runtime PATH omitted - # both): openssl and cksum are near-universal fallbacks, and wc -c is a - # last-resort that cannot itself be absent. + # both): openssl and cksum are near-universal fallbacks, and the final tier + # is a pure-shell content digest (od + awk, both POSIX baseline) rather + # than a raw byte count, so two same-size panes with different content + # cannot collide and mask a real change. if command -v md5 >/dev/null 2>&1; then md5 -q elif [ -x "$sbin_md5" ]; then @@ -195,7 +197,10 @@ hash_pane() { elif command -v cksum >/dev/null 2>&1; then printf '%x\n' "$(cksum | cut -d' ' -f1)" else - wc -c | tr -d ' ' + od -An -tu1 -v | awk ' + { for (i = 1; i <= NF; i++) h = (h * 31 + $i) % 4294967291 } + END { printf "%x\n", h } + ' fi } diff --git a/tests/fm-hash-pane.test.sh b/tests/fm-hash-pane.test.sh index a12aedcdc4..351b14dc94 100755 --- a/tests/fm-hash-pane.test.sh +++ b/tests/fm-hash-pane.test.sh @@ -22,14 +22,18 @@ TMP_ROOT=$(fm_test_tmproot fm-hash-pane) REAL_CUT=$(command -v cut) || fail "cut not found on the test host" REAL_TR=$(command -v tr) || fail "tr not found on the test host" REAL_WC=$(command -v wc) || fail "wc not found on the test host" -REAL_SBIN_MD5=/sbin/md5 -[ -x "$REAL_SBIN_MD5" ] || REAL_SBIN_MD5=$(command -v md5 || true) +REAL_OD=$(command -v od) || fail "od not found on the test host" +REAL_AWK=$(command -v awk) || fail "awk not found on the test host" +REAL_CAT=$(command -v cat) || fail "cat not found on the test host" REAL_OPENSSL=$(command -v openssl || true) REAL_SHASUM=$(command -v shasum || true) +REAL_CKSUM=$(command -v cksum || true) # make_fakebin [tool=path ...]: symlink each named tool from its -# resolved absolute path into , plus cut/tr/wc always (hash_pane's -# fallback tiers pipe through them). Echoes . +# resolved absolute path into , plus cut/tr/wc/od/awk/cat always +# (hash_pane's fallback tiers, including the pure-shell last resort, pipe +# through them; cat also backs make_fake_sbin_md5's stand-in script). +# Echoes . make_fakebin() { local dir=$1 spec tool src shift @@ -37,6 +41,9 @@ make_fakebin() { ln -sf "$REAL_CUT" "$dir/cut" ln -sf "$REAL_TR" "$dir/tr" ln -sf "$REAL_WC" "$dir/wc" + ln -sf "$REAL_OD" "$dir/od" + ln -sf "$REAL_AWK" "$dir/awk" + ln -sf "$REAL_CAT" "$dir/cat" for spec in "$@"; do tool=${spec%%=*} src=${spec#*=} @@ -46,6 +53,23 @@ make_fakebin() { printf '%s\n' "$dir" } +# make_fake_sbin_md5 : write an executable stand-in for `md5 -q` at +# that ignores its arguments and echoes stdin back with a fixed tag +# prefix. hash_pane only needs the sbin-md5 tier to yield a token that is +# stable for identical input and distinct for different input, so this +# avoids depending on the test host actually having /sbin/md5 (absent on +# non-BSD CI runners) or any ambient `md5` binary. Echoes . +make_fake_sbin_md5() { + local path=$1 + cat > "$path" <<'FAKE_MD5' +#!/bin/sh +printf 'faux-sbin-md5:' +cat +FAKE_MD5 + chmod +x "$path" + printf '%s\n' "$path" +} + # source_watch : source fm-watch.sh's function definitions into the # current shell without running its main-entry watcher loop (fm-watch.sh # returns early when sourced; see its "Main entry" guard). @@ -59,19 +83,19 @@ source_watch() { } test_hash_pane_stable_and_distinct_via_sbin_md5() ( - local home fakebin out status + local home fakebin fake_sbin_md5 out status home="$TMP_ROOT/sbin-md5" mkdir -p "$home" source_watch "$home" - [ -n "$REAL_SBIN_MD5" ] || fail "no BSD-compatible md5 -q binary found on this host" fakebin=$(make_fakebin "$home/fakebin") + fake_sbin_md5=$(make_fake_sbin_md5 "$home/fake-sbin-md5") status=0 - out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text a") || status=$? + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$fake_sbin_md5" hash_pane <<<"pane text a") || status=$? expect_code 0 "$status" "hash_pane exit via the sbin md5 tier" [ -n "$out" ] || fail "hash_pane produced empty output via the sbin md5 tier" - [ "$out" = "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text a")" ] \ + [ "$out" = "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$fake_sbin_md5" hash_pane <<<"pane text a")" ] \ || fail "hash_pane is not stable for identical input via the sbin md5 tier" - [ "$out" != "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$REAL_SBIN_MD5" hash_pane <<<"pane text b")" ] \ + [ "$out" != "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$fake_sbin_md5" hash_pane <<<"pane text b")" ] \ || fail "hash_pane produced the same hash for different input via the sbin md5 tier" pass "hash_pane returns a stable, input-sensitive hash via the sbin md5 tier" ) @@ -110,8 +134,27 @@ test_hash_pane_falls_back_past_openssl_to_shasum() ( pass "hash_pane falls back past openssl to shasum and still exits cleanly" ) +test_hash_pane_falls_back_past_shasum_to_cksum() ( + local home fakebin out expected status + home="$TMP_ROOT/no-md5-no-openssl-no-shasum" + mkdir -p "$home" + source_watch "$home" + [ -n "$REAL_CKSUM" ] || fail "cksum not available to exercise the fallback tier" + fakebin=$(make_fakebin "$home/fakebin" "cksum=$REAL_CKSUM") + status=0 + out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text a") || status=$? + expect_code 0 "$status" "hash_pane exit with md5/md5sum/openssl/shasum absent from PATH" + [ -n "$out" ] || fail "hash_pane produced empty output with md5/md5sum/openssl/shasum absent from PATH" + expected=$(printf '%x\n' "$(printf 'pane text a\n' | "$REAL_CKSUM" | "$REAL_CUT" -d' ' -f1)") + [ "$out" = "$expected" ] \ + || fail "hash_pane did not use the cksum fallback tier past shasum (got '$out', wanted '$expected')" + [ "$out" != "$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text b")" ] \ + || fail "hash_pane produced the same hash for different input via the cksum tier" + pass "hash_pane falls back past shasum to cksum and still exits cleanly" +) + test_hash_pane_never_hard_errors_with_no_hash_tool_at_all() ( - local home fakebin out status + local home fakebin out out_b status home="$TMP_ROOT/no-hash-tool" mkdir -p "$home" source_watch "$home" @@ -120,10 +163,17 @@ test_hash_pane_never_hard_errors_with_no_hash_tool_at_all() ( out=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text a") || status=$? expect_code 0 "$status" "hash_pane exit with no md5/md5sum/openssl/shasum/cksum on PATH" [ -n "$out" ] || fail "hash_pane produced empty output with no hash tool at all on PATH" - pass "hash_pane never hard-errors even with no hashing tool at all on PATH" + # "pane text a" and "pane text b" are the same length: this is a regression + # check for the prior last-resort tier (wc -c), which returned a raw byte + # count and so produced the identical token for both, masking real changes. + out_b=$(PATH=$fakebin FM_MD5_SBIN_OVERRIDE="$home/no-such-md5" hash_pane <<<"pane text b") + [ "$out" != "$out_b" ] \ + || fail "hash_pane's last-resort tier produced the same output for same-length different content (got '$out' for both)" + pass "hash_pane never hard-errors and stays content-sensitive with no hashing tool at all on PATH" ) test_hash_pane_stable_and_distinct_via_sbin_md5 test_hash_pane_falls_back_to_openssl_without_md5_or_md5sum test_hash_pane_falls_back_past_openssl_to_shasum +test_hash_pane_falls_back_past_shasum_to_cksum test_hash_pane_never_hard_errors_with_no_hash_tool_at_all From 5a54c4fca14bb0c2c87b697c9fedf3472974d61c Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Sat, 1 Aug 2026 09:44:12 -0700 Subject: [PATCH 2/2] no-mistakes(review): fix stale hash_pane doc and drop unused wc test fixture --- docs/configuration.md | 2 +- tests/fm-hash-pane.test.sh | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2bd969d227..5801d11eaf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -439,7 +439,7 @@ FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip boots FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, supervision repair, and checkout repair commands FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically FM_POLL=15 # seconds between watcher poll cycles -FM_MD5_SBIN_OVERRIDE=/sbin/md5 # override for hash_pane()'s hardcoded /sbin/md5 fallback tier, mainly for tests; hash_pane also falls back through md5sum, openssl, shasum, and cksum before a wc -c last resort that cannot itself be absent, so a watcher's poll-to-poll pane hashing never hard-errors when PATH lacks md5/md5sum +FM_MD5_SBIN_OVERRIDE=/sbin/md5 # override for hash_pane()'s hardcoded /sbin/md5 fallback tier, mainly for tests; hash_pane also falls back through md5sum, openssl, shasum, and cksum before a pure-shell od+awk content digest last resort that cannot itself be absent, so a watcher's poll-to-poll pane hashing never hard-errors when PATH lacks md5/md5sum FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap FM_CHECK_INTERVAL=300 # seconds between slow checks (authenticated merge polls, custom checks, or X-mode dispatch) diff --git a/tests/fm-hash-pane.test.sh b/tests/fm-hash-pane.test.sh index 351b14dc94..b842c0c4b9 100755 --- a/tests/fm-hash-pane.test.sh +++ b/tests/fm-hash-pane.test.sh @@ -21,7 +21,6 @@ TMP_ROOT=$(fm_test_tmproot fm-hash-pane) # Resolve real tool paths once, before any test narrows PATH. REAL_CUT=$(command -v cut) || fail "cut not found on the test host" REAL_TR=$(command -v tr) || fail "tr not found on the test host" -REAL_WC=$(command -v wc) || fail "wc not found on the test host" REAL_OD=$(command -v od) || fail "od not found on the test host" REAL_AWK=$(command -v awk) || fail "awk not found on the test host" REAL_CAT=$(command -v cat) || fail "cat not found on the test host" @@ -30,7 +29,7 @@ REAL_SHASUM=$(command -v shasum || true) REAL_CKSUM=$(command -v cksum || true) # make_fakebin [tool=path ...]: symlink each named tool from its -# resolved absolute path into , plus cut/tr/wc/od/awk/cat always +# resolved absolute path into , plus cut/tr/od/awk/cat always # (hash_pane's fallback tiers, including the pure-shell last resort, pipe # through them; cat also backs make_fake_sbin_md5's stand-in script). # Echoes . @@ -40,7 +39,6 @@ make_fakebin() { mkdir -p "$dir" ln -sf "$REAL_CUT" "$dir/cut" ln -sf "$REAL_TR" "$dir/tr" - ln -sf "$REAL_WC" "$dir/wc" ln -sf "$REAL_OD" "$dir/od" ln -sf "$REAL_AWK" "$dir/awk" ln -sf "$REAL_CAT" "$dir/cat"