cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/portal-dist-prebuild
goq / scripts / public-alpha-uat.sh
public-alpha-uat.sh 41.2K executable
1#!/usr/bin/env bash23set -euo pipefail45FORMAT='goq-public-alpha-uat-v3'6EVIDENCE_SCHEMA='goq-public-alpha-evidence-v3'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 multi-viewer 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"2021usage() {22 cat <<'EOF'23Usage:24 public-alpha-uat.sh init --evidence-dir DIR --release-tag vVERSION --sigil-archive FILE --sigil-bin FILE --portal-assets DIR [--max-age-seconds N]25 public-alpha-uat.sh record --evidence-dir DIR --kind KIND --file FILE26 public-alpha-uat.sh verify --evidence-dir DIR --sigil-archive FILE --sigil-bin FILE --portal-assets DIR2728The harness never runs hardware tests or invents attestations. `record` ingests29strict key=value evidence and `verify` succeeds only when every required gate is30present, current, bound to an exact signed release tag and verified release31assets, and internally valid. Hardware UAT initialization and verification must32run on macOS so the Portal ad-hoc signature and bundle checks can run.33EOF34}3536die() {37 printf 'public-alpha UAT: %s\n' "$*" >&238 exit 139}4041sha256_file() {42 local path="$1"43 if command -v sha256sum >/dev/null 2>&1; then44 sha256sum "$path" | awk '{print $1}'45 else46 shasum -a 256 "$path" | awk '{print $1}'47 fi48}4950file_mode() {51 local path="$1"52 if stat -f '%Lp' "$path" >/dev/null 2>&1; then53 stat -f '%Lp' "$path"54 else55 stat -c '%a' "$path"56 fi57}5859file_size() {60 local path="$1"61 if stat -f '%z' "$path" >/dev/null 2>&1; then62 stat -f '%z' "$path"63 else64 stat -c '%s' "$path"65 fi66}6768require_absolute_path() {69 [[ "$1" == /* ]] || die "path must be absolute: $1"70}7172require_secure_directory() {73 local path="$1"74 local mode75 [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"76 mode="$(file_mode "$path")"77 [[ "$mode" == 700 ]] || die "directory must have mode 0700: $path (mode $mode)"78}7980require_safe_regular_file() {81 local path="$1"82 local mode83 local permissions84 [[ -f "$path" && ! -L "$path" ]] || die "file is missing, non-regular, or a symlink: $path"85 mode="$(file_mode "$path")"86 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"87 permissions=$((8#$mode))88 (( (permissions & 8#022) == 0 )) || die "file is writable by group or others: $path"89}9091require_hash() {92 [[ "$1" =~ ^[0-9a-f]{64}$ ]] || die "invalid SHA256 value for $2"93}9495require_commit() {96 [[ "$1" =~ ^[0-9a-f]{40}$ ]] || die "invalid git commit in $2"97}9899require_release_tag() {100 [[ "$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]+)*)?$ ]] \101 || die "release tag must be an exact vVERSION tag"102}103104require_safe_directory() {105 local path="$1"106 local mode107 local permissions108 [[ -d "$path" && ! -L "$path" ]] || die "directory is missing or a symlink: $path"109 mode="$(file_mode "$path")"110 [[ "$mode" =~ ^[0-7]{3,4}$ ]] || die "could not determine safe permissions for $path"111 permissions=$((8#$mode))112 (( (permissions & 8#022) == 0 )) || die "directory is writable by group or others: $path"113}114115verified_commit=''116verified_portal_dmg=''117verified_portal_checksum=''118verified_portal_manifest=''119120github_api() {121 local endpoint="$1"122 command -v gh > /dev/null 2>&1 || die "GitHub CLI (gh) is required to verify published release assets"123 GH_PROMPT_DISABLED=1 gh api "$endpoint" \124 || die "GitHub API request failed for $endpoint"125}126127git_object_from_json() {128 PYTHONDONTWRITEBYTECODE=1 python3 -c '129import json130import re131import sys132133try:134 obj = json.load(sys.stdin)["object"]135 kind = obj["type"]136 sha = obj["sha"]137except (KeyError, TypeError, ValueError, json.JSONDecodeError):138 raise SystemExit(1)139if kind not in {"commit", "tag"} or not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha):140 raise SystemExit(1)141print(f"{kind}\t{sha}")142'143}144145resolve_published_tag_commit() {146 local release_tag="$1"147 local response148 local parsed149 local object_type150 local object_sha151 local depth=0152153 response="$(github_api "/repos/$GITHUB_REPOSITORY/git/ref/tags/$release_tag")"154 parsed="$(printf '%s' "$response" | git_object_from_json)" \155 || die "GitHub tag ref is malformed for $release_tag"156 IFS=$'\t' read -r object_type object_sha <<< "$parsed"157 while [[ "$object_type" == tag ]]; do158 ((depth += 1))159 (( depth <= 8 )) || die "GitHub annotated tag chain is too deep for $release_tag"160 response="$(github_api "/repos/$GITHUB_REPOSITORY/git/tags/$object_sha")"161 parsed="$(printf '%s' "$response" | git_object_from_json)" \162 || die "GitHub annotated tag object is malformed for $release_tag"163 IFS=$'\t' read -r object_type object_sha <<< "$parsed"164 done165 [[ "$object_type" == commit ]] || die "GitHub tag does not resolve to a commit: $release_tag"166 printf '%s\n' "$object_sha"167}168169verify_published_release() {170 local release_tag="$1"171 local expected_commit="$2"172 local archive="$3"173 local portal_dmg="$4"174 local portal_checksum="$5"175 local portal_manifest="$6"176 local remote_commit177 local release_json178179 remote_commit="$(resolve_published_tag_commit "$release_tag")"180 require_equal "$remote_commit" "$expected_commit" "published GitHub tag commit"181 release_json="$(github_api "/repos/$GITHUB_REPOSITORY/releases/tags/$release_tag")"182 PYTHONDONTWRITEBYTECODE=1 python3 - \183 "$GITHUB_REPOSITORY" "$release_tag" \184 "$archive" "$archive.sha256" "$archive.minisig" \185 "$portal_dmg" "$portal_checksum" "$portal_manifest" \186 3<<< "$release_json" <<'PY' \187 || die "supplied assets do not match the published GitHub prerelease"188import hashlib189import json190import os191import pathlib192import re193import sys194195repository = sys.argv[1]196release_tag = sys.argv[2]197paths = [pathlib.Path(value) for value in sys.argv[3:]]198try:199 with os.fdopen(3) as release_stream:200 release = json.load(release_stream)201except (TypeError, ValueError, json.JSONDecodeError):202 raise SystemExit(1)203204if release.get("tag_name") != release_tag:205 raise SystemExit(1)206if release.get("draft") is not False or release.get("prerelease") is not True:207 raise SystemExit(1)208if not isinstance(release.get("id"), int) or release["id"] <= 0:209 raise SystemExit(1)210if not isinstance(release.get("published_at"), str) or not release["published_at"]:211 raise SystemExit(1)212if release.get("html_url") != f"https://github.com/{repository}/releases/tag/{release_tag}":213 raise SystemExit(1)214215assets = release.get("assets")216if not isinstance(assets, list) or len(assets) != len(paths):217 raise SystemExit(1)218by_name = {}219for asset in assets:220 if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):221 raise SystemExit(1)222 if asset["name"] in by_name:223 raise SystemExit(1)224 by_name[asset["name"]] = asset225226expected_names = {path.name for path in paths}227if len(expected_names) != len(paths) or set(by_name) != expected_names:228 raise SystemExit(1)229for path in paths:230 asset = by_name[path.name]231 digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()232 if asset.get("state") != "uploaded" or asset.get("digest") != digest:233 raise SystemExit(1)234 if asset.get("size") != path.stat().st_size:235 raise SystemExit(1)236 if not isinstance(asset.get("id"), int) or asset["id"] <= 0:237 raise SystemExit(1)238 api_url = asset.get("url")239 if not isinstance(api_url, str) or not re.fullmatch(240 rf"https://api\.github\.com/repos/{re.escape(repository)}/releases/assets/[1-9][0-9]*",241 api_url,242 ):243 raise SystemExit(1)244PY245}246247verify_portal_attestations() {248 local release_tag="$1"249 local expected_commit="$2"250 shift 2251 local asset252253 for asset in "$@"; do254 GH_PROMPT_DISABLED=1 gh attestation verify "$asset" \255 --repo "$GITHUB_REPOSITORY" \256 --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/portal-release.yml" \257 --source-ref "refs/tags/$release_tag" \258 --source-digest "$expected_commit" \259 --deny-self-hosted-runners > /dev/null \260 || die "Portal GitHub build-provenance attestation verification failed: $(basename -- "$asset")"261 done262}263264verify_release_inputs() {265 local release_tag="$1"266 local archive="$2"267 local sigil="$3"268 local portal_assets="$4"269 local version270 local head271 local worktree_status272 local expected_names273274 require_release_tag "$release_tag"275 require_absolute_path "$archive"276 require_absolute_path "$sigil"277 require_absolute_path "$portal_assets"278 require_safe_regular_file "$archive"279 require_safe_regular_file "$archive.sha256"280 require_safe_regular_file "$archive.minisig"281 require_safe_regular_file "$sigil"282 require_safe_directory "$portal_assets"283 require_safe_regular_file "$sigil_release_verifier"284 require_safe_regular_file "$portal_release_verifier"285 require_safe_regular_file "$portal_signature_verifier"286 require_safe_regular_file "$sigil_public_key"287288 verified_commit="$(git -C "$repo_dir" rev-parse --verify "refs/tags/$release_tag^{commit}" 2>/dev/null)" \289 || die "release tag does not resolve through refs/tags/$release_tag"290 require_commit "$verified_commit" release291 head="$(git -C "$repo_dir" rev-parse --verify HEAD 2>/dev/null)" || die "repository HEAD is unavailable"292 require_equal "$head" "$verified_commit" "release tag commit"293 worktree_status="$(git -C "$repo_dir" status --porcelain=v1 --untracked-files=all)" \294 || die "release worktree status is unavailable"295 [[ -z "$worktree_status" ]] || die "hardware UAT must run from the clean exact release tag"296297 version="${release_tag#v}"298 verified_portal_dmg="$portal_assets/Portal-$version-arm64.dmg"299 verified_portal_checksum="$verified_portal_dmg.sha256"300 verified_portal_manifest="$portal_assets/Portal-$version-arm64.json"301 expected_names="$(302 find "$portal_assets" -mindepth 1 -maxdepth 1 -print \303 | sed 's#.*/##' \304 | LC_ALL=C sort305 )"306 require_equal "$expected_names" "$(printf '%s\n' \307 "Portal-$version-arm64.dmg" \308 "Portal-$version-arm64.dmg.sha256" \309 "Portal-$version-arm64.json" | LC_ALL=C sort)" "Portal asset set"310 require_safe_regular_file "$verified_portal_dmg"311 require_safe_regular_file "$verified_portal_checksum"312 require_safe_regular_file "$verified_portal_manifest"313314 "$sigil_release_verifier" \315 --tag "$release_tag" \316 --archive "$archive" \317 --source-commit "$verified_commit" \318 --public-key-file "$sigil_public_key" > /dev/null \319 || die "signed Sigil release verification failed"320321 python3 - "$archive" "$sigil" <<'PY' || die "supplied Sigil executable does not equal payload/release/sigil"322import pathlib323import sys324import tarfile325326archive_path = pathlib.Path(sys.argv[1])327sigil_path = pathlib.Path(sys.argv[2])328try:329 with tarfile.open(archive_path, "r:gz") as archive:330 member = archive.getmember("payload/release/sigil")331 if not member.isreg():332 raise ValueError("Sigil payload is not regular")333 stream = archive.extractfile(member)334 if stream is None or stream.read() != sigil_path.read_bytes():335 raise ValueError("Sigil payload differs")336except (KeyError, OSError, tarfile.TarError, ValueError):337 raise SystemExit(1)338PY339340 PYTHONDONTWRITEBYTECODE=1 python3 "$portal_release_verifier" assets \341 --repo-dir "$repo_dir" \342 --release-tag "$release_tag" \343 --asset-dir "$portal_assets" > /dev/null \344 || die "Portal release asset verification failed"345346 [[ "$(uname -s)" == Darwin ]] || die "hardware UAT release verification must run on macOS"347 bash "$portal_signature_verifier" \348 --dmg "$verified_portal_dmg" \349 --expected-version "$version" \350 --signing adhoc > /dev/null \351 || die "Portal macOS ad-hoc release verification failed"352353 verify_published_release \354 "$release_tag" \355 "$verified_commit" \356 "$archive" \357 "$verified_portal_dmg" \358 "$verified_portal_checksum" \359 "$verified_portal_manifest"360 verify_portal_attestations \361 "$release_tag" \362 "$verified_commit" \363 "$verified_portal_dmg" \364 "$verified_portal_checksum" \365 "$verified_portal_manifest"366}367368manifest_get() {369 local manifest="$1"370 local key="$2"371 awk -F '\t' -v wanted="$key" '372 $1 == wanted { count += 1; value = $2 }373 END { if (count != 1) exit 1; print value }374 ' "$manifest" || die "manifest key is missing or duplicated: $key"375}376377source_get() {378 local source="$1"379 local key="$2"380 awk -v wanted="$key" '381 index($0, wanted "=") == 1 {382 count += 1383 value = substr($0, length(wanted) + 2)384 }385 END { if (count != 1 || value == "") exit 1; print value }386 ' "$source" || die "evidence key is missing, empty, or duplicated: $key"387}388389kind_allowed() {390 local wanted="$1"391 local candidate392 for candidate in "${all_kinds[@]}"; do393 [[ "$candidate" == "$wanted" ]] && return 0394 done395 return 1396}397398common_keys() {399 printf '%s\n' uat_schema evidence_kind observed_at_unix git_commit release_tag sigil_sha256 portal_sha256400}401402kind_keys() {403 case "$1" in404 cold-boot)405 printf '%s\n' cold_boot_result cold_boot_failure_count cold_boot_insufficient_count \406 headless_connector_state gaming_autologin_session sigil_host_enabled sigil_host_active \407 gamescope_pipewire_node gamescope_before_first_ssh sigil_unit_before_first_ssh \408 sigil_ready_before_first_ssh409 ;;410 controller)411 printf '%s\n' physical_controller_attached_to_portal actual_game_controlled \412 controller_coverage neutral_release_on_disconnect neutral_buttons neutral_axes session_seconds413 ;;414 mouse)415 printf '%s\n' target_application left_click_consumed right_click_consumed \416 consumption_observed_in_target click_attempts417 ;;418 soak)419 printf '%s\n' duration_seconds samples capture_fps_p50 presentation_fps_p50 \420 frame_interval_p95_ms hitch_p99_ms video_queue_p95_frames decode_queue_p95_frames \421 audio_queue_p95_ms av_skew_p95_ms max_queue_age_p95_ms cpu_p95_percent \422 gpu_p95_percent rss_p95_mib transport_drops frontend_drops audio_drops \423 latency_first_window_p95_ms latency_last_window_p95_ms disconnects424 ;;425 network-direct|network-relay)426 printf '%s\n' path_mode nat_scenario session_seconds rtt_p50_ms rtt_p95_ms \427 input_ack_p95_ms presentation_latency_p95_ms packet_loss_percent428 ;;429 reconnect)430 printf '%s\n' reconnect_cycles reconnect_successes reconnect_failures state_preserved \431 keyframe_recovery_p95_ms432 ;;433 second-client)434 printf '%s\n' protocol_mode second_client_attempts second_client_rejections \435 authorized_primary_uninterrupted rejection_reason436 ;;437 multi-viewer)438 printf '%s\n' protocol_mode configured_viewer_capacity concurrent_viewers \439 shared_media_generation shared_video_sources shared_video_encoders \440 shared_audio_sources shared_audio_encoders shared_publishers shared_adaptive_actuators \441 viewer_progress slot_0_holders_max focus_handoff_p95_ms slow_viewer_isolation \442 same_peer_replacement live_view_revocation survivors_uninterrupted final_cleanup \443 ordinary_service_restored444 ;;445 loopback-preflight)446 printf '%s\n' loopback_proof profile host_sha256 control_v2 viewers reconnect_cycles \447 same_peer_replacement shared_generation slot_0_single_holder bounded_queues final_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 require_equal "$(source_get "$source" protocol_mode)" legacy-exclusive-v1 protocol_mode608 attempts="$(source_get "$source" second_client_attempts)"; require_number "$attempts" second_client_attempts609 rejections="$(source_get "$source" second_client_rejections)"; require_number "$rejections" second_client_rejections610 number_ge "$attempts" 3 || die "second-client evidence requires at least three attempts"611 require_equal "$rejections" "$attempts" second_client_rejections612 require_equal "$(source_get "$source" authorized_primary_uninterrupted)" pass authorized_primary_uninterrupted613 require_equal "$(source_get "$source" rejection_reason)" legacy-active-client rejection_reason614}615616validate_multi_viewer() {617 local source="$1"618 local capacity viewers handoff_p95619 require_equal "$(source_get "$source" protocol_mode)" native-moq-v2 protocol_mode620 capacity="$(source_get "$source" configured_viewer_capacity)"621 viewers="$(source_get "$source" concurrent_viewers)"622 handoff_p95="$(source_get "$source" focus_handoff_p95_ms)"623 require_number "$capacity" configured_viewer_capacity624 require_number "$viewers" concurrent_viewers625 require_number "$handoff_p95" focus_handoff_p95_ms626 number_ge "$capacity" 3 || die "multi-viewer capacity must be at least three"627 number_le "$capacity" 8 || die "multi-viewer capacity exceeds the hard ceiling"628 number_ge "$viewers" 3 || die "multi-viewer evidence requires three concurrent viewers"629 number_le "$viewers" "$capacity" || die "concurrent viewers exceed configured capacity"630 number_le "$handoff_p95" 1000 || die "focus handoff p95 exceeds 1000 ms"631 for key in shared_video_sources shared_video_encoders shared_audio_sources \632 shared_audio_encoders shared_publishers shared_adaptive_actuators slot_0_holders_max; do633 require_number "$(source_get "$source" "$key")" "$key"634 done635 require_equal "$(source_get "$source" shared_video_sources)" 1 shared_video_sources636 require_equal "$(source_get "$source" shared_video_encoders)" 1 shared_video_encoders637 require_equal "$(source_get "$source" shared_audio_sources)" 1 shared_audio_sources638 require_equal "$(source_get "$source" shared_audio_encoders)" 1 shared_audio_encoders639 require_equal "$(source_get "$source" shared_publishers)" 1 shared_publishers640 require_equal "$(source_get "$source" shared_adaptive_actuators)" 1 shared_adaptive_actuators641 require_equal "$(source_get "$source" slot_0_holders_max)" 1 slot_0_holders_max642 for key in shared_media_generation viewer_progress slow_viewer_isolation \643 same_peer_replacement live_view_revocation survivors_uninterrupted final_cleanup \644 ordinary_service_restored; do645 require_equal "$(source_get "$source" "$key")" pass "$key"646 done647}648649validate_loopback() {650 local source="$1"651 local cycles652 require_equal "$(source_get "$source" loopback_proof)" ok loopback_proof653 require_equal "$(source_get "$source" profile)" release profile654 require_equal "$(source_get "$source" host_sha256)" "$(source_get "$source" sigil_sha256)" host_sha256655 require_equal "$(source_get "$source" control_v2)" ok control_v2656 require_equal "$(source_get "$source" viewers)" 3 viewers657 require_equal "$(source_get "$source" same_peer_replacement)" ok same_peer_replacement658 require_equal "$(source_get "$source" shared_generation)" ok shared_generation659 require_equal "$(source_get "$source" slot_0_single_holder)" ok slot_0_single_holder660 require_equal "$(source_get "$source" bounded_queues)" ok bounded_queues661 require_equal "$(source_get "$source" final_cleanup)" ok final_cleanup662 cycles="$(source_get "$source" reconnect_cycles)"; require_number "$cycles" reconnect_cycles663 number_ge "$cycles" 3 || die "loopback preflight requires at least three reconnect cycles"664}665666validate_kind() {667 case "$2" in668 cold-boot) validate_cold_boot "$1" ;;669 controller) validate_controller "$1" ;;670 mouse) validate_mouse "$1" ;;671 soak) validate_soak "$1" ;;672 network-direct|network-relay) validate_network "$1" "$2" ;;673 reconnect) validate_reconnect "$1" ;;674 multi-viewer) validate_multi_viewer "$1" ;;675 second-client) validate_second_client "$1" ;;676 loopback-preflight) validate_loopback "$1" ;;677 *) die "unsupported evidence kind: $2" ;;678 esac679}680681reject_sensitive_source() {682 local source="$1"683 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"; then684 die "evidence contains a node ID, invitation, or secret-bearing field"685 fi686}687688validate_manifest() {689 local directory="$1"690 local manifest="$directory/manifest.tsv"691 local sigil_archive_name692 local key693 local record_kind694 local record_field695 require_secure_directory "$directory"696 require_secure_directory "$directory/records"697 require_safe_regular_file "$manifest"698 while IFS=$'\t' read -r key _; do699 case "$key" in700 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) ;;701 *)702 if [[ "$key" =~ ^record\.([a-z-]+)\.(file|sha256|observed_at_unix)$ ]]; then703 record_kind="${BASH_REMATCH[1]}"704 record_field="${BASH_REMATCH[2]}"705 kind_allowed "$record_kind" || die "manifest contains an unknown evidence kind: $record_kind"706 [[ -n "$record_field" ]] || die "invalid manifest record field: $key"707 else708 die "unexpected manifest key: $key"709 fi710 ;;711 esac712 done < "$manifest"713 require_equal "$(manifest_get "$manifest" format)" "$FORMAT" format714 require_commit "$(manifest_get "$manifest" git_commit)" manifest715 require_release_tag "$(manifest_get "$manifest" release_tag)"716 sigil_archive_name="$(manifest_get "$manifest" sigil_archive_name)"717 [[ "$sigil_archive_name" =~ ^sigil-v[0-9A-Za-z.-]+-[0-9A-Za-z._-]+[.]tar[.]gz$ \718 && "$sigil_archive_name" == sigil-v*-"$SIGIL_ASSET_TARGET_CONTRACT".tar.gz ]] \719 || die "invalid manifest Sigil archive name"720 [[ "$(manifest_get "$manifest" portal_dmg_name)" =~ ^Portal-[0-9A-Za-z.-]+-arm64\.dmg$ ]] \721 || die "invalid manifest Portal DMG name"722 require_hash "$(manifest_get "$manifest" sigil_archive_sha256)" sigil_archive_sha256723 require_hash "$(manifest_get "$manifest" sigil_checksum_sha256)" sigil_checksum_sha256724 require_hash "$(manifest_get "$manifest" sigil_signature_sha256)" sigil_signature_sha256725 require_hash "$(manifest_get "$manifest" sigil_public_key_sha256)" sigil_public_key_sha256726 require_hash "$(manifest_get "$manifest" sigil_sha256)" sigil_sha256727 require_hash "$(manifest_get "$manifest" portal_sha256)" portal_sha256728 require_hash "$(manifest_get "$manifest" portal_checksum_sha256)" portal_checksum_sha256729 require_hash "$(manifest_get "$manifest" portal_manifest_sha256)" portal_manifest_sha256730 [[ "$(manifest_get "$manifest" created_at_unix)" =~ ^[0-9]{10}$ ]] || die "invalid manifest created_at_unix"731 local max_age732 max_age="$(manifest_get "$manifest" max_age_seconds)"733 if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then734 die "invalid manifest max_age_seconds"735 fi736}737738atomic_manifest_record() {739 local directory="$1"740 local kind="$2"741 local record_sha="$3"742 local observed="$4"743 local manifest="$directory/manifest.tsv"744 local temporary745 temporary="$(mktemp "$directory/.manifest.XXXXXX")"746 chmod 600 "$temporary"747 awk -F '\t' -v prefix="record.$kind." 'index($1, prefix) != 1 { print }' "$manifest" > "$temporary"748 {749 printf 'record.%s.file\trecords/%s.evidence\n' "$kind" "$kind"750 printf 'record.%s.sha256\t%s\n' "$kind" "$record_sha"751 printf 'record.%s.observed_at_unix\t%s\n' "$kind" "$observed"752 } >> "$temporary"753 mv -f "$temporary" "$manifest"754}755756command_init() {757 local directory=''758 local release_tag=''759 local archive=''760 local sigil=''761 local portal_assets=''762 local max_age="$DEFAULT_MAX_AGE_SECONDS"763 local parent764 while (($#)); do765 case "$1" in766 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;767 --release-tag) [[ $# -ge 2 ]] || die "$1 requires a value"; release_tag="$2"; shift 2 ;;768 --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;769 --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;770 --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;771 --max-age-seconds) [[ $# -ge 2 ]] || die "$1 requires a value"; max_age="$2"; shift 2 ;;772 *) die "unknown init argument: $1" ;;773 esac774 done775 [[ -n "$directory" && -n "$release_tag" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \776 || die "init requires evidence dir, release tag, Sigil archive/executable, and Portal asset directory"777 require_absolute_path "$directory"778 if [[ ! "$max_age" =~ ^[0-9]+$ ]] || (( max_age < 3600 || max_age > 2592000 )); then779 die "max age must be 3600..2592000 seconds"780 fi781 [[ ! -e "$directory" && ! -L "$directory" ]] || die "evidence directory already exists"782 parent="$(dirname "$directory")"; [[ -d "$parent" && ! -L "$parent" ]] || die "evidence parent is missing or a symlink"783 verify_release_inputs "$release_tag" "$archive" "$sigil" "$portal_assets"784 mkdir -m 700 "$directory"785 mkdir -m 700 "$directory/records"786 umask 077787 {788 printf 'format\t%s\n' "$FORMAT"789 printf 'created_at_unix\t%s\n' "$(date +%s)"790 printf 'release_tag\t%s\n' "$release_tag"791 printf 'git_commit\t%s\n' "$verified_commit"792 printf 'sigil_archive_name\t%s\n' "$(basename -- "$archive")"793 printf 'sigil_archive_sha256\t%s\n' "$(sha256_file "$archive")"794 printf 'sigil_checksum_sha256\t%s\n' "$(sha256_file "$archive.sha256")"795 printf 'sigil_signature_sha256\t%s\n' "$(sha256_file "$archive.minisig")"796 printf 'sigil_public_key_sha256\t%s\n' "$(sha256_file "$sigil_public_key")"797 printf 'sigil_sha256\t%s\n' "$(sha256_file "$sigil")"798 printf 'portal_dmg_name\t%s\n' "$(basename -- "$verified_portal_dmg")"799 printf 'portal_sha256\t%s\n' "$(sha256_file "$verified_portal_dmg")"800 printf 'portal_checksum_sha256\t%s\n' "$(sha256_file "$verified_portal_checksum")"801 printf 'portal_manifest_sha256\t%s\n' "$(sha256_file "$verified_portal_manifest")"802 printf 'max_age_seconds\t%s\n' "$max_age"803 } > "$directory/manifest.tsv"804 chmod 600 "$directory/manifest.tsv"805 printf 'public_alpha_uat=initialized\nevidence_dir=%s\nrelease_tag=%s\ngit_commit=%s\n' \806 "$directory" "$release_tag" "$verified_commit"807 printf 'sigil_archive_sha256=%s\nsigil_sha256=%s\nportal_sha256=%s\n' \808 "$(sha256_file "$archive")" "$(sha256_file "$sigil")" "$(sha256_file "$verified_portal_dmg")"809}810811command_record() {812 local directory=''813 local kind=''814 local source=''815 local manifest816 local record817 local temporary818 local key819 local observed820 local size821 while (($#)); do822 case "$1" in823 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;824 --kind) [[ $# -ge 2 ]] || die "$1 requires a value"; kind="$2"; shift 2 ;;825 --file) [[ $# -ge 2 ]] || die "$1 requires a value"; source="$2"; shift 2 ;;826 *) die "unknown record argument: $1" ;;827 esac828 done829 [[ -n "$directory" && -n "$kind" && -n "$source" ]] || die "record requires evidence dir, kind, and file"830 require_absolute_path "$directory"; require_absolute_path "$source"831 kind_allowed "$kind" || die "unknown evidence kind: $kind"832 validate_manifest "$directory"833 manifest="$directory/manifest.tsv"834 require_safe_regular_file "$source"835 size="$(file_size "$source")"; [[ "$size" =~ ^[0-9]+$ ]] || die "could not determine evidence size"836 (( size > 0 && size <= MAX_SOURCE_BYTES )) || die "evidence source is empty or exceeds $MAX_SOURCE_BYTES bytes"837 reject_sensitive_source "$source"838 validate_common_evidence "$source" "$kind" "$manifest"839 validate_kind "$source" "$kind"840 [[ ! -e "$directory/records/$kind.evidence" ]] || die "evidence kind is already recorded: $kind"841842 temporary="$(mktemp "$directory/records/.$kind.XXXXXX")"843 chmod 600 "$temporary"844 while IFS= read -r key; do845 printf '%s=%s\n' "$key" "$(source_get "$source" "$key")" >> "$temporary"846 done < <({ common_keys; kind_keys "$kind"; } | awk '!seen[$0]++')847 record="$directory/records/$kind.evidence"848 mv "$temporary" "$record"849 observed="$(source_get "$record" observed_at_unix)"850 atomic_manifest_record "$directory" "$kind" "$(sha256_file "$record")" "$observed"851 printf 'public_alpha_uat=recorded\nevidence_kind=%s\nrecord_sha256=%s\n' "$kind" "$(sha256_file "$record")"852}853854command_verify() {855 local directory=''856 local archive=''857 local sigil=''858 local portal_assets=''859 local manifest860 local kind861 local record862 local expected863 local actual864 local file865 local filename866 local found_kind867 while (($#)); do868 case "$1" in869 --evidence-dir) [[ $# -ge 2 ]] || die "$1 requires a value"; directory="$2"; shift 2 ;;870 --sigil-archive) [[ $# -ge 2 ]] || die "$1 requires a value"; archive="$2"; shift 2 ;;871 --sigil-bin) [[ $# -ge 2 ]] || die "$1 requires a value"; sigil="$2"; shift 2 ;;872 --portal-assets) [[ $# -ge 2 ]] || die "$1 requires a value"; portal_assets="$2"; shift 2 ;;873 *) die "unknown verify argument: $1" ;;874 esac875 done876 [[ -n "$directory" && -n "$archive" && -n "$sigil" && -n "$portal_assets" ]] \877 || die "verify requires evidence dir, Sigil archive/executable, and Portal asset directory"878 require_absolute_path "$directory"879 validate_manifest "$directory"; manifest="$directory/manifest.tsv"880 verify_release_inputs "$(manifest_get "$manifest" release_tag)" "$archive" "$sigil" "$portal_assets"881 require_equal "$verified_commit" "$(manifest_get "$manifest" git_commit)" "release tag commit"882 require_equal "$(basename -- "$archive")" "$(manifest_get "$manifest" sigil_archive_name)" "Sigil archive name"883 require_equal "$(sha256_file "$archive")" "$(manifest_get "$manifest" sigil_archive_sha256)" "Sigil archive hash"884 require_equal "$(sha256_file "$archive.sha256")" "$(manifest_get "$manifest" sigil_checksum_sha256)" "Sigil checksum asset hash"885 require_equal "$(sha256_file "$archive.minisig")" "$(manifest_get "$manifest" sigil_signature_sha256)" "Sigil signature asset hash"886 require_equal "$(sha256_file "$sigil_public_key")" "$(manifest_get "$manifest" sigil_public_key_sha256)" "Sigil public key hash"887 require_equal "$(sha256_file "$sigil")" "$(manifest_get "$manifest" sigil_sha256)" "Sigil release-candidate hash"888 require_equal "$(basename -- "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_dmg_name)" "Portal DMG name"889 require_equal "$(sha256_file "$verified_portal_dmg")" "$(manifest_get "$manifest" portal_sha256)" "Portal release-candidate hash"890 require_equal "$(sha256_file "$verified_portal_checksum")" "$(manifest_get "$manifest" portal_checksum_sha256)" "Portal checksum asset hash"891 require_equal "$(sha256_file "$verified_portal_manifest")" "$(manifest_get "$manifest" portal_manifest_sha256)" "Portal manifest asset hash"892893 while IFS= read -r file; do894 case "$file" in895 "$directory/manifest.tsv") [[ -f "$file" ]] || die "manifest is not a regular file" ;;896 "$directory/records") [[ -d "$file" ]] || die "records path is not a directory" ;;897 "$directory/records/"*.evidence)898 [[ -f "$file" ]] || die "evidence record is not a regular file: $file"899 filename="${file##*/}"900 found_kind="${filename%.evidence}"901 kind_allowed "$found_kind" || die "unexpected evidence kind in bundle: $found_kind"902 ;;903 *) die "unexpected file or directory in evidence bundle: $file" ;;904 esac905 [[ ! -L "$file" ]] || die "evidence bundle contains a symlink: $file"906 done < <(find "$directory" -mindepth 1 -maxdepth 2 -print)907908 for kind in "${required_kinds[@]}"; do909 record="$directory/records/$kind.evidence"910 require_safe_regular_file "$record"911 expected="$(manifest_get "$manifest" "record.$kind.sha256")"912 actual="$(sha256_file "$record")"913 require_equal "$actual" "$expected" "$kind record hash"914 require_equal "$(manifest_get "$manifest" "record.$kind.file")" "records/$kind.evidence" "$kind record path"915 require_equal "$(manifest_get "$manifest" "record.$kind.observed_at_unix")" \916 "$(source_get "$record" observed_at_unix)" "$kind record timestamp"917 validate_common_evidence "$record" "$kind" "$manifest"918 validate_kind "$record" "$kind"919 done920 if grep -q '^record\.loopback-preflight\.' "$manifest" && \921 [[ ! -e "$directory/records/loopback-preflight.evidence" ]]; then922 die "loopback preflight manifest metadata exists without its record"923 fi924 if [[ -e "$directory/records/loopback-preflight.evidence" ]]; then925 record="$directory/records/loopback-preflight.evidence"926 require_safe_regular_file "$record"927 require_equal "$(sha256_file "$record")" \928 "$(manifest_get "$manifest" record.loopback-preflight.sha256)" "loopback preflight record hash"929 require_equal "$(manifest_get "$manifest" record.loopback-preflight.file)" \930 "records/loopback-preflight.evidence" "loopback preflight record path"931 require_equal "$(manifest_get "$manifest" record.loopback-preflight.observed_at_unix)" \932 "$(source_get "$record" observed_at_unix)" "loopback preflight record timestamp"933 validate_common_evidence "$record" loopback-preflight "$manifest"934 validate_loopback "$record"935 fi936937 printf 'public_alpha_uat=pass\ngit_commit=%s\nrelease_tag=%s\n' \938 "$(manifest_get "$manifest" git_commit)" "$(manifest_get "$manifest" release_tag)"939 printf 'sigil_sha256=%s\nportal_sha256=%s\nrequired_gates=%s\n' \940 "$(manifest_get "$manifest" sigil_sha256)" "$(manifest_get "$manifest" portal_sha256)" "${#required_kinds[@]}"941}942943main() {944 local command="${1:-}"945 [[ -n "$command" ]] || { usage; exit 2; }946 shift947 case "$command" in948 init) command_init "$@" ;;949 record) command_record "$@" ;;950 verify) command_verify "$@" ;;951 --help|-h|help) usage ;;952 *) usage >&2; die "unknown command: $command" ;;953 esac954}955956main "$@"