123set -euo pipefail45FORMAT='goq-public-alpha-uat-v2'6EVIDENCE_SCHEMA='goq-public-alpha-evidence-v2'7MAX_SOURCE_BYTES=$((1024 * 1024))8DEFAULT_MAX_AGE_SECONDS=$((7 * 24 * 60 * 60))9FUTURE_SKEW_SECONDS=30010GITHUB_REPOSITORY='FelineStateMachine/goq'11SIGIL_ASSET_TARGET_CONTRACT='linux-glibc2.17-x86_64'12required_kinds=(cold-boot controller mouse soak network-direct network-relay reconnect second-client)13all_kinds=("${required_kinds[@]}" loopback-preflight)14script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"15repo_dir="$(CDPATH='' cd -- "$script_dir/.." && pwd -P)"16sigil_release_verifier="$script_dir/verify-sigil-release.sh"17portal_release_verifier="$script_dir/verify-portal-release.py"18portal_signature_verifier="$script_dir/verify-macos-portal-signature.sh"19sigil_public_key="$repo_dir/release/sigil-minisign.pub"20portal_apple_team_id_file="$repo_dir/release/portal-apple-team-id.txt"2122usage() {23 cat <<'EOF'24Usage:25 public-alpha-uat.sh init --evidence-dir DIR --release-tag vVERSION --sigil-archive FILE --sigil-bin FILE --portal-assets DIR [--max-age-seconds N]26 public-alpha-uat.sh record --evidence-dir DIR --kind KIND --file FILE27 public-alpha-uat.sh verify --evidence-dir DIR --sigil-archive FILE --sigil-bin FILE --portal-assets DIR2829The harness never runs hardware tests or invents attestations. `record` ingests30strict key=value evidence and `verify` succeeds only when every required gate is31present, current, bound to an exact signed release tag and verified release32assets, and internally valid. Hardware UAT initialization and verification must33run on macOS so Portal signature, notarization, and Gatekeeper checks can run.34EOF35}3637die() {38 printf 'public-alpha UAT: %s\n' "$*" >&239 exit 140}4142sha256_file() {43 local path="$1"44 if command -v sha256sum >/dev/null 2>&1; then45 sha256sum "$path" | awk '{print $1}'46 else47 shasum -a 256 "$path" | awk '{print $1}'48 fi49}5051file_mode() {52 local path="$1"53 if stat -f '%Lp' "$path" >/dev/null 2>&1; then54 stat -f '%Lp' "$path"55 else56 stat -c '%a' "$path"57 fi58}5960file_size() {61 local path="$1"62 if stat -f '%z' "$path" >/dev/null 2>&1; then63 stat -f '%z' "$path"64 else65 stat -c '%s' "$path"66 fi67}6869require_absolute_path() {70 [[ "$1" == /* ]] || die "path must be absolute: $1"71}7273require_secure_directory() {74 local path="$1"75 local mode76 [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"77 mode="$(file_mode "$path")"78 [[ "$mode" == 700 ]] || die "directory must have mode 0700: $path (mode $mode)"79}8081require_safe_regular_file() {82 local path="$1"83 local mode84 local permissions85 [[ -f "$path" && ! -L "$path" ]] || die "file is missing, non-regular, or a symlink: $path"86 mode="$(file_mode "$path")"87 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"88 permissions=$((8#$mode))89 (( (permissions & 8#022) == 0 )) || die "file is writable by group or others: $path"90}9192require_hash() {93 [[ "$1" =~ ^[0-9a-f]{64}$ ]] || die "invalid SHA256 value for $2"94}9596require_commit() {97 [[ "$1" =~ ^[0-9a-f]{40}$ ]] || die "invalid git commit in $2"98}99100require_release_tag() {101 [[ "$1" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]] \102 || die "release tag must be an exact vVERSION tag"103}104105require_safe_directory() {106 local path="$1"107 local mode108 local permissions109 [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"110 mode="$(file_mode "$path")"111 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"112 permissions=$((8#$mode))113 (( (permissions & 8#022) == 0 )) || die "directory is writable by group or others: $path"114}115116verified_commit=''117verified_portal_dmg=''118verified_portal_checksum=''119verified_portal_manifest=''120121github_api() {122 local endpoint="$1"123 command -v gh > /dev/null 2>&1 || die "GitHub CLI (gh) is required to verify published release assets"124 GH_PROMPT_DISABLED=1 gh api "$endpoint" \125 || die "GitHub API request failed for $endpoint"126}127128git_object_from_json() {129 PYTHONDONTWRITEBYTECODE=1 python3 -c '130import json131import re132import sys133134try:135 obj = json.load(sys.stdin)["object"]136 kind = obj["type"]137 sha = obj["sha"]138except (KeyError, TypeError, ValueError, json.JSONDecodeError):139 raise SystemExit(1)140if kind not in {"commit", "tag"} or not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha):141 raise SystemExit(1)142print(f"{kind}\t{sha}")143'144}145146resolve_published_tag_commit() {147 local release_tag="$1"148 local response149 local parsed150 local object_type151 local object_sha152 local depth=0153154 response="$(github_api "/repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag")"155 parsed="$(printf '%s' "$response" | git_object_from_json)" \156 || die "GitHub tag ref is malformed for $release_tag"157 IFS=$'\t' read -r object_type object_sha <<< "$parsed"158 while [[ "$object_type" == tag ]]; do159 ((depth += 1))160 (( depth <= 8 )) || die "GitHub annotated tag chain is too deep for $release_tag"161 response="$(github_api "/repos/$GITHUB_REPOSITORY/git/tags/$object_sha")"162 parsed="$(printf '%s' "$response" | git_object_from_json)" \163 || die "GitHub annotated tag object is malformed for $release_tag"164 IFS=$'\t' read -r object_type object_sha <<< "$parsed"165 done166 [[ "$object_type" == commit ]] || die "GitHub tag does not resolve to a commit: $release_tag"167 printf '%s\n' "$object_sha"168}169170verify_published_release() {171 local release_tag="$1"172 local expected_commit="$2"173 local archive="$3"174 local portal_dmg="$4"175 local portal_checksum="$5"176 local portal_manifest="$6"177 local remote_commit178 local release_json179180 remote_commit="$(resolve_published_tag_commit "$release_tag")"181 require_equal "$remote_commit" "$expected_commit" "published GitHub tag commit"182 release_json="$(github_api "/repos/$GITHUB_REPOSITORY/releases/tags/$release_tag")"183 PYTHONDONTWRITEBYTECODE=1 python3 - \184 "$GITHUB_REPOSITORY" "$release_tag" \185 "$archive" "$archive.sha256" "$archive.minisig" \186 "$portal_dmg" "$portal_checksum" "$portal_manifest" \187 3<<< "$release_json" <<'PY' \188 || die "supplied assets do not match the published GitHub prerelease"189import hashlib190import json191import os192import pathlib193import re194import sys195196repository = sys.argv[1]197release_tag = sys.argv[2]198paths = [pathlib.Path(value) for value in sys.argv[3:]]199try:200 with os.fdopen(3) as release_stream:201 release = json.load(release_stream)202except (TypeError, ValueError, json.JSONDecodeError):203 raise SystemExit(1)204205if release.get("tag_name") != release_tag:206 raise SystemExit(1)207if release.get("draft") is not False or release.get("prerelease") is not True:208 raise SystemExit(1)209if not isinstance(release.get("id"), int) or release["id"] <= 0:210 raise SystemExit(1)211if not isinstance(release.get("published_at"), str) or not release["published_at"]:212 raise SystemExit(1)213if release.get("html_url") != f"https://github.com/{repository}/releases/tag/{release_tag}":214 raise SystemExit(1)215216assets = release.get("assets")217if not isinstance(assets, list) or len(assets) != len(paths):218 raise SystemExit(1)219by_name = {}220for asset in assets:221 if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):222 raise SystemExit(1)223 if asset["name"] in by_name:224 raise SystemExit(1)225 by_name[asset["name"]] = asset226227expected_names = {path.name for path in paths}228if len(expected_names) != len(paths) or set(by_name) != expected_names:229 raise SystemExit(1)230for path in paths:231 asset = by_name[path.name]232 digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()233 if asset.get("state") != "uploaded" or asset.get("digest") != digest:234 raise SystemExit(1)235 if asset.get("size") != path.stat().st_size:236 raise SystemExit(1)237 if not isinstance(asset.get("id"), int) or asset["id"] <= 0:238 raise SystemExit(1)239 api_url = asset.get("url")240 if not isinstance(api_url, str) or not re.fullmatch(241 rf"https://api\.github\.com/repos/{re.escape(repository)}/releases/assets/[1-9][0-9]*",242 api_url,243 ):244 raise SystemExit(1)245PY246}247248verify_portal_attestations() {249 local release_tag="$1"250 local expected_commit="$2"251 shift 2252 local asset253254 for asset in "$@"; do255 GH_PROMPT_DISABLED=1 gh attestation verify "$asset" \256 --repo "$GITHUB_REPOSITORY" \257 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/portal-release.yml" \258 --source-ref "refs/tags/$release_tag" \259 --source-digest "$expected_commit" \260 --deny-self-hosted-runners > /dev/null \261 || die "Portal GitHub build-provenance attestation verification failed: $(basename -- "$asset")"262 done263}264265verify_release_inputs() {266 local release_tag="$1"267 local archive="$2"268 local sigil="$3"269 local portal_assets="$4"270 local version271 local head272 local worktree_status273 local expected_names274 local portal_apple_team_id275276 require_release_tag "$release_tag"277 require_absolute_path "$archive"278 require_absolute_path "$sigil"279 require_absolute_path "$portal_assets"280 require_safe_regular_file "$archive"281 require_safe_regular_file "$archive.sha256"282 require_safe_regular_file "$archive.minisig"283 require_safe_regular_file "$sigil"284 require_safe_directory "$portal_assets"285 require_safe_regular_file "$sigil_release_verifier"286 require_safe_regular_file "$portal_release_verifier"287 require_safe_regular_file "$portal_signature_verifier"288 require_safe_regular_file "$sigil_public_key"289 require_safe_regular_file "$portal_apple_team_id_file"290291 verified_commit="$(git -C "$repo_dir" rev-parse --verify "refs/tags/$release_tag^{commit}" 2>/dev/null)" \292 || die "release tag does not resolve through refs/tags/$release_tag"293 require_commit "$verified_commit" release294 head="$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)" || die "repository HEAD is unavailable"295 require_equal "$head" "$verified_commit" "release tag commit"296 worktree_status="$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all)" \297 || die "release worktree status is unavailable"298 [[ -z "$worktree_status" ]] || die "hardware UAT must run from the clean exact release tag"299300 version="${release_tag#v}"301 verified_portal_dmg="$portal_assets/Portal-$version-arm64.dmg"302 verified_portal_checksum="$verified_portal_dmg.sha256"303 verified_portal_manifest="$portal_assets/Portal-$version-arm64.json"304 expected_names="$(305 find "$portal_assets" -mindepth 1 -maxdepth 1 -print \306 | sed 's#.*/##' \307 | LC_ALL=C sort308 )"309 require_equal "$expected_names" "$(printf '%s\n' \310 "Portal-$version-arm64.dmg" \311 "Portal-$version-arm64.dmg.sha256" \312 "Portal-$version-arm64.json" | LC_ALL=C sort)" "Portal asset set"313 require_safe_regular_file "$verified_portal_dmg"314 require_safe_regular_file "$verified_portal_checksum"315 require_safe_regular_file "$verified_portal_manifest"316317 "$sigil_release_verifier" \318 --tag "$release_tag" \319 --archive "$archive" \320 --source-commit "$verified_commit" \321 --public-key-file "$sigil_public_key" > /dev/null \322 || die "signed Sigil release verification failed"323324 python3 - "$archive" "$sigil" <<'PY' || die "supplied Sigil executable does not equal payload/release/sigil"325import pathlib326import sys327import tarfile328329archive_path = pathlib.Path(sys.argv[1])330sigil_path = pathlib.Path(sys.argv[2])331try:332 with tarfile.open(archive_path, "r:gz") as archive:333 member = archive.getmember("payload/release/sigil")334 if not member.isreg():335 raise ValueError("Sigil payload is not regular")336 stream = archive.extractfile(member)337 if stream is None or stream.read() != sigil_path.read_bytes():338 raise ValueError("Sigil payload differs")339except (KeyError, OSError, tarfile.TarError, ValueError):340 raise SystemExit(1)341PY342343 PYTHONDONTWRITEBYTECODE=1 python3 "$portal_release_verifier" assets \344 --repo-dir "$repo_dir" \345 --release-tag "$release_tag" \346 --asset-dir "$portal_assets" > /dev/null \347 || die "Portal release asset verification failed"348349 portal_apple_team_id="$(awk 'NF { count += 1; value = $0 } END { if (count != 1) exit 1; print value }' \350 "$portal_apple_team_id_file")" \351 || die "committed Portal Apple TeamIdentifier pin must contain exactly one non-empty line"352 [[ "$portal_apple_team_id" =~ ^[A-Z0-9]{10}$ ]] \353 || die "committed Portal Apple TeamIdentifier pin is invalid"354355 [[ "$(uname -s)" == Darwin ]] || die "hardware UAT release verification must run on macOS"356 bash "$portal_signature_verifier" \357 --dmg "$verified_portal_dmg" \358 --expected-version "$version" \359 --expected-team-id "$portal_apple_team_id" > /dev/null \360 || die "Portal macOS signature verification failed"361362 verify_published_release \363 "$release_tag" \364 "$verified_commit" \365 "$archive" \366 "$verified_portal_dmg" \367 "$verified_portal_checksum" \368 "$verified_portal_manifest"369 verify_portal_attestations \370 "$release_tag" \371 "$verified_commit" \372 "$verified_portal_dmg" \373 "$verified_portal_checksum" \374 "$verified_portal_manifest"375}376377manifest_get() {378 local manifest="$1"379 local key="$2"380 awk -F '\t' -v wanted="$key" '381 $1 == wanted { count += 1; value = $2 }382 END { if (count != 1) exit 1; print value }383 ' "$manifest" || die "manifest key is missing or duplicated: $key"384}385386source_get() {387 local source="$1"388 local key="$2"389 awk -v wanted="$key" '390 index($0, wanted "=") == 1 {391 count += 1392 value = substr($0, length(wanted) + 2)393 }394 END { if (count != 1 || value == "") exit 1; print value }395 ' "$source" || die "evidence key is missing, empty, or duplicated: $key"396}397398kind_allowed() {399 local wanted="$1"400 local candidate401 for candidate in "${all_kinds[@]}"; do402 [[ "$candidate" == "$wanted" ]] && return 0403 done404 return 1405}406407common_keys() {408 printf '%s\n' uat_schema evidence_kind observed_at_unix git_commit release_tag sigil_sha256 portal_sha256409}410411kind_keys() {412 case "$1" in413 cold-boot)414 printf '%s\n' cold_boot_result cold_boot_failure_count cold_boot_insufficient_count \415 headless_connector_state gaming_autologin_session sigil_host_enabled sigil_host_active \416 gamescope_pipewire_node gamescope_before_first_ssh sigil_unit_before_first_ssh \417 sigil_ready_before_first_ssh418 ;;419 controller)420 printf '%s\n' physical_controller_attached_to_portal actual_game_controlled \421 controller_coverage neutral_release_on_disconnect neutral_buttons neutral_axes session_seconds422 ;;423 mouse)424 printf '%s\n' target_application left_click_consumed right_click_consumed \425 consumption_observed_in_target click_attempts426 ;;427 soak)428 printf '%s\n' duration_seconds samples capture_fps_p50 presentation_fps_p50 \429 frame_interval_p95_ms hitch_p99_ms video_queue_p95_frames decode_queue_p95_frames \430 audio_queue_p95_ms av_skew_p95_ms max_queue_age_p95_ms cpu_p95_percent \431 gpu_p95_percent rss_p95_mib transport_drops frontend_drops audio_drops \432 latency_first_window_p95_ms latency_last_window_p95_ms disconnects433 ;;434 network-direct|network-relay)435 printf '%s\n' path_mode nat_scenario session_seconds rtt_p50_ms rtt_p95_ms \436 input_ack_p95_ms presentation_latency_p95_ms packet_loss_percent437 ;;438 reconnect)439 printf '%s\n' reconnect_cycles reconnect_successes reconnect_failures state_preserved \440 keyframe_recovery_p95_ms441 ;;442 second-client)443 printf '%s\n' second_client_attempts second_client_rejections \444 authorized_primary_uninterrupted rejection_reason445 ;;446 loopback-preflight)447 printf '%s\n' loopback_proof profile host_sha256 active_client_rejection reconnect_cycles cleanup448 ;;449 *) return 1 ;;450 esac451}452453require_number() {454 [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "$2 must be a non-negative decimal number"455}456457number_ge() {458 awk -v value="$1" -v minimum="$2" 'BEGIN { exit !(value >= minimum) }'459}460461number_le() {462 awk -v value="$1" -v maximum="$2" 'BEGIN { exit !(value <= maximum) }'463}464465require_equal() {466 [[ "$1" == "$2" ]] || die "$3 must be $2 (got $1)"467}468469validate_common_evidence() {470 local source="$1"471 local expected_kind="$2"472 local manifest="$3"473 local observed474 local now475 local max_age476 local value477478 require_equal "$(source_get "$source" uat_schema)" "$EVIDENCE_SCHEMA" uat_schema479 require_equal "$(source_get "$source" evidence_kind)" "$expected_kind" evidence_kind480 value="$(source_get "$source" git_commit)"481 require_commit "$value" evidence482 require_equal "$value" "$(manifest_get "$manifest" git_commit)" git_commit483 value="$(source_get "$source" release_tag)"484 require_release_tag "$value"485 require_equal "$value" "$(manifest_get "$manifest" release_tag)" release_tag486 value="$(source_get "$source" sigil_sha256)"487 require_hash "$value" sigil_sha256488 require_equal "$value" "$(manifest_get "$manifest" sigil_sha256)" sigil_sha256489 value="$(source_get "$source" portal_sha256)"490 require_hash "$value" portal_sha256491 require_equal "$value" "$(manifest_get "$manifest" portal_sha256)" portal_sha256492493 observed="$(source_get "$source" observed_at_unix)"494 [[ "$observed" =~ ^[0-9]{10}$ ]] || die "observed_at_unix must be a ten-digit Unix timestamp"495 now="$(date +%s)"496 max_age="$(manifest_get "$manifest" max_age_seconds)"497 (( observed <= now + FUTURE_SKEW_SECONDS )) || die "evidence timestamp is in the future"498 (( now - observed <= max_age )) || die "evidence is stale"499}500501validate_cold_boot() {502 local source="$1"503 require_equal "$(source_get "$source" cold_boot_result)" pass cold_boot_result504 require_equal "$(source_get "$source" cold_boot_failure_count)" 0 cold_boot_failure_count505 require_equal "$(source_get "$source" cold_boot_insufficient_count)" 0 cold_boot_insufficient_count506 require_equal "$(source_get "$source" headless_connector_state)" ok headless_connector_state507 require_equal "$(source_get "$source" gaming_autologin_session)" ok gaming_autologin_session508 require_equal "$(source_get "$source" sigil_host_enabled)" enabled sigil_host_enabled509 require_equal "$(source_get "$source" sigil_host_active)" active sigil_host_active510 require_equal "$(source_get "$source" gamescope_pipewire_node)" ok gamescope_pipewire_node511 require_equal "$(source_get "$source" gamescope_before_first_ssh)" ok gamescope_before_first_ssh512 require_equal "$(source_get "$source" sigil_unit_before_first_ssh)" ok sigil_unit_before_first_ssh513 require_equal "$(source_get "$source" sigil_ready_before_first_ssh)" ok sigil_ready_before_first_ssh514}515516validate_controller() {517 local source="$1"518 local seconds519 require_equal "$(source_get "$source" physical_controller_attached_to_portal)" pass physical_controller_attached_to_portal520 require_equal "$(source_get "$source" actual_game_controlled)" pass actual_game_controlled521 require_equal "$(source_get "$source" controller_coverage)" abxy,dpad,sticks,triggers,shoulders,start-back controller_coverage522 require_equal "$(source_get "$source" neutral_release_on_disconnect)" pass neutral_release_on_disconnect523 require_equal "$(source_get "$source" neutral_buttons)" pass neutral_buttons524 require_equal "$(source_get "$source" neutral_axes)" pass neutral_axes525 seconds="$(source_get "$source" session_seconds)"; require_number "$seconds" session_seconds526 number_ge "$seconds" 300 || die "controller session must last at least 300 seconds"527}528529validate_mouse() {530 local source="$1"531 local target532 local attempts533 target="$(source_get "$source" target_application)"534 [[ "$target" =~ ^[A-Za-z0-9._+-]{1,64}$ ]] || die "target_application must be a bounded slug"535 require_equal "$(source_get "$source" left_click_consumed)" pass left_click_consumed536 require_equal "$(source_get "$source" right_click_consumed)" pass right_click_consumed537 require_equal "$(source_get "$source" consumption_observed_in_target)" pass consumption_observed_in_target538 attempts="$(source_get "$source" click_attempts)"; require_number "$attempts" click_attempts539 number_ge "$attempts" 5 || die "mouse evidence requires at least five click attempts"540}541542validate_soak() {543 local source="$1"544 local key545 local value546 for key in $(kind_keys soak); do547 value="$(source_get "$source" "$key")"548 require_number "$value" "$key"549 done550 number_ge "$(source_get "$source" duration_seconds)" 3600 || die "soak must last at least 3600 seconds"551 number_ge "$(source_get "$source" samples)" 60 || die "soak requires at least 60 samples"552 number_ge "$(source_get "$source" capture_fps_p50)" 55 || die "capture_fps_p50 is below 55"553 number_ge "$(source_get "$source" presentation_fps_p50)" 55 || die "presentation_fps_p50 is below 55"554 number_le "$(source_get "$source" frame_interval_p95_ms)" 25 || die "frame_interval_p95_ms exceeds 25"555 number_le "$(source_get "$source" hitch_p99_ms)" 50 || die "hitch_p99_ms exceeds 50"556 number_le "$(source_get "$source" video_queue_p95_frames)" 2 || die "video queue exceeds two frames"557 number_le "$(source_get "$source" decode_queue_p95_frames)" 2 || die "decode queue exceeds two frames"558 number_le "$(source_get "$source" audio_queue_p95_ms)" 100 || die "audio queue exceeds 100 ms"559 number_le "$(source_get "$source" av_skew_p95_ms)" 50 || die "A/V skew exceeds 50 ms"560 number_le "$(source_get "$source" max_queue_age_p95_ms)" 100 || die "queue age exceeds 100 ms"561 number_le "$(source_get "$source" cpu_p95_percent)" 90 || die "CPU p95 exceeds 90 percent"562 number_le "$(source_get "$source" gpu_p95_percent)" 95 || die "GPU p95 exceeds 95 percent"563 number_le "$(source_get "$source" rss_p95_mib)" 2048 || die "RSS p95 exceeds 2048 MiB"564 require_equal "$(source_get "$source" disconnects)" 0 disconnects565 awk -v first="$(source_get "$source" latency_first_window_p95_ms)" \566 -v last="$(source_get "$source" latency_last_window_p95_ms)" \567 'BEGIN { growth = last - first; exit !(last <= first * 1.20 && growth <= 5.0) }' || \568 die "latency grew by more than both bounded thresholds"569}570571validate_network() {572 local source="$1"573 local kind="$2"574 local expected_mode=direct575 local expected_nat=ordinary576 local key577 if [[ "$kind" == network-relay ]]; then578 expected_mode=relay579 expected_nat=difficult580 fi581 require_equal "$(source_get "$source" path_mode)" "$expected_mode" path_mode582 require_equal "$(source_get "$source" nat_scenario)" "$expected_nat" nat_scenario583 for key in session_seconds rtt_p50_ms rtt_p95_ms input_ack_p95_ms presentation_latency_p95_ms packet_loss_percent; do584 require_number "$(source_get "$source" "$key")" "$key"585 done586 number_ge "$(source_get "$source" session_seconds)" 600 || die "$kind session must last at least 600 seconds"587 number_le "$(source_get "$source" packet_loss_percent)" 5 || die "$kind packet loss exceeds 5 percent"588}589590validate_reconnect() {591 local source="$1"592 local cycles successes failures recovery593 cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles594 successes="$(source_get "$source" reconnect_successes)"; require_number "$successes" reconnect_successes595 failures="$(source_get "$source" reconnect_failures)"; require_number "$failures" reconnect_failures596 recovery="$(source_get "$source" keyframe_recovery_p95_ms)"; require_number "$recovery" keyframe_recovery_p95_ms597 number_ge "$cycles" 10 || die "reconnect evidence requires at least ten cycles"598 require_equal "$successes" "$cycles" reconnect_successes599 require_equal "$failures" 0 reconnect_failures600 require_equal "$(source_get "$source" state_preserved)" pass state_preserved601 number_le "$recovery" 2000 || die "keyframe recovery p95 exceeds 2000 ms"602}603604validate_second_client() {605 local source="$1"606 local attempts rejections607 attempts="$(source_get "$source" second_client_attempts)"; require_number "$attempts" second_client_attempts608 rejections="$(source_get "$source" second_client_rejections)"; require_number "$rejections" second_client_rejections609 number_ge "$attempts" 3 || die "second-client evidence requires at least three attempts"610 require_equal "$rejections" "$attempts" second_client_rejections611 require_equal "$(source_get "$source" authorized_primary_uninterrupted)" pass authorized_primary_uninterrupted612 require_equal "$(source_get "$source" rejection_reason)" active-client rejection_reason613}614615validate_loopback() {616 local source="$1"617 local cycles618 require_equal "$(source_get "$source" loopback_proof)" ok loopback_proof619 require_equal "$(source_get "$source" profile)" release profile620 require_equal "$(source_get "$source" host_sha256)" "$(source_get "$source" sigil_sha256)" host_sha256621 require_equal "$(source_get "$source" active_client_rejection)" ok active_client_rejection622 require_equal "$(source_get "$source" cleanup)" ok cleanup623 cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles624 number_ge "$cycles" 3 || die "loopback preflight requires at least three reconnect cycles"625}626627validate_kind() {628 case "$2" in629 cold-boot) validate_cold_boot "$1" ;;630 controller) validate_controller "$1" ;;631 mouse) validate_mouse "$1" ;;632 soak) validate_soak "$1" ;;633 network-direct|network-relay) validate_network "$1" "$2" ;;634 reconnect) validate_reconnect "$1" ;;635 second-client) validate_second_client "$1" ;;636 loopback-preflight) validate_loopback "$1" ;;637 *) die "unsupported evidence kind: $2" ;;638 esac639}640641reject_sensitive_source() {642 local source="$1"643 if LC_ALL=C grep -Eiq '(^|_)(node_id|host_node_id|peer_node_id|secret|secret_key|identity_seed|private_key)=|goq-invite-v1[.]|BEGIN [A-Z ]*PRIVATE KEY' "$source"; then644 die "evidence contains a node ID, invitation, or secret-bearing field"645 fi646}647648validate_manifest() {649 local directory="$1"650 local manifest="$directory/manifest.tsv"651 local sigil_archive_name652 local key653 local record_kind654 local record_field655 require_secure_directory "$directory"656 require_secure_directory "$directory/records"657 require_safe_regular_file "$manifest"658 while IFS=$'\t' read -r key _; do659 case "$key" in660 format|created_at_unix|release_tag|git_commit|sigil_archive_name|sigil_archive_sha256|sigil_checksum_sha256|sigil_signature_sha256|sigil_public_key_sha256|sigil_sha256|portal_dmg_name|portal_sha256|portal_checksum_sha256|portal_manifest_sha256|max_age_seconds) ;;661 *)662 if [[ "$key" =~ ^record\.([a-z-]+)\.(file|sha256|observed_at_unix)$ ]]; then663 record_kind="${BASH_REMATCH[1]}"664 record_field="${BASH_REMATCH[2]}"665 kind_allowed "$record_kind" || die "manifest contains an unknown evidence kind: $record_kind"666 [[ -n "$record_field" ]] || die "invalid manifest record field: $key"667 else668 die "unexpected manifest key: $key"669 fi670 ;;671 esac672 done < "$manifest"673 require_equal "$(manifest_get "$manifest" format)" "$FORMAT" format674 require_commit "$(manifest_get "$manifest" git_commit)" manifest675 require_release_tag "$(manifest_get "$manifest" release_tag)"676 sigil_archive_name="$(manifest_get "$manifest" sigil_archive_name)"677 [[ "$sigil_archive_name" =~ ^sigil-v[0-9A-Za-z.-]+-[0-9A-Za-z._-]+[.]tar[.]gz$ \678 && "$sigil_archive_name" == sigil-v*-"$SIGIL_ASSET_TARGET_CONTRACT".tar.gz ]] \679 || die "invalid manifest Sigil archive name"680 [[ "$(manifest_get "$manifest" portal_dmg_name)" =~ ^Portal-[0-9A-Za-z.-]+-arm64\.dmg$ ]] \681 || die "invalid manifest Portal DMG name"682 require_hash "$(manifest_get "$manifest" sigil_archive_sha256)" sigil_archive_sha256683 require_hash "$(manifest_get "$manifest" sigil_checksum_sha256)" sigil_checksum_sha256684 require_hash "$(manifest_get "$manifest" sigil_signature_sha256)" sigil_signature_sha256685 require_hash "$(manifest_get "$manifest" sigil_public_key_sha256)" sigil_public_key_sha256686 require_hash "$(manifest_get "$manifest" sigil_sha256)" sigil_sha256687 require_hash "$(manifest_get "$manifest" portal_sha256)" portal_sha256688 require_hash "$(manifest_get "$manifest" portal_checksum_sha256)" portal_checksum_sha256689 require_hash "$(manifest_get "$manifest" portal_manifest_sha256)" portal_manifest_sha256690 [[ "$(manifest_get "$manifest" created_at_unix)" =~ ^[0-9]{10}$ ]] || die "invalid manifest created_at_unix"691 local max_age692 max_age="$(manifest_get "$manifest" max_age_seconds)"693 if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then694 die "invalid manifest max_age_seconds"695 fi696}697698atomic_manifest_record() {699 local directory="$1"700 local kind="$2"701 local record_sha="$3"702 local observed="$4"703 local manifest="$directory/manifest.tsv"704 local temporary705 temporary="$(mktemp "$directory/.manifest.XXXXXX")"706 chmod 600 "$temporary"707 awk -F '\t' -v prefix="record.$kind." 'index($1, prefix) != 1 { print }' "$manifest" > "$temporary"708 {709 printf 'record.%s.file\trecords/%s.evidence\n' "$kind" "$kind"710 printf 'record.%s.sha256\t%s\n' "$kind" "$record_sha"711 printf 'record.%s.observed_at_unix\t%s\n' "$kind" "$observed"712 } >> "$temporary"713 mv -f "$temporary" "$manifest"714}715716command_init() {717 local directory=''718 local release_tag=''719 local archive=''720 local sigil=''721 local portal_assets=''722 local max_age="$DEFAULT_MAX_AGE_SECONDS"723 local parent724 while (($#)); do725 case "$1" in726 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;727 --release-tag) [[ $# -ge 2 ]] || die "$1 requires a value"; release_tag="$2"; shift 2 ;;728 --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;729 --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;730 --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;731 --max-age-seconds) [[ $# -ge 2 ]] || die "$1 requires a value"; max_age="$2"; shift 2 ;;732 *) die "unknown init argument: $1" ;;733 esac734 done735 [[ -n "$directory" && -n "$release_tag" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \736 || die "init requires evidence dir, release tag, Sigil archive/executable, and Portal asset directory"737 require_absolute_path "$directory"738 if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then739 die "max age must be 3600..2592000 seconds"740 fi741 [[ ! -e "$directory" && ! -L "$directory" ]] || die "evidence directory already exists"742 parent="$(dirname "$directory")"; [[ -d "$parent" && ! -L "$parent" ]] || die "evidence parent is missing or a symlink"743 verify_release_inputs "$release_tag" "$archive" "$sigil" "$portal_assets"744 mkdir -m 700 "$directory"745 mkdir -m 700 "$directory/records"746 umask 077747 {748 printf 'format\t%s\n' "$FORMAT"749 printf 'created_at_unix\t%s\n' "$(date +%s)"750 printf 'release_tag\t%s\n' "$release_tag"751 printf 'git_commit\t%s\n' "$verified_commit"752 printf 'sigil_archive_name\t%s\n' "$(basename -- "$archive")"753 printf 'sigil_archive_sha256\t%s\n' "$(sha256_file "$archive")"754 printf 'sigil_checksum_sha256\t%s\n' "$(sha256_file "$archive.sha256")"755 printf 'sigil_signature_sha256\t%s\n' "$(sha256_file "$archive.minisig")"756 printf 'sigil_public_key_sha256\t%s\n' "$(sha256_file "$sigil_public_key")"757 printf 'sigil_sha256\t%s\n' "$(sha256_file "$sigil")"758 printf 'portal_dmg_name\t%s\n' "$(basename -- "$verified_portal_dmg")"759 printf 'portal_sha256\t%s\n' "$(sha256_file "$verified_portal_dmg")"760 printf 'portal_checksum_sha256\t%s\n' "$(sha256_file "$verified_portal_checksum")"761 printf 'portal_manifest_sha256\t%s\n' "$(sha256_file "$verified_portal_manifest")"762 printf 'max_age_seconds\t%s\n' "$max_age"763 } > "$directory/manifest.tsv"764 chmod 600 "$directory/manifest.tsv"765 printf 'public_alpha_uat=initialized\nevidence_dir=%s\nrelease_tag=%s\ngit_commit=%s\n' \766 "$directory" "$release_tag" "$verified_commit"767 printf 'sigil_archive_sha256=%s\nsigil_sha256=%s\nportal_sha256=%s\n' \768 "$(sha256_file "$archive")" "$(sha256_file "$sigil")" "$(sha256_file "$verified_portal_dmg")"769}770771command_record() {772 local directory=''773 local kind=''774 local source=''775 local manifest776 local record777 local temporary778 local key779 local observed780 local size781 while (($#)); do782 case "$1" in783 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;784 --kind) [[ $# -ge 2 ]] || die "$1 requires a value"; kind="$2"; shift 2 ;;785 --file) [[ $# -ge 2 ]] || die "$1 requires a value"; source="$2"; shift 2 ;;786 *) die "unknown record argument: $1" ;;787 esac788 done789 [[ -n "$directory" && -n "$kind" && -n "$source" ]] || die "record requires evidence dir, kind, and file"790 require_absolute_path "$directory"; require_absolute_path "$source"791 kind_allowed "$kind" || die "unknown evidence kind: $kind"792 validate_manifest "$directory"793 manifest="$directory/manifest.tsv"794 require_safe_regular_file "$source"795 size="$(file_size "$source")"; [[ "$size" =~ ^[0-9]+$ ]] || die "could not determine evidence size"796 (( size > 0 && size <= MAX_SOURCE_BYTES )) || die "evidence source is empty or exceeds $MAX_SOURCE_BYTES bytes"797 reject_sensitive_source "$source"798 validate_common_evidence "$source" "$kind" "$manifest"799 validate_kind "$source" "$kind"800 [[ ! -e "$directory/records/$kind.evidence" ]] || die "evidence kind is already recorded: $kind"801802 temporary="$(mktemp "$directory/records/.$kind.XXXXXX")"803 chmod 600 "$temporary"804 while IFS= read -r key; do805 printf '%s=%s\n' "$key" "$(source_get "$source" "$key")" >> "$temporary"806 done < <({ common_keys; kind_keys "$kind"; } | awk '!seen[$0]++')807 record="$directory/records/$kind.evidence"808 mv "$temporary" "$record"809 observed="$(source_get "$record" observed_at_unix)"810 atomic_manifest_record "$directory" "$kind" "$(sha256_file "$record")" "$observed"811 printf 'public_alpha_uat=recorded\nevidence_kind=%s\nrecord_sha256=%s\n' "$kind" "$(sha256_file "$record")"812}813814command_verify() {815 local directory=''816 local archive=''817 local sigil=''818 local portal_assets=''819 local manifest820 local kind821 local record822 local expected823 local actual824 local file825 local filename826 local found_kind827 while (($#)); do828 case "$1" in829 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;830 --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;831 --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;832 --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;833 *) die "unknown verify argument: $1" ;;834 esac835 done836 [[ -n "$directory" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \837 || die "verify requires evidence dir, Sigil archive/executable, and Portal asset directory"838 require_absolute_path "$directory"839 validate_manifest "$directory"; manifest="$directory/manifest.tsv"840 verify_release_inputs "$(manifest_get "$manifest" release_tag)" "$archive" "$sigil" "$portal_assets"841 require_equal "$verified_commit" "$(manifest_get "$manifest" git_commit)" "release tag commit"842 require_equal "$(basename -- "$archive")" "$(manifest_get "$manifest" sigil_archive_name)" "Sigil archive name"843 require_equal "$(sha256_file "$archive")" "$(manifest_get "$manifest" sigil_archive_sha256)" "Sigil archive hash"844 require_equal "$(sha256_file "$archive.sha256")" "$(manifest_get "$manifest" sigil_checksum_sha256)" "Sigil checksum asset hash"845 require_equal "$(sha256_file "$archive.minisig")" "$(manifest_get "$manifest" sigil_signature_sha256)" "Sigil signature asset hash"846 require_equal "$(sha256_file "$sigil_public_key")" "$(manifest_get "$manifest" sigil_public_key_sha256)" "Sigil public key hash"847 require_equal "$(sha256_file "$sigil")" "$(manifest_get "$manifest" sigil_sha256)" "Sigil release-candidate hash"848 require_equal "$(basename -- "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_dmg_name)" "Portal DMG name"849 require_equal "$(sha256_file "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_sha256)" "Portal release-candidate hash"850 require_equal "$(sha256_file "$verified_portal_checksum")" "$(manifest_get "$manifest" portal_checksum_sha256)" "Portal checksum asset hash"851 require_equal "$(sha256_file "$verified_portal_manifest")" "$(manifest_get "$manifest" portal_manifest_sha256)" "Portal manifest asset hash"852853 while IFS= read -r file; do854 case "$file" in855 "$directory/manifest.tsv") [[ -f "$file" ]] || die "manifest is not a regular file" ;;856 "$directory/records") [[ -d "$file" ]] || die "records path is not a directory" ;;857 "$directory/records/"*.evidence)858 [[ -f "$file" ]] || die "evidence record is not a regular file: $file"859 filename="${file##*/}"860 found_kind="${filename%.evidence}"861 kind_allowed "$found_kind" || die "unexpected evidence kind in bundle: $found_kind"862 ;;863 *) die "unexpected file or directory in evidence bundle: $file" ;;864 esac865 [[ ! -L "$file" ]] || die "evidence bundle contains a symlink: $file"866 done < <(find "$directory" -mindepth 1 -maxdepth 2 -print)867868 for kind in "${required_kinds[@]}"; do869 record="$directory/records/$kind.evidence"870 require_safe_regular_file "$record"871 expected="$(manifest_get "$manifest" "record.$kind.sha256")"872 actual="$(sha256_file "$record")"873 require_equal "$actual" "$expected" "$kind record hash"874 require_equal "$(manifest_get "$manifest" "record.$kind.file")" "records/$kind.evidence" "$kind record path"875 require_equal "$(manifest_get "$manifest" "record.$kind.observed_at_unix")" \876 "$(source_get "$record" observed_at_unix)" "$kind record timestamp"877 validate_common_evidence "$record" "$kind" "$manifest"878 validate_kind "$record" "$kind"879 done880 if grep -q '^record\.loopback-preflight\.' "$manifest" && \881 [[ ! -e "$directory/records/loopback-preflight.evidence" ]]; then882 die "loopback preflight manifest metadata exists without its record"883 fi884 if [[ -e "$directory/records/loopback-preflight.evidence" ]]; then885 record="$directory/records/loopback-preflight.evidence"886 require_safe_regular_file "$record"887 require_equal "$(sha256_file "$record")" \888 "$(manifest_get "$manifest" record.loopback-preflight.sha256)" "loopback preflight record hash"889 require_equal "$(manifest_get "$manifest" record.loopback-preflight.file)" \890 "records/loopback-preflight.evidence" "loopback preflight record path"891 require_equal "$(manifest_get "$manifest" record.loopback-preflight.observed_at_unix)" \892 "$(source_get "$record" observed_at_unix)" "loopback preflight record timestamp"893 validate_common_evidence "$record" loopback-preflight "$manifest"894 validate_loopback "$record"895 fi896897 printf 'public_alpha_uat=pass\ngit_commit=%s\nrelease_tag=%s\n' \898 "$(manifest_get "$manifest" git_commit)" "$(manifest_get "$manifest" release_tag)"899 printf 'sigil_sha256=%s\nportal_sha256=%s\nrequired_gates=%s\n' \900 "$(manifest_get "$manifest" sigil_sha256)" "$(manifest_get "$manifest" portal_sha256)" "${#required_kinds[@]}"901}902903main() {904 local command="${1:-}"905 [[ -n "$command" ]] || { usage; exit 2; }906 shift907 case "$command" in908 init) command_init "$@" ;;909 record) command_record "$@" ;;910 verify) command_verify "$@" ;;911 --help|-h|help) usage ;;912 *) usage >&2; die "unknown command: $command" ;;913 esac914}915916main "$@"