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/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 a12aedcdc4..b842c0c4b9 100755
--- a/tests/fm-hash-pane.test.sh
+++ b/tests/fm-hash-pane.test.sh
@@ -21,22 +21,27 @@ 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_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/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
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"
for spec in "$@"; do
tool=${spec%%=*}
src=${spec#*=}
@@ -46,6 +51,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 +81,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 +132,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 +161,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