diff --git a/AGENTS.md b/AGENTS.md index 134fdde5559..48debcb70d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ state/ volatile runtime signals; gitignored .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication + .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its provider poll artifacts retire .pr-check-quarantine/ private non-runnable storage for checks neutralized by the non-executing migration .pr-check-migration.log private per-task outcomes distinguishing rebuilt or canonically registered replacement polls, quarantined unarmed polls, and incomplete migrations .pr-check-migration-scan-v1 private marker proving the non-executing scan disabled every unsafe legacy check; .pr-check-migration-v1 separately records completed private repairs diff --git a/bin/fm-pr-check-migrate.sh b/bin/fm-pr-check-migrate.sh index 5459343a5fb..ef3cac5a025 100755 --- a/bin/fm-pr-check-migrate.sh +++ b/bin/fm-pr-check-migrate.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Non-executing migration for watcher PR checks created by older Firstmate # versions. Legacy check files are never run, sourced, or parsed by Bash. -# Canonical polls are rebuilt from validated metadata, provenance-bound polls -# and registered custom checks remain armed, and every other task poll is +# Pending validated merged-poll retirements finish first. Canonical polls are +# rebuilt from validated metadata, provenance-bound polls and registered custom +# checks remain armed, and every other task poll is # quarantined for private review. A current X-mode shim is preserved by exact # content, while the recognized older byte-static shim is refreshed in place. # Usage: fm-pr-check-migrate.sh [--checks-safe] @@ -253,9 +254,18 @@ x_shim_locked_scan_needed() { return 0 } -# Marker short-circuits apply only when generated artifact identities are current. -# Otherwise watcher exclusion comes before every check scan and state mutation. -if ! x_shim_locked_scan_needed; then +retirement_recovery_needed() { + local receipt + for receipt in "$STATE"/*.pr-poll-retirement; do + [ -e "$receipt" ] || [ -L "$receipt" ] || continue + return 0 + done + return 1 +} + +# Marker short-circuits apply only when generated artifact identities are current +# and no identity-bound retirement remains to finish under watcher exclusion. +if ! x_shim_locked_scan_needed && ! retirement_recovery_needed; then migration_complete && exit 0 [ "$ALLOW_INCOMPLETE_REPAIRS" -eq 1 ] && scan_complete && exit 0 fi @@ -333,6 +343,10 @@ if [ ! -d "$STATE" ] || [ -L "$STATE" ]; then fi STATE_DEVICE=$(fm_pr_file_device "$STATE") || exit 1 [ -n "$STATE_DEVICE" ] || exit 1 +if ! fm_pr_poll_retirement_recover_all "$STATE" "$TEMPLATE"; then + echo "PR_CHECK_MIGRATION: pending review poll retirement could not be validated:$FM_PR_POLL_RETIREMENT_REJECTED" >&2 + exit 1 +fi refresh_v1_x_shim() { local shim="$STATE/x-watch.check.sh" fmx_poll_shim_v1_valid "$shim" "$FM_HOME" "$FM_ROOT" "$STATE_DEVICE" || return 0 diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index 317355e99be..183148265bf 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -39,6 +39,14 @@ if [ ! -f "$META" ] || [ -L "$META" ] || [ "$(fm_pr_file_link_count "$META")" != exit 1 fi +# A prior exact merged result may have queued its durable notification before an +# interruption. Finish only its identity-bound receipt before publishing a +# replacement poll. +fm_pr_poll_retirement_recover_one "$STATE" "$ID" "$SCRIPT_DIR/fm-pr-poll.sh" || { + echo "error: pending review poll retirement could not be validated" >&2 + exit 1 +} + # Neutralize any pre-fix poll before recording or arming this task. The # migration never executes legacy artifacts and holds watcher exclusion while # it quarantines or rebuilds them. diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 7eb02a7d595..45fd14c5d1d 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -1,12 +1,19 @@ #!/usr/bin/env bash -# Shared validation and atomic artifact helpers for GitHub PR merge polling. -# Callers must validate task IDs and raw PR URLs before constructing task paths -# or performing any side effect. +# Shared validation and atomic artifact helpers for GitHub and GitLab review +# polling. Callers must validate task IDs and raw review URLs before constructing +# task paths or performing any side effect. +# +# A validated exact merged result is retired only after its durable wake is +# appended. The private receipt binds the provider identity and every current +# poll artifact, including an artifact's exact absence, so interrupted cleanup +# can resume without executing state-file bytes or removing a replacement poll. # shellcheck disable=SC2034 # Parsed forge globals are consumed by sourcing scripts. FM_PR_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=bin/fm-forge-lib.sh disable=SC1091 . "$FM_PR_LIB_DIR/fm-forge-lib.sh" +# shellcheck source=bin/fm-check-lib.sh disable=SC1091 +. "$FM_PR_LIB_DIR/fm-check-lib.sh" FM_PR_URL= FM_PR_FORGE= @@ -19,7 +26,10 @@ FM_PR_DATA_URL= FM_PR_DATA_OWNER= FM_PR_DATA_REPO= FM_PR_DATA_NUMBER= +FM_PR_META_FORGE= FM_PR_META_URL= +FM_PR_META_HOST= +FM_PR_META_PROJECT= FM_PR_META_OWNER= FM_PR_META_REPO= FM_PR_META_NUMBER= @@ -49,6 +59,45 @@ FM_PR_POLL_EXPECT_DATA_IDENTITY= FM_PR_POLL_EXPECT_CHECK_IDENTITY= FM_PR_POLL_TEMPLATE= FM_PR_POLL_STATE_DEVICE= +FM_PR_POLL_SNAPSHOT_ID= +FM_PR_POLL_SNAPSHOT_PROVIDER= +FM_PR_POLL_SNAPSHOT_URL= +FM_PR_POLL_SNAPSHOT_HOST= +FM_PR_POLL_SNAPSHOT_PROJECT= +FM_PR_POLL_SNAPSHOT_NUMBER= +FM_PR_POLL_SNAPSHOT_CHECK_MODE= +FM_PR_POLL_SNAPSHOT_CHECK_HASH= +FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY= +FM_PR_POLL_SNAPSHOT_DATA_PRESENCE= +FM_PR_POLL_SNAPSHOT_DATA_HASH= +FM_PR_POLL_SNAPSHOT_DATA_IDENTITY= +FM_PR_POLL_SNAPSHOT_REG_PRESENCE= +FM_PR_POLL_SNAPSHOT_REG_HASH= +FM_PR_POLL_SNAPSHOT_REG_IDENTITY= +FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE= +FM_PR_POLL_SNAPSHOT_TRUST_HASH= +FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY= +FM_PR_RETIRE_ID= +FM_PR_RETIRE_PROVIDER= +FM_PR_RETIRE_URL= +FM_PR_RETIRE_HOST= +FM_PR_RETIRE_PROJECT= +FM_PR_RETIRE_NUMBER= +FM_PR_RETIRE_CHECK_MODE= +FM_PR_RETIRE_CHECK_HASH= +FM_PR_RETIRE_CHECK_IDENTITY= +FM_PR_RETIRE_DATA_PRESENCE= +FM_PR_RETIRE_DATA_HASH= +FM_PR_RETIRE_DATA_IDENTITY= +FM_PR_RETIRE_REG_PRESENCE= +FM_PR_RETIRE_REG_HASH= +FM_PR_RETIRE_REG_IDENTITY= +FM_PR_RETIRE_TRUST_PRESENCE= +FM_PR_RETIRE_TRUST_HASH= +FM_PR_RETIRE_TRUST_IDENTITY= +FM_PR_RETIRE_RECEIPT_HASH= +FM_PR_RETIRE_RECEIPT_IDENTITY= +FM_PR_POLL_RETIREMENT_REJECTED= fm_task_id_path_safe() { local id=${1-} @@ -182,7 +231,10 @@ fm_pr_regular_destination_on_device_or_absent() { fm_pr_metadata_identity_parse() { local file=$1 line value pr_count=0 head_count=0 target_count=0 seen_pr=0 post_pr_invalid=0 + FM_PR_META_FORGE= FM_PR_META_URL= + FM_PR_META_HOST= + FM_PR_META_PROJECT= FM_PR_META_OWNER= FM_PR_META_REPO= FM_PR_META_NUMBER= @@ -197,7 +249,10 @@ fm_pr_metadata_identity_parse() { [ "$pr_count" -eq 1 ] || continue value=${line#pr=} if fm_pr_url_parse "$value"; then + FM_PR_META_FORGE=$FM_PR_FORGE FM_PR_META_URL=$FM_PR_URL + FM_PR_META_HOST=$FM_PR_HOST + FM_PR_META_PROJECT=$FM_PR_PROJECT FM_PR_META_OWNER=$FM_PR_OWNER FM_PR_META_REPO=$FM_PR_REPO FM_PR_META_NUMBER=$FM_PR_NUMBER @@ -502,3 +557,503 @@ fm_pr_poll_artifacts_valid() { [ "$FM_PR_META_REPO" = "$FM_PR_DATA_REPO" ] || return 1 [ "$FM_PR_META_NUMBER" = "$FM_PR_DATA_NUMBER" ] } + +fm_pr_poll_snapshot_reset() { + FM_PR_POLL_SNAPSHOT_ID= + FM_PR_POLL_SNAPSHOT_PROVIDER= + FM_PR_POLL_SNAPSHOT_URL= + FM_PR_POLL_SNAPSHOT_HOST= + FM_PR_POLL_SNAPSHOT_PROJECT= + FM_PR_POLL_SNAPSHOT_NUMBER= + FM_PR_POLL_SNAPSHOT_CHECK_MODE= + FM_PR_POLL_SNAPSHOT_CHECK_HASH= + FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY= + FM_PR_POLL_SNAPSHOT_DATA_PRESENCE= + FM_PR_POLL_SNAPSHOT_DATA_HASH= + FM_PR_POLL_SNAPSHOT_DATA_IDENTITY= + FM_PR_POLL_SNAPSHOT_REG_PRESENCE= + FM_PR_POLL_SNAPSHOT_REG_HASH= + FM_PR_POLL_SNAPSHOT_REG_IDENTITY= + FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE= + FM_PR_POLL_SNAPSHOT_TRUST_HASH= + FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY= +} + +fm_pr_gitlab_poll_artifacts_valid() { + local state=$1 id=$2 state_device meta check trust data registration + local line worktree='' worktree_count=0 + fm_pr_task_id_valid "$id" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + meta="$state/$id.meta" + check="$state/$id.check.sh" + trust="$state/$id.check-trust" + data="$state/$id.pr-poll" + registration="$state/$id.pr-poll-registration" + fm_pr_metadata_identity_parse "$meta" || return 1 + [ "$FM_PR_META_FORGE" = gitlab ] && [ -n "$FM_PR_META_TARGET" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + worktree=*) + worktree_count=$((worktree_count + 1)) + [ "$worktree_count" -eq 1 ] && worktree=${line#worktree=} + ;; + esac + done < "$meta" + [ "$worktree_count" -eq 1 ] && [ -d "$worktree" ] || return 1 + fm_forge_gitlab_mr_url_parse "$worktree" "$FM_PR_META_URL" || return 1 + [ "$FM_FORGE_HOST" = "$FM_PR_META_HOST" ] \ + && [ "$FM_FORGE_PROJECT" = "$FM_PR_META_PROJECT" ] || return 1 + fm_custom_check_registered "$state" "$id" || return 1 + fm_pr_private_file_valid "$check" 700 "$state_device" || return 1 + fm_pr_private_file_valid "$trust" 600 "$state_device" || return 1 + [ ! -e "$data" ] && [ ! -L "$data" ] || return 1 + [ ! -e "$registration" ] && [ ! -L "$registration" ] || return 1 + cmp -s <(printf '#!/usr/bin/env bash\nexec %q mr-poll %q %q --target %q\n' \ + "$FM_PR_LIB_DIR/fm-forge.sh" "$worktree" "$FM_PR_META_URL" "$FM_PR_META_TARGET") "$check" +} + +fm_pr_poll_snapshot_capture() { + local state=$1 id=$2 template=$3 check data registration trust state_device + fm_pr_poll_snapshot_reset + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + check="$state/$id.check.sh" + data="$state/$id.pr-poll" + registration="$state/$id.pr-poll-registration" + trust="$state/$id.check-trust" + if fm_pr_poll_artifacts_valid "$state" "$id" "$template"; then + [ ! -e "$trust" ] && [ ! -L "$trust" ] || return 1 + fm_pr_url_parse "$FM_PR_DATA_URL" || return 1 + [ "$FM_PR_FORGE" = github ] || return 1 + FM_PR_POLL_SNAPSHOT_PROVIDER=github + FM_PR_POLL_SNAPSHOT_CHECK_MODE=600 + FM_PR_POLL_SNAPSHOT_DATA_PRESENCE=present + FM_PR_POLL_SNAPSHOT_REG_PRESENCE=present + FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE=absent + FM_PR_POLL_SNAPSHOT_TRUST_HASH=- + FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY=- + elif fm_pr_gitlab_poll_artifacts_valid "$state" "$id"; then + FM_PR_POLL_SNAPSHOT_PROVIDER=gitlab + FM_PR_POLL_SNAPSHOT_CHECK_MODE=700 + FM_PR_POLL_SNAPSHOT_DATA_PRESENCE=absent + FM_PR_POLL_SNAPSHOT_DATA_HASH=- + FM_PR_POLL_SNAPSHOT_DATA_IDENTITY=- + FM_PR_POLL_SNAPSHOT_REG_PRESENCE=absent + FM_PR_POLL_SNAPSHOT_REG_HASH=- + FM_PR_POLL_SNAPSHOT_REG_IDENTITY=- + FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE=present + else + return 1 + fi + FM_PR_POLL_SNAPSHOT_ID=$id + FM_PR_POLL_SNAPSHOT_URL=$FM_PR_META_URL + FM_PR_POLL_SNAPSHOT_HOST=$FM_PR_META_HOST + FM_PR_POLL_SNAPSHOT_PROJECT=$FM_PR_META_PROJECT + FM_PR_POLL_SNAPSHOT_NUMBER=$FM_PR_META_NUMBER + FM_PR_POLL_SNAPSHOT_CHECK_HASH=$(fm_pr_sha256 "$check") || return 1 + FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY=$(fm_pr_file_identity "$check") || return 1 + if [ "$FM_PR_POLL_SNAPSHOT_DATA_PRESENCE" = present ]; then + FM_PR_POLL_SNAPSHOT_DATA_HASH=$(fm_pr_sha256 "$data") || return 1 + FM_PR_POLL_SNAPSHOT_DATA_IDENTITY=$(fm_pr_file_identity "$data") || return 1 + fi + if [ "$FM_PR_POLL_SNAPSHOT_REG_PRESENCE" = present ]; then + FM_PR_POLL_SNAPSHOT_REG_HASH=$(fm_pr_sha256 "$registration") || return 1 + FM_PR_POLL_SNAPSHOT_REG_IDENTITY=$(fm_pr_file_identity "$registration") || return 1 + fi + if [ "$FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE" = present ]; then + FM_PR_POLL_SNAPSHOT_TRUST_HASH=$(fm_pr_sha256 "$trust") || return 1 + FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY=$(fm_pr_file_identity "$trust") || return 1 + fi +} + +fm_pr_poll_snapshot_fingerprint() { + printf '%s\n' \ + "$FM_PR_POLL_SNAPSHOT_ID" \ + "$FM_PR_POLL_SNAPSHOT_PROVIDER" \ + "$FM_PR_POLL_SNAPSHOT_URL" \ + "$FM_PR_POLL_SNAPSHOT_HOST" \ + "$FM_PR_POLL_SNAPSHOT_PROJECT" \ + "$FM_PR_POLL_SNAPSHOT_NUMBER" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_MODE" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_HASH" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_DATA_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_DATA_HASH" \ + "$FM_PR_POLL_SNAPSHOT_DATA_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_REG_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_REG_HASH" \ + "$FM_PR_POLL_SNAPSHOT_REG_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_HASH" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY" +} + +fm_pr_poll_snapshot_matches() { + local state=$1 id=$2 template=$3 expected current + expected=$(fm_pr_poll_snapshot_fingerprint) + [ -n "$FM_PR_POLL_SNAPSHOT_ID" ] && [ "$id" = "$FM_PR_POLL_SNAPSHOT_ID" ] || return 1 + fm_pr_poll_snapshot_capture "$state" "$id" "$template" || return 1 + current=$(fm_pr_poll_snapshot_fingerprint) + [ "$current" = "$expected" ] +} + +fm_pr_poll_retirement_reset() { + FM_PR_RETIRE_ID= + FM_PR_RETIRE_PROVIDER= + FM_PR_RETIRE_URL= + FM_PR_RETIRE_HOST= + FM_PR_RETIRE_PROJECT= + FM_PR_RETIRE_NUMBER= + FM_PR_RETIRE_CHECK_MODE= + FM_PR_RETIRE_CHECK_HASH= + FM_PR_RETIRE_CHECK_IDENTITY= + FM_PR_RETIRE_DATA_PRESENCE= + FM_PR_RETIRE_DATA_HASH= + FM_PR_RETIRE_DATA_IDENTITY= + FM_PR_RETIRE_REG_PRESENCE= + FM_PR_RETIRE_REG_HASH= + FM_PR_RETIRE_REG_IDENTITY= + FM_PR_RETIRE_TRUST_PRESENCE= + FM_PR_RETIRE_TRUST_HASH= + FM_PR_RETIRE_TRUST_IDENTITY= +} + +fm_pr_poll_retirement_tuple_valid() { + local presence=$1 hash=$2 identity=$3 + case "$presence" in + present) + [[ "$hash" =~ ^[0-9a-f]{64}$ ]] \ + && [[ "$identity" =~ ^[0-9]+:[0-9]+$ ]] + ;; + absent) [ "$hash" = - ] && [ "$identity" = - ] ;; + *) return 1 ;; + esac +} + +fm_pr_poll_retirement_parse() { + local file=$1 version id provider url host project number check_mode check_hash check_identity + local data_presence data_hash data_identity reg_presence reg_hash reg_identity + local trust_presence trust_hash trust_identity result _extra + fm_pr_poll_retirement_reset + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + exec 9< "$file" || return 1 + IFS= read -r version <&9 || { exec 9<&-; return 1; } + IFS= read -r id <&9 || { exec 9<&-; return 1; } + IFS= read -r provider <&9 || { exec 9<&-; return 1; } + IFS= read -r url <&9 || { exec 9<&-; return 1; } + IFS= read -r host <&9 || { exec 9<&-; return 1; } + IFS= read -r project <&9 || { exec 9<&-; return 1; } + IFS= read -r number <&9 || { exec 9<&-; return 1; } + IFS= read -r check_mode <&9 || { exec 9<&-; return 1; } + IFS= read -r check_hash <&9 || { exec 9<&-; return 1; } + IFS= read -r check_identity <&9 || { exec 9<&-; return 1; } + IFS= read -r data_presence <&9 || { exec 9<&-; return 1; } + IFS= read -r data_hash <&9 || { exec 9<&-; return 1; } + IFS= read -r data_identity <&9 || { exec 9<&-; return 1; } + IFS= read -r reg_presence <&9 || { exec 9<&-; return 1; } + IFS= read -r reg_hash <&9 || { exec 9<&-; return 1; } + IFS= read -r reg_identity <&9 || { exec 9<&-; return 1; } + IFS= read -r trust_presence <&9 || { exec 9<&-; return 1; } + IFS= read -r trust_hash <&9 || { exec 9<&-; return 1; } + IFS= read -r trust_identity <&9 || { exec 9<&-; return 1; } + IFS= read -r result <&9 || { exec 9<&-; return 1; } + if IFS= read -r _extra <&9; then + exec 9<&- + return 1 + fi + exec 9<&- + [ "$version" = fm-pr-poll-retirement-v1 ] || return 1 + fm_pr_task_id_valid "$id" || return 1 + fm_pr_url_parse "$url" || return 1 + [ "$provider" = "$FM_PR_FORGE" ] \ + && [ "$host" = "$FM_PR_HOST" ] \ + && [ "$project" = "$FM_PR_PROJECT" ] \ + && [ "$number" = "$FM_PR_NUMBER" ] || return 1 + case "$provider:$check_mode:$data_presence:$reg_presence:$trust_presence" in + github:600:present:present:absent|gitlab:700:absent:absent:present) ;; + *) return 1 ;; + esac + [[ "$check_hash" =~ ^[0-9a-f]{64}$ ]] || return 1 + [[ "$check_identity" =~ ^[0-9]+:[0-9]+$ ]] || return 1 + fm_pr_poll_retirement_tuple_valid "$data_presence" "$data_hash" "$data_identity" || return 1 + fm_pr_poll_retirement_tuple_valid "$reg_presence" "$reg_hash" "$reg_identity" || return 1 + fm_pr_poll_retirement_tuple_valid "$trust_presence" "$trust_hash" "$trust_identity" || return 1 + [ "$result" = merged ] || return 1 + FM_PR_RETIRE_ID=$id + FM_PR_RETIRE_PROVIDER=$provider + FM_PR_RETIRE_URL=$url + FM_PR_RETIRE_HOST=$host + FM_PR_RETIRE_PROJECT=$project + FM_PR_RETIRE_NUMBER=$number + FM_PR_RETIRE_CHECK_MODE=$check_mode + FM_PR_RETIRE_CHECK_HASH=$check_hash + FM_PR_RETIRE_CHECK_IDENTITY=$check_identity + FM_PR_RETIRE_DATA_PRESENCE=$data_presence + FM_PR_RETIRE_DATA_HASH=$data_hash + FM_PR_RETIRE_DATA_IDENTITY=$data_identity + FM_PR_RETIRE_REG_PRESENCE=$reg_presence + FM_PR_RETIRE_REG_HASH=$reg_hash + FM_PR_RETIRE_REG_IDENTITY=$reg_identity + FM_PR_RETIRE_TRUST_PRESENCE=$trust_presence + FM_PR_RETIRE_TRUST_HASH=$trust_hash + FM_PR_RETIRE_TRUST_IDENTITY=$trust_identity +} + +fm_pr_poll_retirement_fingerprint() { + printf '%s\n' \ + "$FM_PR_RETIRE_ID" \ + "$FM_PR_RETIRE_PROVIDER" \ + "$FM_PR_RETIRE_URL" \ + "$FM_PR_RETIRE_HOST" \ + "$FM_PR_RETIRE_PROJECT" \ + "$FM_PR_RETIRE_NUMBER" \ + "$FM_PR_RETIRE_CHECK_MODE" \ + "$FM_PR_RETIRE_CHECK_HASH" \ + "$FM_PR_RETIRE_CHECK_IDENTITY" \ + "$FM_PR_RETIRE_DATA_PRESENCE" \ + "$FM_PR_RETIRE_DATA_HASH" \ + "$FM_PR_RETIRE_DATA_IDENTITY" \ + "$FM_PR_RETIRE_REG_PRESENCE" \ + "$FM_PR_RETIRE_REG_HASH" \ + "$FM_PR_RETIRE_REG_IDENTITY" \ + "$FM_PR_RETIRE_TRUST_PRESENCE" \ + "$FM_PR_RETIRE_TRUST_HASH" \ + "$FM_PR_RETIRE_TRUST_IDENTITY" +} + +fm_pr_poll_retirement_receipt_valid() { + local state=$1 id=$2 receipt state_device meta + fm_pr_task_id_valid "$id" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + receipt="$state/$id.pr-poll-retirement" + fm_pr_private_file_valid "$receipt" 600 "$state_device" || return 1 + fm_pr_poll_retirement_parse "$receipt" || return 1 + [ "$FM_PR_RETIRE_ID" = "$id" ] || return 1 + meta="$state/$id.meta" + fm_pr_metadata_identity_parse "$meta" || return 1 + [ "$FM_PR_META_FORGE" = "$FM_PR_RETIRE_PROVIDER" ] \ + && [ "$FM_PR_META_URL" = "$FM_PR_RETIRE_URL" ] \ + && [ "$FM_PR_META_HOST" = "$FM_PR_RETIRE_HOST" ] \ + && [ "$FM_PR_META_PROJECT" = "$FM_PR_RETIRE_PROJECT" ] \ + && [ "$FM_PR_META_NUMBER" = "$FM_PR_RETIRE_NUMBER" ] || return 1 + FM_PR_RETIRE_RECEIPT_HASH=$(fm_pr_sha256 "$receipt") || return 1 + FM_PR_RETIRE_RECEIPT_IDENTITY=$(fm_pr_file_identity "$receipt") || return 1 +} + +fm_pr_poll_retirement_check_valid() { + local state=$1 id=$2 state_device check + state_device=$(fm_pr_file_device "$state") || return 1 + check="$state/$id.check.sh" + fm_pr_private_file_valid "$check" "$FM_PR_RETIRE_CHECK_MODE" "$state_device" || return 1 + [ "$(fm_pr_sha256 "$check")" = "$FM_PR_RETIRE_CHECK_HASH" ] \ + && [ "$(fm_pr_file_identity "$check")" = "$FM_PR_RETIRE_CHECK_IDENTITY" ] +} + +fm_pr_poll_retirement_data_valid() { + local state=$1 id=$2 state_device data + state_device=$(fm_pr_file_device "$state") || return 1 + data="$state/$id.pr-poll" + fm_pr_private_file_valid "$data" 600 "$state_device" || return 1 + [ "$(fm_pr_sha256 "$data")" = "$FM_PR_RETIRE_DATA_HASH" ] \ + && [ "$(fm_pr_file_identity "$data")" = "$FM_PR_RETIRE_DATA_IDENTITY" ] \ + && fm_pr_poll_data_parse "$data" \ + && [ "$FM_PR_DATA_URL" = "$FM_PR_RETIRE_URL" ] \ + && [ "$FM_PR_DATA_NUMBER" = "$FM_PR_RETIRE_NUMBER" ] +} + +fm_pr_poll_retirement_registration_valid() { + local state=$1 id=$2 state_device registration + state_device=$(fm_pr_file_device "$state") || return 1 + registration="$state/$id.pr-poll-registration" + fm_pr_private_file_valid "$registration" 600 "$state_device" || return 1 + [ "$(fm_pr_sha256 "$registration")" = "$FM_PR_RETIRE_REG_HASH" ] \ + && [ "$(fm_pr_file_identity "$registration")" = "$FM_PR_RETIRE_REG_IDENTITY" ] \ + && fm_pr_poll_registration_parse "$registration" \ + && [ "$FM_PR_REG_ID" = "$id" ] \ + && [ "$FM_PR_REG_URL" = "$FM_PR_RETIRE_URL" ] \ + && [ "$FM_PR_REG_NUMBER" = "$FM_PR_RETIRE_NUMBER" ] +} + +fm_pr_poll_retirement_trust_valid() { + local state=$1 id=$2 state_device trust + state_device=$(fm_pr_file_device "$state") || return 1 + trust="$state/$id.check-trust" + fm_pr_private_file_valid "$trust" 600 "$state_device" || return 1 + [ "$(fm_pr_sha256 "$trust")" = "$FM_PR_RETIRE_TRUST_HASH" ] \ + && [ "$(fm_pr_file_identity "$trust")" = "$FM_PR_RETIRE_TRUST_IDENTITY" ] \ + && fm_custom_check_trust_read "$state" "$id" \ + && [ "$FM_CUSTOM_CHECK_HASH" = "$FM_PR_RETIRE_CHECK_HASH" ] +} + +fm_pr_poll_retirement_state_valid() { + local state=$1 id=$2 role expected path present_seen=0 + fm_pr_poll_retirement_receipt_valid "$state" "$id" || return 1 + for role in check registration data trust; do + case "$role" in + check) + expected=present + path="$state/$id.check.sh" + ;; + registration) + expected=$FM_PR_RETIRE_REG_PRESENCE + path="$state/$id.pr-poll-registration" + ;; + data) + expected=$FM_PR_RETIRE_DATA_PRESENCE + path="$state/$id.pr-poll" + ;; + trust) + expected=$FM_PR_RETIRE_TRUST_PRESENCE + path="$state/$id.check-trust" + ;; + esac + if [ "$expected" = absent ]; then + [ ! -e "$path" ] && [ ! -L "$path" ] || return 1 + continue + fi + if [ -e "$path" ] || [ -L "$path" ]; then + case "$role" in + check) fm_pr_poll_retirement_check_valid "$state" "$id" || return 1 ;; + registration) fm_pr_poll_retirement_registration_valid "$state" "$id" || return 1 ;; + data) fm_pr_poll_retirement_data_valid "$state" "$id" || return 1 ;; + trust) fm_pr_poll_retirement_trust_valid "$state" "$id" || return 1 ;; + esac + present_seen=1 + else + [ "$present_seen" -eq 0 ] || return 1 + fi + done +} + +fm_pr_poll_retirement_remove_exact() { + local path=$1 mode=$2 state_device=$3 expected_identity=$4 expected_hash=$5 + fm_pr_private_file_valid "$path" "$mode" "$state_device" || return 1 + [ "$(fm_pr_file_identity "$path")" = "$expected_identity" ] || return 1 + [ "$(fm_pr_sha256 "$path")" = "$expected_hash" ] || return 1 + rm -f -- "$path" || return 1 + [ ! -e "$path" ] && [ ! -L "$path" ] +} + +fm_pr_poll_retirement_discard_obsolete() { + local state=$1 id=$2 template=$3 receipt state_device receipt_hash receipt_identity + local retired current + fm_pr_task_id_valid "$id" || return 1 + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + receipt="$state/$id.pr-poll-retirement" + fm_pr_private_file_valid "$receipt" 600 "$state_device" || return 1 + fm_pr_poll_retirement_parse "$receipt" || return 1 + [ "$FM_PR_RETIRE_ID" = "$id" ] || return 1 + retired=$(fm_pr_poll_retirement_fingerprint) + receipt_hash=$(fm_pr_sha256 "$receipt") || return 1 + receipt_identity=$(fm_pr_file_identity "$receipt") || return 1 + fm_pr_poll_snapshot_capture "$state" "$id" "$template" || return 1 + current=$(fm_pr_poll_snapshot_fingerprint) + [ "$current" != "$retired" ] || return 1 + fm_pr_poll_retirement_remove_exact "$receipt" 600 "$state_device" \ + "$receipt_identity" "$receipt_hash" +} + +fm_pr_poll_retirement_publish() { + local state=$1 id=$2 template=$3 result=$4 receipt state_device tmp + [ "$result" = merged ] || return 1 + fm_pr_poll_snapshot_matches "$state" "$id" "$template" || return 1 + state_device=$(fm_pr_file_device "$state") || return 1 + receipt="$state/$id.pr-poll-retirement" + fm_pr_regular_destination_on_device_or_absent "$receipt" "$state_device" || return 1 + [ ! -e "$receipt" ] && [ ! -L "$receipt" ] || return 1 + umask 077 + tmp=$(mktemp "$state/.fm-pr-poll-retirement.XXXXXX") || return 1 + if ! printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + fm-pr-poll-retirement-v1 \ + "$FM_PR_POLL_SNAPSHOT_ID" \ + "$FM_PR_POLL_SNAPSHOT_PROVIDER" \ + "$FM_PR_POLL_SNAPSHOT_URL" \ + "$FM_PR_POLL_SNAPSHOT_HOST" \ + "$FM_PR_POLL_SNAPSHOT_PROJECT" \ + "$FM_PR_POLL_SNAPSHOT_NUMBER" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_MODE" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_HASH" \ + "$FM_PR_POLL_SNAPSHOT_CHECK_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_DATA_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_DATA_HASH" \ + "$FM_PR_POLL_SNAPSHOT_DATA_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_REG_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_REG_HASH" \ + "$FM_PR_POLL_SNAPSHOT_REG_IDENTITY" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_PRESENCE" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_HASH" \ + "$FM_PR_POLL_SNAPSHOT_TRUST_IDENTITY" \ + merged > "$tmp" \ + || ! chmod 0600 "$tmp" \ + || ! fm_pr_private_file_valid "$tmp" 600 "$state_device" \ + || ! fm_pr_poll_retirement_parse "$tmp" \ + || [ "$FM_PR_RETIRE_ID" != "$id" ] \ + || ! fm_pr_poll_snapshot_matches "$state" "$id" "$template" \ + || ! fm_pr_regular_destination_on_device_or_absent "$receipt" "$state_device" \ + || [ -e "$receipt" ] || [ -L "$receipt" ] \ + || ! mv -f -- "$tmp" "$receipt"; then + rm -f -- "$tmp" + return 1 + fi + fm_pr_poll_retirement_receipt_valid "$state" "$id" || return 1 +} + +fm_pr_poll_retirement_recover_one() { + local state=$1 id=$2 template=$3 receipt state_device receipt_hash receipt_identity path + fm_pr_task_id_valid "$id" || return 1 + receipt="$state/$id.pr-poll-retirement" + if [ ! -e "$receipt" ] && [ ! -L "$receipt" ]; then + return 0 + fi + if ! fm_pr_poll_retirement_state_valid "$state" "$id"; then + fm_pr_poll_retirement_discard_obsolete "$state" "$id" "$template" && return 0 + return 1 + fi + state_device=$(fm_pr_file_device "$state") || return 1 + receipt_hash=$FM_PR_RETIRE_RECEIPT_HASH + receipt_identity=$FM_PR_RETIRE_RECEIPT_IDENTITY + path="$state/$id.check.sh" + if [ -e "$path" ] || [ -L "$path" ]; then + fm_pr_poll_retirement_remove_exact "$path" "$FM_PR_RETIRE_CHECK_MODE" "$state_device" \ + "$FM_PR_RETIRE_CHECK_IDENTITY" "$FM_PR_RETIRE_CHECK_HASH" || return 1 + fi + path="$state/$id.pr-poll-registration" + if [ "$FM_PR_RETIRE_REG_PRESENCE" = present ] && { [ -e "$path" ] || [ -L "$path" ]; }; then + fm_pr_poll_retirement_remove_exact "$path" 600 "$state_device" \ + "$FM_PR_RETIRE_REG_IDENTITY" "$FM_PR_RETIRE_REG_HASH" || return 1 + fi + path="$state/$id.pr-poll" + if [ "$FM_PR_RETIRE_DATA_PRESENCE" = present ] && { [ -e "$path" ] || [ -L "$path" ]; }; then + fm_pr_poll_retirement_remove_exact "$path" 600 "$state_device" \ + "$FM_PR_RETIRE_DATA_IDENTITY" "$FM_PR_RETIRE_DATA_HASH" || return 1 + fi + path="$state/$id.check-trust" + if [ "$FM_PR_RETIRE_TRUST_PRESENCE" = present ] && { [ -e "$path" ] || [ -L "$path" ]; }; then + fm_pr_poll_retirement_remove_exact "$path" 600 "$state_device" \ + "$FM_PR_RETIRE_TRUST_IDENTITY" "$FM_PR_RETIRE_TRUST_HASH" || return 1 + fi + fm_pr_poll_retirement_remove_exact "$receipt" 600 "$state_device" \ + "$receipt_identity" "$receipt_hash" || return 1 + [ ! -e "$state/$id.check.sh" ] && [ ! -L "$state/$id.check.sh" ] \ + && [ ! -e "$state/$id.pr-poll-registration" ] && [ ! -L "$state/$id.pr-poll-registration" ] \ + && [ ! -e "$state/$id.pr-poll" ] && [ ! -L "$state/$id.pr-poll" ] \ + && [ ! -e "$state/$id.check-trust" ] && [ ! -L "$state/$id.check-trust" ] \ + && [ ! -e "$receipt" ] && [ ! -L "$receipt" ] +} + +fm_pr_poll_retirement_recover_all() { + local state=$1 template=$2 receipt id + FM_PR_POLL_RETIREMENT_REJECTED= + for receipt in "$state"/*.pr-poll-retirement; do + [ -e "$receipt" ] || [ -L "$receipt" ] || continue + id=$(basename "$receipt" .pr-poll-retirement) + if ! fm_pr_task_id_valid "$id" \ + || ! fm_pr_poll_retirement_recover_one "$state" "$id" "$template"; then + FM_PR_POLL_RETIREMENT_REJECTED="$FM_PR_POLL_RETIREMENT_REJECTED $receipt" + fi + done + [ -z "$FM_PR_POLL_RETIREMENT_REJECTED" ] +} diff --git a/bin/fm-review-diff.sh b/bin/fm-review-diff.sh index 6f1c10cf481..33401e4915e 100755 --- a/bin/fm-review-diff.sh +++ b/bin/fm-review-diff.sh @@ -5,9 +5,10 @@ # helper compares remote-backed projects against origin/ after fetching # the default branch, and local-only projects against the local default branch. # When state/.meta records pr= for an open review, the compare side is the -# reviewed head (recorded pr_head= when reachable, else the forge's review ref) -# so review stays current after later fix rounds push to the review; if the head -# cannot be resolved, the script falls back to the local branch with a warning. +# freshly fetched provider review head. A reachable recorded pr_head= is an +# offline fallback only, and the local branch is the final warned fallback. +# Review refs live under refs/fm-review/ so later base fetches cannot clobber the +# comparison tip through FETCH_HEAD. # Usage: fm-review-diff.sh [--stat] # --stat prints only the stat summary; default prints stat summary plus full diff. set -eu @@ -74,34 +75,66 @@ if ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null; th git -C "$WT" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $WT" >&2; exit 1; } fi +fetch_review_head() { + local pr_url=$1 provider host project number origin review_ref private_ref resolved + fm_pr_url_parse "$pr_url" || return 2 + provider=$FM_PR_FORGE + host=$FM_PR_HOST + project=$FM_PR_PROJECT + number=$FM_PR_NUMBER + origin=$(git -C "$WT" config --get remote.origin.url 2>/dev/null) || return 1 + fm_forge_remote_parse "$origin" || return 2 + [ "$FM_FORGE_KIND" = "$provider" ] \ + && [ "$FM_FORGE_HOST" = "$host" ] \ + && [ "$FM_FORGE_PROJECT" = "$project" ] || return 2 + case "$provider" in + github) review_ref="refs/pull/$number/head" ;; + gitlab) review_ref="refs/merge-requests/$number/head" ;; + *) return 2 ;; + esac + private_ref="refs/fm-review/$provider/$number/head" + git -C "$WT" fetch --quiet origin "+$review_ref:$private_ref" >/dev/null 2>&1 || return 1 + resolved=$(git -C "$WT" rev-parse --verify "$private_ref^{commit}" 2>/dev/null) || return 1 + fm_pr_head_valid "$resolved" || return 1 + printf '%s' "$resolved" +} + resolve_pr_head() { - local pr_url=$1 recorded_head=$2 n resolved review_ref - if [ -n "$recorded_head" ] \ - && git -C "$WT" cat-file -e "$recorded_head^{commit}" 2>/dev/null; then - printf '%s' "$recorded_head" + local pr_url=$1 recorded_head=$2 resolved fetch_status + if resolved=$(fetch_review_head "$pr_url"); then + printf '%s' "$resolved" return 0 + else + fetch_status=$? fi - fm_pr_url_parse "$pr_url" || return 1 - n=$FM_PR_NUMBER - case "$FM_PR_FORGE" in - github) review_ref="refs/pull/$n/head" ;; - gitlab) review_ref="refs/merge-requests/$n/head" ;; - *) return 1 ;; - esac - git -C "$WT" remote get-url origin >/dev/null 2>&1 || return 1 - git -C "$WT" fetch --quiet origin "$review_ref" >/dev/null 2>&1 || return 1 - resolved=$(git -C "$WT" rev-parse --verify 'FETCH_HEAD^{commit}' 2>/dev/null) || return 1 - [ -n "$resolved" ] || return 1 - printf '%s' "$resolved" + [ "$fetch_status" -ne 2 ] || return 2 + if fm_pr_head_valid "$recorded_head" \ + && resolved=$(git -C "$WT" rev-parse --verify "$recorded_head^{commit}" 2>/dev/null) \ + && fm_pr_head_valid "$resolved"; then + printf '%s' "$resolved" + return 0 + fi + return 1 } PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) -PR_HEAD_RECORDED=$(grep '^pr_head=' "$META" | tail -1 | cut -d= -f2- || true) +PR_HEAD_RECORDED= COMPARE_REF=$BRANCH if [ -n "$PR_URL" ]; then + if ! fm_pr_metadata_identity_parse "$META"; then + echo "error: invalid review metadata for task $ID" >&2 + exit 1 + fi + PR_URL=$FM_PR_META_URL + PR_HEAD_RECORDED=$FM_PR_META_HEAD if PR_HEAD=$(resolve_pr_head "$PR_URL" "$PR_HEAD_RECORDED"); then COMPARE_REF=$PR_HEAD else + resolve_status=$? + if [ "$resolve_status" -eq 2 ]; then + echo "error: review URL does not match the trusted origin for task $ID" >&2 + exit 1 + fi echo "warning: review head unavailable; diff may lag the open review (using local branch $BRANCH)" >&2 fi fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index b2afe10d37a..bccafbf8621 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -29,10 +29,11 @@ # declared scratch and the report at data//report.md is the work # product. Teardown proceeds only once the report exists and the shared # unresolved-decision completion gate verifies its captain-held inventory. -# Before destructive cleanup, teardown validates task check artifacts and any -# matching quarantine entries as ordinary single-link files on the state -# device. It refuses and preserves task state when that proof fails; otherwise -# it removes the task's check, trust record, PR sidecar, publication record, and +# Before destructive cleanup, teardown finishes any exact pending merged-poll +# retirement, then validates task check artifacts and matching quarantine +# entries as ordinary single-link files on the state device. It refuses and +# preserves task state when that proof fails; otherwise it removes the task's +# check, trust record, PR sidecar, publication or retirement record, and # quarantine entries with the rest of the volatile state. # A state/.observer record is delegated to fm-no-mistakes-observer.sh # cleanup after landing and worktree safety checks but before any worker or @@ -207,6 +208,10 @@ remove_grok_turnend_auth() { validate_pr_poll_cleanup() { local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0 fm_task_id_path_safe "$id" || return 0 + if ! fm_pr_poll_retirement_recover_one "$state_dir" "$id" "$SCRIPT_DIR/fm-pr-poll.sh"; then + echo "REFUSED: unsafe pending review poll retirement; preserving task state." >&2 + return 1 + fi quarantine="$state_dir/.pr-check-quarantine" if [ "$id" = _noncanonical ] \ && { [ -e "$quarantine/_noncanonical.diagnostic.pending-noncanonical" ] \ @@ -217,7 +222,8 @@ validate_pr_poll_cleanup() { return 1 fi for artifact in "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ - "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust"; do + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust" \ + "$state_dir/$id.pr-poll-retirement"; do [ -e "$artifact" ] || [ -L "$artifact" ] || continue has_artifact=1 done @@ -228,7 +234,8 @@ validate_pr_poll_cleanup() { [ -d "$state_dir" ] && [ ! -L "$state_dir" ] || return 1 state_device=$(fm_pr_file_device "$state_dir") || return 1 for artifact in "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ - "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust"; do + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust" \ + "$state_dir/$id.pr-poll-retirement"; do [ -e "$artifact" ] || [ -L "$artifact" ] || continue if [ ! -f "$artifact" ] || [ -L "$artifact" ] \ || [ "$(fm_pr_file_device "$artifact")" != "$state_device" ] \ @@ -261,7 +268,8 @@ remove_pr_poll_artifacts() { local state_dir=$1 id=$2 quarantine artifact validate_pr_poll_cleanup "$state_dir" "$id" || return 1 rm -f "$state_dir/$id.check.sh" "$state_dir/$id.pr-poll" \ - "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust" || return 1 + "$state_dir/$id.pr-poll-registration" "$state_dir/$id.check-trust" \ + "$state_dir/$id.pr-poll-retirement" || return 1 if fm_task_id_path_safe "$id"; then quarantine="$state_dir/.pr-check-quarantine" if [ -d "$quarantine" ] && [ ! -L "$quarantine" ]; then diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 4d09ea6fe05..40c50c23022 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -34,6 +34,9 @@ # check: