feat(wake): #940 snapshot-datable digests — fd-3 snapshot-metadata channel (#941)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

Adapter emits snapshot sha/ts out-of-band on fd 3 so a changing value never enters the delta-gate hash. Detector validates advisorily (sha regex, epoch sanity before arithmetic, future-skew slack); malformed metadata is dropped loudly and never gates the wake. Digest renders snapshot_sha/snapshot_ts plus a git-show re-verify hint. Adapters that never write fd 3 are byte-identical.

Reviewed-by: Mos (design, independent)
Reviewed-by: mos-dt (artifact, hardening §2)
Co-authored-by: mos-dt-0 <[email protected]>
This commit was merged in pull request #941.
This commit is contained in:
2026-07-30 10:55:18 +00:00
committed by Mos
parent b981b4ec10
commit 8710d0f6d7
5 changed files with 301 additions and 4 deletions
@@ -225,15 +225,31 @@ _poll_source() {
# The source definition is handed to the adapter on STDIN via a temp FILE
# (not a pipe): an adapter that ignores stdin must not take SIGPIPE and read
# back as a spurious "source error" under `pipefail`.
local raw rc deftmp
local raw rc deftmp metatmp
mkdir -p "$DET_DIR"
# _wake_tmp_prefix is provided by _wake-common.sh (sourced above).
# shellcheck disable=SC2154
deftmp="$(mktemp "$DET_DIR/${_wake_tmp_prefix}defXXXXXX")" || return 1
printf '%s' "$def" >"$deftmp"
raw="$("$WAKE_DETECTOR_SOURCE_CMD" "$kind" "$id" <"$deftmp" 2>/dev/null)"
# fd 3 is the OUT-OF-BAND snapshot-metadata channel (#940): the adapter MAY
# write one JSON object {"snapshot_sha": "<git commit sha>", "snapshot_ts":
# <epoch>} there. It must stay out of stdout because EVERYTHING on stdout is
# hashed by the delta gate — an in-band tip-commit SHA would advance
# observed_hash on every unrelated push. Adapters that never write fd 3 leave
# the file empty: byte-identical legacy behavior.
metatmp="$(mktemp "$DET_DIR/${_wake_tmp_prefix}metaXXXXXX")" || {
rm -f "$deftmp"
return 1
}
raw="$("$WAKE_DETECTOR_SOURCE_CMD" "$kind" "$id" <"$deftmp" 3>"$metatmp" 2>/dev/null)"
rc=$?
rm -f "$deftmp"
# Slurp + remove the metadata file NOW so every return path below is clean;
# it is parsed only after the fail-loud gates (metadata from a FAILED
# observation is meaningless and must not be trusted or diagnosed).
local rawmeta=""
[ -s "$metatmp" ] && rawmeta="$(cat "$metatmp")"
rm -f "$metatmp"
if [ "$rc" -ne 0 ]; then
echo "detector.sh: FAIL LOUD (G2a) — source '$kind/$id' errored (adapter exit $rc: network/401/403/privacy-404/partial). observed_seq NOT advanced; NOT treated as 'no change'." >&2
return 1
@@ -243,6 +259,50 @@ _poll_source() {
return 1
fi
# --- snapshot metadata (fd 3, #940) — ADVISORY, validated, never gating ----
# Malformed metadata is dropped with a LOUD diagnostic but cannot fail the
# poll or suppress the wake: the obligation never depends on optional dating.
local snap_sha="" snap_ts="" snap_json=""
if [ -n "$rawmeta" ]; then
if snap_json="$(jq -ce '.' <<<"$rawmeta" 2>/dev/null)"; then
snap_sha="$(jq -r 'if (.snapshot_sha|type) == "string" then .snapshot_sha else "" end' <<<"$snap_json")"
snap_ts="$(jq -r 'if (.snapshot_ts|type) == "number" then (.snapshot_ts|floor|tostring) else "" end' <<<"$snap_json")"
if [ -n "$snap_sha" ] && ! printf '%s' "$snap_sha" | grep -Eq '^[0-9a-f]{7,64}$'; then
echo "detector.sh: source '$kind/$id' snapshot_sha rejected (not a 7-64 char lowercase-hex git sha) — snapshot metadata DROPPED, poll continues (#940)." >&2
snap_sha=""
snap_ts=""
fi
# A ts must be a sane positive epoch BEFORE any arithmetic touches it: a
# negative or absurdly large value would make the shell integer comparison
# below error out and silently KEEP the bad ts — validate first, compare after.
if [ -n "$snap_ts" ] && ! printf '%s' "$snap_ts" | grep -Eq '^[0-9]{1,12}$'; then
echo "detector.sh: source '$kind/$id' snapshot_ts rejected (not a sane positive epoch) — snapshot_ts DROPPED, poll continues (#940)." >&2
snap_ts=""
fi
# ts is only meaningful anchored to a revision the consumer can re-verify —
# a bare number with no sha behind it is the weakest possible attestation,
# so ts requires a valid sha (#940 review §2).
if [ -n "$snap_ts" ] && [ -z "$snap_sha" ]; then
echo "detector.sh: source '$kind/$id' snapshot_ts without a valid snapshot_sha — snapshot_ts DROPPED (unverifiable dating), poll continues (#940)." >&2
snap_ts=""
fi
# A FUTURE ts renders a stale snapshot fresher-than-fresh (negative age) —
# wrong in the reassuring direction, the exact failure class #940 fixes.
# Cross-host NTP skew of a few seconds is the NORMAL case, so allow a small
# slack; beyond it, drop the ts (the sha stays: independently verifiable).
if [ -n "$snap_ts" ]; then
local now_s
now_s="$(date +%s)"
if [ "$snap_ts" -gt $((now_s + ${WAKE_SNAPSHOT_TS_FUTURE_SLACK:-300})) ]; then
echo "detector.sh: source '$kind/$id' snapshot_ts is beyond the ${WAKE_SNAPSHOT_TS_FUTURE_SLACK:-300}s future-skew allowance (ts=$snap_ts now=$now_s) — snapshot_ts DROPPED, poll continues (#940)." >&2
snap_ts=""
fi
fi
else
echo "detector.sh: source '$kind/$id' wrote UNPARSEABLE snapshot metadata on fd 3 — DROPPED, poll continues (#940)." >&2
fi
fi
# --- anchor-scope (feature (a)) then hash ---------------------------------
local scoped
if [ -n "$anchor" ]; then
@@ -281,12 +341,17 @@ _poll_source() {
local locators emit_ts
emit_ts="$(date +%s)"
# NB: `def` is a reserved word in jq — the source-definition arg is `$sdef`.
# snapshot_sha/snapshot_ts (#940) join only when the adapter attested them.
locators="$(jq -cn \
--arg kind "$kind" \
--arg id "$id" \
--argjson sdef "$def" \
--arg hash "$h" \
--arg ssha "$snap_sha" \
--arg sts "$snap_ts" \
'{kind:$kind, id:$id, observed_hash:$hash}
+ (if $ssha != "" then {snapshot_sha: $ssha} else {} end)
+ (if $sts != "" then {snapshot_ts: ($sts|tonumber)} else {} end)
+ ( $sdef | {repo, path, anchor, remote, branches} | with_entries(select(.value != null)) )')"
local args=(enqueue --locators "$locators" --emit-ts "$emit_ts")