From 8077e94481c202a93f64d512a05bbf7c85384952 Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 14:53:35 -0400 Subject: [PATCH 1/3] feat(sync): add transactional bloom-sync engine --- Makefile | 2 + build/dependencies.lock | 8 +- build/legacy-manifest.json | 19 +- docs/ARCHITECTURE.md | 24 +- docs/SAVE_SYNC.md | 48 +- docs/SECURITY.md | 14 +- .../build/.tmp_update/bin/bloom-network-sync | 12 +- static/build/.tmp_update/bin/bloom-sync | 993 ++++++++++++++++++ static/build/.tmp_update/bin/bloomctl | 16 + test/shell/legacy_manifest.bats | 4 +- test/shell/network_sync.bats | 14 + test/shell/save_sync_engine.bats | 482 +++++++++ tools/legacy_manifest.py | 1 + 13 files changed, 1605 insertions(+), 32 deletions(-) create mode 100755 static/build/.tmp_update/bin/bloom-sync create mode 100644 test/shell/save_sync_engine.bats diff --git a/Makefile b/Makefile index 09ce570a2..3133e6008 100644 --- a/Makefile +++ b/Makefile @@ -182,6 +182,8 @@ core: $(CACHE)/.setup shared-libs @cp $(ROOT_DIR)/build/ra-core-policy.json $(BUILD_DIR)/.tmp_update/config/ra-core-policy.json @cp $(ROOT_DIR)/build/core-selection-policy.json $(BUILD_DIR)/.tmp_update/config/core-selection-policy.json @cp $(ROOT_DIR)/build/save-migration-policy.json $(BUILD_DIR)/.tmp_update/config/save-migration-policy.json + @cp $(ROOT_DIR)/build/save-sync-contract.json $(BUILD_DIR)/.tmp_update/config/save-sync-contract.json + @cp $(ROOT_DIR)/build/save-sync-bundle.schema.json $(BUILD_DIR)/.tmp_update/config/save-sync-bundle.schema.json @cp $(ROOT_DIR)/build/transfer-policy.json $(BUILD_DIR)/.tmp_update/config/transfer-policy.json @cp $(ROOT_DIR)/build/system-catalog.json $(BUILD_DIR)/.tmp_update/config/system-catalog.json @cp $(ROOT_DIR)/build/libretro-artwork-policy.json $(BUILD_DIR)/.tmp_update/config/libretro-artwork-policy.json diff --git a/build/dependencies.lock b/build/dependencies.lock index c8083fb66..961c85bcb 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -207,10 +207,10 @@ legacy_channels = ["development"] [legacy_inventory] manifest = "build/legacy-manifest.json" schema = 1 -component_count = 326 -file_count = 1967 -byte_count = 260662316 -source_build_components = 232 +component_count = 327 +file_count = 1968 +byte_count = 260706544 +source_build_components = 233 unresolved_components = 94 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 82b37a9fd..768a3945d 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -632,7 +632,7 @@ "path": "static/build/.tmp_update/bin/bloom-network-sync", "file_count": 1, "byte_count": 2845, - "tree_sha256": "fc3e0cc0ec29b5082344fdb988e4c2e2ceeaccafdbde4015ad0e2f79cde4bb69", + "tree_sha256": "44f59ee23cf0b3f36afbdc981446883782a44a66d0a586ab3131d39a56a75482", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -834,6 +834,19 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-sync", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-sync", + "file_count": 1, + "byte_count": 43429, + "tree_sha256": "0538b57032dc248a0d69c4b7bceebf2271a433878d6378d1cbe028c3872e68ac", + "source": "https://github.com/boburning/BloomOS", + "source_revision": "release-commit", + "license": "GPL-3.0-only", + "build_recipe": "Makefile", + "resolution": "source-build" + }, { "id": "runtime-tmp-update-bin-bloom-test-runner", "kind": "runtime", @@ -1034,8 +1047,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloomctl", "file_count": 1, - "byte_count": 40803, - "tree_sha256": "0b9d24be9d02aea7fafb84b48278ff5813e1c3bf86043dfa0d531f1930a9fc88", + "byte_count": 41602, + "tree_sha256": "d7b77598e3c3b402f609996f2e93fbf81c0811322d29420ec630009193868f2b", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5987aead2..5de482583 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -990,13 +990,13 @@ launches the inherited TLS-bypassing check or publishes its legacy availability marker. Acknowledgement failure leaves the event pending, and consumer failure remains secondary to Wi-Fi health. -`bloom-network-sync` completes the fixed event fan-out without claiming the -later Save Sync program. It validates and acknowledges only its exact sync -generation, returns `engine_unavailable` while SYNC-01/02 have supplied neither -the save-bundle contract nor `bloom-sync`, and returns `offline` for an offline +`bloom-network-sync` completes the fixed event fan-out without becoming a Save +Sync transport. It validates and acknowledges only its exact sync generation, +returns `engine_ready` when the transport-neutral `bloom-sync` status contract +is healthy, `engine_unavailable` when it is not, and `offline` for an offline observation. It never reads or writes Saves, starts a transport, or treats the -filesystem `sync` command as Save Sync. This explicit disposition lets NET-07 -complete while preserving SYNC-01 through SYNC-04 as separately gated work. +filesystem `sync` command as Save Sync. This explicit disposition keeps NET-07, +SYNC-02, and the still-conditional SYNC-03 transport gate separate. Bounded `request enable` and `request disable` operations persist only `device.wifi_enabled` through the active `bloom-settings` authority before @@ -1338,9 +1338,15 @@ idempotent, a contiguous parent-linked generation is a staged fast-forward, retained verified ancestry is a stale no-op, and incompatible identities, forks, unrelated lineages, generation gaps, or unverified history preserve both sides for explicit review. A bundle-ID collision is a security event. No SYNC-01 -path exports, imports, transports, or writes Saves. The later `bloom-sync` -transaction is the sole planned publication owner; generic transfer keeps -Saves read-only. The complete normative contract and limitations are in +path exports, imports, transports, or writes Saves. The SYNC-02 `bloom-sync` +runtime is the sole publication owner. It authenticates immutable bundles, +classifies and privately stages or quarantines input, requires explicit review +before promoting a fork, incompatible input, or unrelated lineage, snapshots +the complete current save tree, and publishes a verified candidate through a +same-directory atomic replacement while the game session is inactive. It +cannot override a bundle-ID collision or missing contiguous history. Generic +transfer keeps Saves read-only, and `bloom-sync` itself has no transport or +listener. The complete normative contract and limitations are in [`SAVE_SYNC.md`](SAVE_SYNC.md). ## Canonical game identity diff --git a/docs/SAVE_SYNC.md b/docs/SAVE_SYNC.md index 311af49ca..d5441e31e 100644 --- a/docs/SAVE_SYNC.md +++ b/docs/SAVE_SYNC.md @@ -1,8 +1,8 @@ # Save Sync contract -Status: normative SYNC-01 contract. Save Sync remains unavailable until the -transport-neutral engine, a qualified transport, and the UI pass their separate -SYNC-02 through SYNC-04 gates. +Status: normative SYNC-01 contract with the transport-neutral SYNC-02 engine. +User-visible Save Sync remains unavailable until a qualified transport and the +UI pass their separate SYNC-03 and SYNC-04 gates. `build/save-sync-contract.json` is the semantic authority and `build/save-sync-bundle.schema.json` is the closed bundle-envelope schema. This @@ -116,9 +116,45 @@ not mean that a transport or UI may replace user data. Generic SFTP and any future Web Transfer keep Saves read-only. A transport may move opaque bytes but never owns identity, trust, conflict, or publication. -SYNC-02 must provide one transactional owner that snapshots current data, -publishes only after explicit conflict resolution, preserves rollback material, -and never writes a live save directory directly from transport input. +`bloom-sync` is that transactional owner. Its input and output are immutable +bundle directories containing only `bundle.json`, `payload.bin`, and `complete`. +It has no socket, provider, cloud, discovery, or filesystem-watcher dependency. +The network event consumer may report `engine_ready`, but it starts no transport +and moves no bundle. + +The engine keeps its local Ed25519 identity, explicitly authorized peer keys, +exports, candidates, quarantined review material, current lineage records, and +transactions under the private `.bloom/sync` root. The private key is created +with mode `0600`. Normal peer authorization is first-key only: a divergent key +or epoch for an existing device ID fails as clone or rotation evidence. A +deauthorized peer can no longer validate new input and no accepted user data is +deleted. + +`export` accepts a closed local manifest draft and one relative path beneath the +matching `saves` or `states` tree. It rejects links and traversal, derives the +exact payload size and digest, adds the local device identity, emits RFC +8785-compatible canonical bytes for the schema's ASCII/integer subset, and +signs those bytes with Ed25519. Empty payloads are valid. Export is not a +transport operation. + +`validate` and `import` enforce the fixed SYNC-01 order: bounds, closed schema, +bundle and payload identity, paired device and active epoch, Ed25519 signature, +reviewed core compatibility, then conflict classification. New lineages and +fast-forwards enter private candidate storage. Exact replay and retained +ancestry are no-ops. Collisions, incompatibility, forks, missing history, and +unrelated lineages remain immutable in quarantine. Explicit review may promote +only incompatible, forked, or unrelated-lineage input; a bundle-ID collision or +missing contiguous history cannot be overridden. + +`publish` accepts only an exact staged bundle ID, relative local destination, +and matching classification. Review-required input must first carry the +engine's explicit resolution record. Publication is refused while a game +session is active. The engine creates and verifies a full save snapshot, keeps +the replaced file and previous lineage record in a private transaction, copies +the candidate to a same-directory temporary file, verifies its digest, and +atomically renames it into place. A late state-write failure restores the prior +live bytes and current record while retaining the snapshot and candidate. +Transport input is never written directly into the live save tree. Malformed, oversized, unknown-version, unauthenticated, incompatible, forked, gapped, or replay-collision input fails closed while preserving user data. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d485ac187..27e9d5b4e 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -388,9 +388,15 @@ bundle and payload identities match exactly. Lineage, generation, parent payload hash, and retained verified ancestry own conflict semantics. Wall time is display-only and never authorizes overwrite. Forks, unrelated lineages, gaps, unverified history, and incompatible saves are -preserved for explicit review. SYNC-01 has no runtime consumer and cannot read -or write Saves. Future transports move only immutable bundles; only the -transactional `bloom-sync` owner may eventually publish after snapshot and -conflict resolution. Generic remote access continues to expose Saves read-only. +preserved for explicit review. SYNC-01 itself has no runtime consumer and +cannot read or write Saves. SYNC-02's transactional `bloom-sync` owner validates +bounded immutable bundles, an explicitly authorized device/key epoch, the +Ed25519 signature, reviewed compatibility, and ordered conflict outcome before +private staging. It refuses active-session publication, creates a verified +whole-save snapshot, retains prior bytes and lineage state, and uses a +same-directory verified atomic replacement. Late state failure restores the +prior live file. Bundle collisions and missing history cannot be overridden. +Future transports move only opaque immutable bundles and have no publication +authority. Generic remote access continues to expose Saves read-only. Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/static/build/.tmp_update/bin/bloom-network-sync b/static/build/.tmp_update/bin/bloom-network-sync index 545e54307..d6932dc6d 100755 --- a/static/build/.tmp_update/bin/bloom-network-sync +++ b/static/build/.tmp_update/bin/bloom-network-sync @@ -5,6 +5,7 @@ set -u SYSTEM_ROOT=${BLOOM_NETWORK_SYNC_SYSTEM_ROOT:-/mnt/SDCARD/.tmp_update} NETWORK=${BLOOM_NETWORK_SYNC_NETWORK_BIN:-$SYSTEM_ROOT/bin/bloom-network} JQ=${BLOOM_NETWORK_SYNC_JQ_BIN:-$SYSTEM_ROOT/bin/jq} +SYNC=${BLOOM_NETWORK_SYNC_ENGINE_BIN:-$SYSTEM_ROOT/bin/bloom-sync} result() { printf '{"schema":1,"service":"bloom-network-sync","applied":%s,"state":"%s"}\n' "$1" "$2" @@ -35,11 +36,14 @@ reconcile() { *) result false event_invalid; return 1 ;; esac - # SYNC-01/02 have not yet supplied a save-bundle contract or bloom-sync - # engine. This consumer records that explicit boundary; it never reads or - # writes Saves, starts a transport, or interprets filesystem sync as Save Sync. case "$connectivity" in - associated) disposition=engine_unavailable ;; + associated) + if [ -x "$SYNC" ] && "$SYNC" status >/dev/null 2>&1; then + disposition=engine_ready + else + disposition=engine_unavailable + fi + ;; offline) disposition=offline ;; esac diff --git a/static/build/.tmp_update/bin/bloom-sync b/static/build/.tmp_update/bin/bloom-sync new file mode 100755 index 000000000..129ba2a98 --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-sync @@ -0,0 +1,993 @@ +#!/bin/sh + +set -u +umask 077 + +SD_ROOT=${BLOOM_SD_ROOT:-/mnt/SDCARD} +SYNC_ROOT=${BLOOM_SYNC_ROOT:-$SD_ROOT/.bloom/sync} +SAVE_ROOT=${BLOOM_SYNC_SAVE_ROOT:-$SD_ROOT/Saves/CurrentProfile} +CONFIG_ROOT=${BLOOM_SYNC_CONFIG_ROOT:-$SD_ROOT/.tmp_update/config} +JQ=${BLOOM_SYNC_JQ_BIN:-$SD_ROOT/.tmp_update/bin/jq} +OPENSSL=${BLOOM_SYNC_OPENSSL_BIN:-$SD_ROOT/.tmp_update/bin/openssl} +OPENSSL_LIB_ROOT=${BLOOM_SYNC_OPENSSL_LIB_ROOT:-$SD_ROOT/.tmp_update/lib} +SNAPSHOT=${BLOOM_SYNC_SNAPSHOT_BIN:-$SD_ROOT/.tmp_update/bin/bloom-save-snapshot} +SESSION_STATE=${BLOOM_SYNC_SESSION_STATE_FILE:-/tmp/bloom-session/state} +CORE_POLICY=${BLOOM_SYNC_CORE_POLICY:-$CONFIG_ROOT/core-selection-policy.json} +MAX_MANIFEST_BYTES=16384 +MAX_PAYLOAD_BYTES=67108864 +LOCKED=0 +STAGE= +TEMP_MANIFEST= +TEMP_FILE_A= +TEMP_FILE_B= +PUBLISH_TEMP= +LIVE_REPLACED=0 +LIVE_HAD_PREVIOUS=false +LIVE_EXPECTED_HASH= +CURRENT_WRITTEN=0 +CURRENT_DESTINATION= +CURRENT_TEMP= + +result() { + printf '{"schema":1,"service":"bloom-sync","ok":%s,"operation":"%s","state":"%s"' "$1" "$2" "$3" + [ "$#" -lt 4 ] || printf ',%s' "$4" + printf '}\n' +} + +run_openssl() { + LD_LIBRARY_PATH="$OPENSSL_LIB_ROOT${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$OPENSSL" "$@" +} + +fail() { + operation=$1 + state=$2 + result false "$operation" "$state" + exit 1 +} + +unlock() { + if [ "$LOCKED" -eq 1 ]; then + rmdir "$SYNC_ROOT/.lock" 2>/dev/null || true + LOCKED=0 + fi +} + +rollback_incomplete_publication() { + [ "$LIVE_REPLACED" -eq 1 ] || return + if regular_file "$DESTINATION" && + [ "$(sha256sum "$DESTINATION" | awk '{print $1}')" = "$LIVE_EXPECTED_HASH" ]; then + if [ "$LIVE_HAD_PREVIOUS" = true ] && regular_file "$STAGE/previous"; then + rollback_temp=$DESTINATION.bloom-sync-rollback.$$ + cp "$STAGE/previous" "$rollback_temp" && chmod 600 "$rollback_temp" && + sync && mv -f "$rollback_temp" "$DESTINATION" && sync || true + else + rm -f "$DESTINATION" + sync + fi + fi + if [ "$CURRENT_WRITTEN" -eq 1 ] && [ -n "$CURRENT_DESTINATION" ]; then + if regular_file "$STAGE/previous-current.json"; then + cp "$STAGE/previous-current.json" "$CURRENT_DESTINATION.restore.$$" && + chmod 400 "$CURRENT_DESTINATION.restore.$$" && sync && + mv -f "$CURRENT_DESTINATION.restore.$$" "$CURRENT_DESTINATION" && sync || true + else + rm -f "$CURRENT_DESTINATION" + sync + fi + fi +} + +cleanup_ephemeral() { + rollback_incomplete_publication + if [ -n "$PUBLISH_TEMP" ]; then rm -f "$PUBLISH_TEMP"; fi + if [ -n "$TEMP_MANIFEST" ]; then rm -f "$TEMP_MANIFEST"; fi + if [ -n "$TEMP_FILE_A" ]; then rm -f "$TEMP_FILE_A"; fi + if [ -n "$TEMP_FILE_B" ]; then rm -f "$TEMP_FILE_B"; fi + if [ -n "$CURRENT_TEMP" ]; then rm -f "$CURRENT_TEMP"; fi + clear_validation + if [ -n "$STAGE" ] && safe_directory "$STAGE"; then + rm -f "$STAGE/private.pem" "$STAGE/public.der" "$STAGE/identity.json" + rm -f "$STAGE/manifest.jcs" "$STAGE/signature.bin" "$STAGE/bundle.json" + rm -f "$STAGE/payload.bin" "$STAGE/complete" "$STAGE/decision.json" + rm -f "$STAGE/incoming" "$STAGE/previous" "$STAGE/previous-current.json" + rm -f "$STAGE/transaction.json" + if safe_directory "$STAGE/bundle"; then + rm -f "$STAGE/bundle/bundle.json" "$STAGE/bundle/payload.bin" "$STAGE/bundle/complete" + rmdir "$STAGE/bundle" 2>/dev/null || true + fi + rmdir "$STAGE" 2>/dev/null || true + fi + unlock +} +trap cleanup_ephemeral EXIT HUP INT TERM + +regular_file() { [ -f "$1" ] && [ ! -L "$1" ]; } +safe_directory() { [ -d "$1" ] && [ ! -L "$1" ]; } + +safe_leaf() { + [ "${#1}" -ge 1 ] && [ "${#1}" -le 96 ] || return 1 + case "$1" in .|..|.*|*[!A-Za-z0-9._:-]*) return 1 ;; esac +} + +safe_bundle_id() { + [ "${#1}" -eq 85 ] || return 1 + printf '%s\n' "$1" | grep -Eq '^bloom-save-bundle-v1:[0-9a-f]{64}$' +} + +safe_device_id() { + [ "${#1}" -eq 48 ] || return 1 + printf '%s\n' "$1" | grep -Eq '^bloom-device-v1:[0-9a-f]{32}$' +} + +safe_relative_path() { + [ -n "$1" ] && [ "${#1}" -le 512 ] || return 1 + case "$1" in /*|*\\*) return 1 ;; esac + printf '%s\n' "$1" | grep -Eq '(^|/)\.\.?(/|$)' && return 1 + old_ifs=$IFS + IFS=/ + set -- $1 + IFS=$old_ifs + [ "$#" -ge 1 ] || return 1 + for component do + [ -n "$component" ] || return 1 + done +} + +path_regular_under() { + base=$1 + relative=$2 + safe_directory "$base" && safe_relative_path "$relative" || return 1 + cursor=$base + old_ifs=$IFS + IFS=/ + set -- $relative + IFS=$old_ifs + while [ "$#" -gt 1 ]; do + cursor=$cursor/$1 + safe_directory "$cursor" || return 1 + shift + done + regular_file "$cursor/$1" +} + +path_parent_under() { + base=$1 + relative=$2 + safe_directory "$base" && safe_relative_path "$relative" || return 1 + cursor=$base + old_ifs=$IFS + IFS=/ + set -- $relative + IFS=$old_ifs + while [ "$#" -gt 1 ]; do + cursor=$cursor/$1 + safe_directory "$cursor" || return 1 + shift + done + DESTINATION=$cursor/$1 + if [ -e "$DESTINATION" ]; then regular_file "$DESTINATION" || return 1; fi +} + +ensure_root() { + [ -x "$JQ" ] && [ -x "$OPENSSL" ] || return 1 + bloom_root=$SD_ROOT/.bloom + if [ ! -e "$bloom_root" ]; then mkdir "$bloom_root" || return 1; fi + safe_directory "$bloom_root" || return 1 + if [ ! -e "$SYNC_ROOT" ]; then mkdir "$SYNC_ROOT" || return 1; fi + safe_directory "$SYNC_ROOT" || return 1 + if [ -e "$SYNC_ROOT/identity" ]; then safe_directory "$SYNC_ROOT/identity" || return 1; fi + for directory in trust exports candidates quarantine current transactions; do + if [ ! -e "$SYNC_ROOT/$directory" ]; then mkdir "$SYNC_ROOT/$directory" || return 1; fi + safe_directory "$SYNC_ROOT/$directory" || return 1 + done +} + +lock() { + ensure_root || return 1 + mkdir "$SYNC_ROOT/.lock" 2>/dev/null || return 1 + LOCKED=1 +} + +read_identity() { + safe_directory "$SYNC_ROOT/identity" && + regular_file "$SYNC_ROOT/identity/identity.json" && + regular_file "$SYNC_ROOT/identity/private.pem" && + regular_file "$SYNC_ROOT/identity/public.der" || return 1 + IDENTITY=$("$JQ" -cer ' + select(type == "object" and keys == ["device_id","key_epoch","key_id","schema"] and + .schema == 1 and (.device_id | test("^bloom-device-v1:[0-9a-f]{32}$")) and + .key_epoch == 1 and + (.key_id | test("^bloom-sync-key-v1:[0-9a-f]{64}$"))) + ' "$SYNC_ROOT/identity/identity.json" 2>/dev/null) || return 1 + IDENTITY_DEVICE=$(printf '%s\n' "$IDENTITY" | "$JQ" -er .device_id) || return 1 + IDENTITY_KEY=$(printf '%s\n' "$IDENTITY" | "$JQ" -er .key_id) || return 1 + raw_hash=$(tail -c 32 "$SYNC_ROOT/identity/public.der" | sha256sum | awk '{print $1}') || return 1 + [ "$IDENTITY_KEY" = "bloom-sync-key-v1:$raw_hash" ] && + run_openssl pkey -in "$SYNC_ROOT/identity/private.pem" -pubout -outform DER 2>/dev/null | + cmp -s - "$SYNC_ROOT/identity/public.der" +} + +identity_init() { + operation=identity_init + lock || fail "$operation" unavailable + if read_identity; then + unlock + result true "$operation" already_initialized + return + fi + [ ! -e "$SYNC_ROOT/identity" ] || fail "$operation" identity_invalid + stage=$SYNC_ROOT/.identity.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" stage_failed + run_openssl genpkey -algorithm ED25519 -out "$stage/private.pem" >/dev/null 2>&1 || + fail "$operation" key_generation_failed + run_openssl pkey -in "$stage/private.pem" -pubout -outform DER -out "$stage/public.der" >/dev/null 2>&1 || + fail "$operation" key_generation_failed + [ "$(wc -c <"$stage/public.der" | tr -d ' ')" -eq 44 ] || fail "$operation" key_generation_failed + device_hex=$(run_openssl rand -hex 16 2>/dev/null) || fail "$operation" random_failed + public_hash=$(tail -c 32 "$stage/public.der" | sha256sum | awk '{print $1}') || + fail "$operation" key_generation_failed + "$JQ" -cn --arg device_id "bloom-device-v1:$device_hex" \ + --arg key_id "bloom-sync-key-v1:$public_hash" \ + '{schema:1,device_id:$device_id,key_epoch:1,key_id:$key_id}' >"$stage/identity.json" || + fail "$operation" identity_write_failed + chmod 600 "$stage/private.pem" && chmod 400 "$stage/public.der" "$stage/identity.json" && + chmod 500 "$stage" || + fail "$operation" identity_write_failed + sync + mv "$stage" "$SYNC_ROOT/identity" || fail "$operation" identity_write_failed + STAGE= + sync + read_identity || fail "$operation" identity_invalid + unlock + result true "$operation" initialized +} + +trust_handle() { printf '%s' "$1" | sha256sum | awk '{print $1}'; } + +pair_authorize() { + operation=pair_authorize + device_id=$1 + epoch=$2 + public_key=$3 + safe_device_id "$device_id" || fail "$operation" invalid_device + case "$epoch" in ''|*[!0-9]*) fail "$operation" invalid_epoch ;; esac + [ "$epoch" -ge 1 ] && [ "$epoch" -le 2147483647 ] || fail "$operation" invalid_epoch + regular_file "$public_key" && [ "$(wc -c <"$public_key" | tr -d ' ')" -eq 44 ] || + fail "$operation" invalid_public_key + run_openssl pkey -pubin -inform DER -in "$public_key" -noout >/dev/null 2>&1 || + fail "$operation" invalid_public_key + lock || fail "$operation" unavailable + handle=$(trust_handle "$device_id") || fail "$operation" unavailable + metadata=$SYNC_ROOT/trust/$handle.json + stored_key=$SYNC_ROOT/trust/$handle.der + key_hash=$(tail -c 32 "$public_key" | sha256sum | awk '{print $1}') || + fail "$operation" invalid_public_key + key_id=bloom-sync-key-v1:$key_hash + if [ -e "$metadata" ] || [ -e "$stored_key" ]; then + regular_file "$metadata" && regular_file "$stored_key" || + fail "$operation" clone_or_rotation_requires_repair + "$JQ" -e --arg device "$device_id" --arg key "$key_id" --argjson epoch "$epoch" ' + .schema == 1 and .device_id == $device and .key_id == $key and + .key_epoch == $epoch and .authorized == true + ' "$metadata" >/dev/null 2>&1 || fail "$operation" clone_or_rotation_requires_repair + cmp -s "$public_key" "$stored_key" || fail "$operation" clone_or_rotation_requires_repair + unlock + result true "$operation" already_authorized + return + fi + temporary=$SYNC_ROOT/trust/.$handle.$$ + TEMP_FILE_A=$temporary.der + TEMP_FILE_B=$temporary.json + cp "$public_key" "$temporary.der" || fail "$operation" write_failed + "$JQ" -cn --arg device "$device_id" --arg key "$key_id" --argjson epoch "$epoch" \ + '{schema:1,device_id:$device,key_epoch:$epoch,key_id:$key,authorized:true}' >"$temporary.json" || + fail "$operation" write_failed + chmod 400 "$temporary.der" "$temporary.json" || fail "$operation" write_failed + sync + mv "$temporary.der" "$stored_key" && mv "$temporary.json" "$metadata" || + fail "$operation" write_failed + TEMP_FILE_A= + TEMP_FILE_B= + sync + unlock + result true "$operation" authorized +} + +pair_deauthorize() { + operation=pair_deauthorize + device_id=$1 + safe_device_id "$device_id" || fail "$operation" invalid_device + lock || fail "$operation" unavailable + handle=$(trust_handle "$device_id") || fail "$operation" unavailable + metadata=$SYNC_ROOT/trust/$handle.json + regular_file "$metadata" || fail "$operation" unknown_device + temporary=$SYNC_ROOT/trust/.$handle.$$ + TEMP_FILE_A=$temporary + "$JQ" -ce --arg device "$device_id" ' + select(.schema == 1 and .device_id == $device) | .authorized = false + ' "$metadata" >"$temporary" || fail "$operation" trust_invalid + chmod 400 "$temporary" || fail "$operation" write_failed + sync + mv "$temporary" "$metadata" || fail "$operation" write_failed + TEMP_FILE_A= + sync + unlock + result true "$operation" deauthorized +} + +manifest_valid() { + manifest_file=$1 + regular_file "$manifest_file" && + [ "$(wc -c <"$manifest_file" | tr -d ' ')" -le "$MAX_MANIFEST_BYTES" ] || return 1 + "$JQ" -e ' + def exact($names): type == "object" and (keys_unsorted | sort) == ($names | sort); + def safe: type == "string" and length <= 96 and test("^[a-z0-9][a-z0-9._-]*$"); + def sha: type == "string" and test("^[0-9a-f]{64}$"); + exact(["schema","bloom_game_id","content_identity","system_id","save_kind", + "compatibility_id","backend","source_device","lineage_id","generation", + "parent_payload_sha256","payload","software"] + + (if has("created_at") then ["created_at"] else [] end)) and + .schema == 1 and (.bloom_game_id | test("^bloom-game-v1:[0-9a-f]{64}$")) and + (.content_identity | type == "string" and length <= 160 and + test("^bloom-content:[a-z][a-z0-9-]{0,63}:v[1-9][0-9]*:[0-9a-f]{64}$")) and + (.system_id | safe) and (.save_kind == "save" or .save_kind == "state") and + (.compatibility_id | safe) and + (.backend | + if (.emulator_id | startswith("libretro:")) then + exact(["emulator_id","core_id","component_id","component_sha256"]) and + (.emulator_id | test("^libretro:[a-z0-9][a-z0-9._-]*$")) and + (.core_id | safe) and (.component_id | safe) and (.component_sha256 | sha) + else + exact(["emulator_id","component_id","component_sha256"]) and + (.emulator_id | test("^standalone:[a-z0-9][a-z0-9._-]*$")) and + (.component_id | safe) and (.component_sha256 | sha) + end) and + (.source_device | exact(["device_id","key_epoch"]) and + (.device_id | test("^bloom-device-v1:[0-9a-f]{32}$")) and + (.key_epoch | type == "number" and floor == . and . >= 1 and . <= 2147483647)) and + (.lineage_id | test("^bloom-save-lineage-v1:[0-9a-f]{32}$")) and + (.generation | type == "number" and floor == . and . >= 1 and . <= 9007199254740991) and + (.parent_payload_sha256 | test("^(none|[0-9a-f]{64})$")) and + (if .generation == 1 then .parent_payload_sha256 == "none" + else .parent_payload_sha256 != "none" end) and + (.payload | exact(["format","sha256","bytes"]) and + .format == "bloom-save-payload-v1" and (.sha256 | sha) and + (.bytes | type == "number" and floor == . and . >= 0 and . <= 67108864)) and + (.software | exact(["bloomos_version","source_commit"]) and + (.bloomos_version | type == "string" and length <= 64 and + test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")) and + (.source_commit | test("^[0-9a-f]{40}$"))) and + ((has("created_at") | not) or + (.created_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"))) + ' "$manifest_file" >/dev/null 2>&1 +} + +compatibility_valid() { + manifest_file=$1 + regular_file "$CORE_POLICY" || return 1 + "$JQ" -e --slurpfile manifest "$manifest_file" ' + $manifest[0] as $m | + .schema == 1 and .policy_id == "bloomos-core-selection-v1" and + any(.systems[]; + .id == $m.system_id and + any(.candidates[]; + .emulator_id == $m.backend.emulator_id and + ((($m.backend.emulator_id | startswith("libretro:")) and .id == $m.backend.core_id) or + (($m.backend.emulator_id | startswith("standalone:")) and .id == $m.backend.component_id)) and + (if $m.save_kind == "save" then .save_compatibility_id + else .state_compatibility_id end) == $m.compatibility_id)) + ' "$CORE_POLICY" >/dev/null 2>&1 +} + +draft_valid() { + "$JQ" -e ' + type == "object" and + (keys_unsorted | sort) == + (["schema","bloom_game_id","content_identity","system_id","save_kind", + "compatibility_id","backend","lineage_id","generation","parent_payload_sha256", + "software"] + (if has("created_at") then ["created_at"] else [] end) | sort) and + .schema == 1 + ' "$1" >/dev/null 2>&1 +} + +export_bundle() { + operation=export + draft=$1 + relative=$2 + export_id=$3 + safe_leaf "$export_id" || fail "$operation" invalid_export_id + regular_file "$draft" && + [ "$(wc -c <"$draft" | tr -d ' ')" -le "$MAX_MANIFEST_BYTES" ] && + draft_valid "$draft" || fail "$operation" invalid_draft + ensure_root && read_identity || fail "$operation" identity_unavailable + inactive_session || fail "$operation" active_session + kind=$("$JQ" -er .save_kind "$draft" 2>/dev/null) || fail "$operation" invalid_draft + case "$kind" in + save) payload_root=$SAVE_ROOT/saves ;; + state) payload_root=$SAVE_ROOT/states ;; + *) fail "$operation" invalid_draft ;; + esac + destination=$SYNC_ROOT/exports/$export_id + lock || fail "$operation" busy + inactive_session || fail "$operation" active_session + path_regular_under "$payload_root" "$relative" || fail "$operation" payload_unsafe + payload=$payload_root/$relative + payload_bytes=$(wc -c <"$payload" | tr -d ' ') || fail "$operation" payload_unavailable + [ "$payload_bytes" -le "$MAX_PAYLOAD_BYTES" ] || fail "$operation" payload_oversized + payload_hash=$(sha256sum "$payload" | awk '{print $1}') || fail "$operation" payload_unavailable + [ ! -e "$destination" ] || fail "$operation" export_exists + stage=$SYNC_ROOT/exports/.$export_id.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" stage_failed + "$JQ" -cS --arg device "$IDENTITY_DEVICE" --argjson epoch 1 --arg hash "$payload_hash" \ + --argjson bytes "$payload_bytes" ' + . + {source_device:{device_id:$device,key_epoch:$epoch}, + payload:{format:"bloom-save-payload-v1",sha256:$hash,bytes:$bytes}} + ' "$draft" >"$stage/manifest.jcs" || fail "$operation" invalid_draft + manifest_valid "$stage/manifest.jcs" && compatibility_valid "$stage/manifest.jcs" || + fail "$operation" incompatible + manifest_hash=$(sha256sum "$stage/manifest.jcs" | awk '{print $1}') || + fail "$operation" stage_failed + bundle_id=bloom-save-bundle-v1:$manifest_hash + run_openssl pkeyutl -sign -rawin -inkey "$SYNC_ROOT/identity/private.pem" \ + -in "$stage/manifest.jcs" -out "$stage/signature.bin" >/dev/null 2>&1 || + fail "$operation" signing_failed + signature=$(run_openssl base64 -A -in "$stage/signature.bin" 2>/dev/null | + tr '+/' '-_' | tr -d '=') || fail "$operation" signing_failed + [ "${#signature}" -eq 86 ] || fail "$operation" signing_failed + "$JQ" -cn --slurpfile manifest "$stage/manifest.jcs" --arg bundle "$bundle_id" \ + --arg key "$IDENTITY_KEY" --arg signature "$signature" ' + {schema:1,bundle_id:$bundle,manifest:$manifest[0],authentication:{ + scheme:"ed25519-rfc8032",key_id:$key,key_epoch:1, + signed_bytes:"rfc8785-jcs-manifest-v1",signature:$signature}} + ' >"$stage/bundle.json" || fail "$operation" stage_failed + cp "$payload" "$stage/payload.bin" || fail "$operation" stage_failed + [ "$(wc -c <"$stage/payload.bin" | tr -d ' ')" = "$payload_bytes" ] && + [ "$(sha256sum "$stage/payload.bin" | awk '{print $1}')" = "$payload_hash" ] || + fail "$operation" payload_changed + : >"$stage/complete" || fail "$operation" stage_failed + rm -f "$stage/manifest.jcs" "$stage/signature.bin" + chmod 400 "$stage/bundle.json" "$stage/payload.bin" "$stage/complete" || + fail "$operation" stage_failed + sync + chmod 500 "$stage" || fail "$operation" stage_failed + mv "$stage" "$destination" || fail "$operation" stage_failed + STAGE= + sync + unlock + result true "$operation" exported \ + "\"bundle_id\":\"$bundle_id\",\"export_id\":\"$export_id\"" +} + +bundle_files_valid() { + bundle=$1 + safe_directory "$bundle" && regular_file "$bundle/bundle.json" && + regular_file "$bundle/payload.bin" && regular_file "$bundle/complete" || return 1 + [ "$(find "$bundle" -mindepth 1 -maxdepth 1 -print | wc -l | tr -d ' ')" -eq 3 ] || return 1 + [ "$(wc -c <"$bundle/bundle.json" | tr -d ' ')" -le "$MAX_MANIFEST_BYTES" ] && + [ "$(wc -c <"$bundle/payload.bin" | tr -d ' ')" -le "$MAX_PAYLOAD_BYTES" ] +} + +envelope_valid() { + "$JQ" -e ' + type == "object" and + (keys_unsorted | sort) == ["authentication","bundle_id","manifest","schema"] and + .schema == 1 and (.bundle_id | test("^bloom-save-bundle-v1:[0-9a-f]{64}$")) and + (.authentication | type == "object" and + (keys_unsorted | sort) == ["key_epoch","key_id","scheme","signature","signed_bytes"] and + .scheme == "ed25519-rfc8032" and + (.key_id | test("^bloom-sync-key-v1:[0-9a-f]{64}$")) and + (.key_epoch | type == "number" and floor == . and . >= 1 and . <= 2147483647) and + .signed_bytes == "rfc8785-jcs-manifest-v1" and + (.signature | test("^[A-Za-z0-9_-]{86}$"))) + ' "$1" >/dev/null 2>&1 +} + +trusted_public_key() { + manifest_file=$1 + envelope=$2 + source_device=$("$JQ" -er .source_device.device_id "$manifest_file") || return 1 + source_epoch=$("$JQ" -er .source_device.key_epoch "$manifest_file") || return 1 + key_id=$("$JQ" -er .authentication.key_id "$envelope") || return 1 + auth_epoch=$("$JQ" -er .authentication.key_epoch "$envelope") || return 1 + [ "$source_epoch" = "$auth_epoch" ] || return 1 + if read_identity && [ "$source_device" = "$IDENTITY_DEVICE" ] && + [ "$key_id" = "$IDENTITY_KEY" ] && [ "$source_epoch" -eq 1 ]; then + PUBLIC_KEY=$SYNC_ROOT/identity/public.der + return 0 + fi + handle=$(trust_handle "$source_device") || return 1 + metadata=$SYNC_ROOT/trust/$handle.json + key=$SYNC_ROOT/trust/$handle.der + regular_file "$metadata" && regular_file "$key" || return 1 + "$JQ" -e --arg device "$source_device" --arg key "$key_id" --argjson epoch "$source_epoch" ' + .schema == 1 and .device_id == $device and .key_id == $key and + .key_epoch == $epoch and .authorized == true + ' "$metadata" >/dev/null 2>&1 || return 1 + PUBLIC_KEY=$key +} + +clear_validation() { + rm -f "$SYNC_ROOT/.validate.$$/manifest.jcs" "$SYNC_ROOT/.validate.$$/signature.bin" + rmdir "$SYNC_ROOT/.validate.$$" 2>/dev/null || true +} + +validate_bundle_internal() { + bundle=$1 + bundle_files_valid "$bundle" || return 10 + envelope=$bundle/bundle.json + envelope_valid "$envelope" || return 11 + work=$SYNC_ROOT/.validate.$$ + [ ! -e "$work" ] && mkdir "$work" || return 12 + "$JQ" -cS .manifest "$envelope" >"$work/manifest.jcs" || return 11 + manifest_valid "$work/manifest.jcs" || return 11 + expected_bytes=$("$JQ" -er .payload.bytes "$work/manifest.jcs") || return 11 + expected_payload=$("$JQ" -er .payload.sha256 "$work/manifest.jcs") || return 11 + actual_bytes=$(wc -c <"$bundle/payload.bin" | tr -d ' ') || return 10 + [ "$actual_bytes" = "$expected_bytes" ] || return 13 + actual_payload=$(sha256sum "$bundle/payload.bin" | awk '{print $1}') || return 13 + [ "$actual_payload" = "$expected_payload" ] || return 13 + expected_bundle=bloom-save-bundle-v1:$(sha256sum "$work/manifest.jcs" | awk '{print $1}') || + return 13 + actual_bundle=$("$JQ" -er .bundle_id "$envelope") || return 11 + [ "$actual_bundle" = "$expected_bundle" ] || return 13 + trusted_public_key "$work/manifest.jcs" "$envelope" || return 14 + signature=$("$JQ" -er .authentication.signature "$envelope") || return 11 + printf '%s==' "$signature" | tr '_-' '/+' | + run_openssl base64 -d -A -out "$work/signature.bin" 2>/dev/null || return 15 + [ "$(wc -c <"$work/signature.bin" | tr -d ' ')" -eq 64 ] || return 15 + run_openssl pkeyutl -verify -pubin -keyform DER -inkey "$PUBLIC_KEY" -rawin \ + -in "$work/manifest.jcs" -sigfile "$work/signature.bin" >/dev/null 2>&1 || return 15 + compatibility_valid "$work/manifest.jcs" || return 16 + VALID_BUNDLE_ID=$actual_bundle + VALID_PAYLOAD_HASH=$actual_payload + VALID_MANIFEST=$work/manifest.jcs +} + +validation_state() { + case "$1" in + 10) printf bounds_invalid ;; + 11) printf schema_invalid ;; + 12) printf unavailable ;; + 13) printf identity_mismatch ;; + 14) printf unauthorized ;; + 15) printf signature_invalid ;; + 16) printf incompatible ;; + *) printf invalid ;; + esac +} + +validate_bundle() { + operation=validate + ensure_root || fail "$operation" unavailable + validate_bundle_internal "$1" + status=$? + if [ "$status" -ne 0 ]; then + clear_validation + fail "$operation" "$(validation_state "$status")" + fi + bundle_id=$VALID_BUNDLE_ID + clear_validation + result true "$operation" verified "\"bundle_id\":\"$bundle_id\"" +} + +record_handle() { + "$JQ" -cS '{bloom_game_id,save_kind}' "$1" | sha256sum | awk '{print $1}' +} + +classify_bundle() { + manifest=$1 + bundle_id=$2 + payload_hash=$3 + handle=$(record_handle "$manifest") || return 1 + current=$SYNC_ROOT/current/$handle.json + if ! regular_file "$current"; then + CLASSIFICATION=new_lineage + CURRENT_RECORD= + return 0 + fi + "$JQ" -e ' + .schema == 1 and (.bundle | type == "object") and + (.ancestry | type == "array" and length <= 64) + ' "$current" >/dev/null 2>&1 || return 1 + CURRENT_RECORD=$current + current_id=$("$JQ" -er .bundle.bundle_id "$current") || return 1 + current_payload=$("$JQ" -er .bundle.manifest.payload.sha256 "$current") || return 1 + if [ "$current_id" = "$bundle_id" ]; then + if [ "$current_payload" = "$payload_hash" ]; then + CLASSIFICATION=exact_replay + else + CLASSIFICATION=bundle_id_collision + fi + return 0 + fi + "$JQ" -n -e --slurpfile incoming "$manifest" --slurpfile current "$current" ' + $incoming[0] as $i | $current[0].bundle.manifest as $c | + $i.bloom_game_id == $c.bloom_game_id and + $i.content_identity == $c.content_identity and + $i.system_id == $c.system_id and $i.save_kind == $c.save_kind and + $i.compatibility_id == $c.compatibility_id + ' >/dev/null 2>&1 || { CLASSIFICATION=incompatible; return 0; } + incoming_lineage=$("$JQ" -er .lineage_id "$manifest") || return 1 + current_lineage=$("$JQ" -er .bundle.manifest.lineage_id "$current") || return 1 + if [ "$incoming_lineage" != "$current_lineage" ]; then + CLASSIFICATION=unrelated_lineage + return 0 + fi + incoming_generation=$("$JQ" -er .generation "$manifest") || return 1 + current_generation=$("$JQ" -er .bundle.manifest.generation "$current") || return 1 + incoming_parent=$("$JQ" -er .parent_payload_sha256 "$manifest") || return 1 + if [ "$incoming_generation" -eq $((current_generation + 1)) ]; then + if [ "$incoming_parent" = "$current_payload" ]; then + CLASSIFICATION=fast_forward + else + CLASSIFICATION=fork + fi + elif [ "$incoming_generation" -gt $((current_generation + 1)) ]; then + CLASSIFICATION=history_required + elif [ "$incoming_generation" -lt "$current_generation" ]; then + if "$JQ" -e --arg payload "$payload_hash" \ + '.ancestry | index($payload) != null' "$current" >/dev/null 2>&1; then + CLASSIFICATION=stale_ancestor + else + CLASSIFICATION=history_required + fi + else + CLASSIFICATION=fork + fi +} + +copy_bundle() { + copy_source=$1 + copy_destination=$2 + mkdir "$copy_destination" || return 1 + cp "$copy_source/bundle.json" "$copy_destination/bundle.json" && + cp "$copy_source/payload.bin" "$copy_destination/payload.bin" && + cp "$copy_source/complete" "$copy_destination/complete" || return 1 + chmod 400 "$copy_destination/bundle.json" "$copy_destination/payload.bin" \ + "$copy_destination/complete" +} + +staged_bundle_matches() { + staged_bundle=$1 + expected_bundle_id=$2 + expected_payload_hash=$3 + validate_bundle_internal "$staged_bundle" + staged_status=$? + if [ "$staged_status" -ne 0 ]; then + clear_validation + return 1 + fi + [ "$VALID_BUNDLE_ID" = "$expected_bundle_id" ] && + [ "$VALID_PAYLOAD_HASH" = "$expected_payload_hash" ] + staged_status=$? + clear_validation + return "$staged_status" +} + +import_bundle() { + operation=import + source=$1 + ensure_root || fail "$operation" unavailable + validate_bundle_internal "$source" + status=$? + if [ "$status" -ne 0 ]; then + clear_validation + fail "$operation" "$(validation_state "$status")" + fi + bundle_id=$VALID_BUNDLE_ID + payload_hash=$VALID_PAYLOAD_HASH + manifest_copy=$SYNC_ROOT/.import-manifest.$$ + TEMP_MANIFEST=$manifest_copy + cp "$VALID_MANIFEST" "$manifest_copy" || { clear_validation; fail "$operation" unavailable; } + clear_validation + lock || fail "$operation" busy + classify_bundle "$manifest_copy" "$bundle_id" "$payload_hash" || + { rm -f "$manifest_copy"; fail "$operation" state_invalid; } + rm -f "$manifest_copy" + TEMP_MANIFEST= + case "$CLASSIFICATION" in + new_lineage|fast_forward) + destination=$SYNC_ROOT/candidates/$bundle_id + if [ -e "$destination" ]; then + safe_directory "$destination" && regular_file "$destination/decision.json" || + fail "$operation" candidate_invalid + existing=$("$JQ" -er .classification "$destination/decision.json" 2>/dev/null) || + fail "$operation" candidate_invalid + [ "$existing" = "$CLASSIFICATION" ] || fail "$operation" candidate_invalid + unlock + result true "$operation" already_staged \ + "\"classification\":\"$CLASSIFICATION\"" + return + fi + stage=$SYNC_ROOT/candidates/.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" stage_failed + copy_bundle "$source" "$stage/bundle" || fail "$operation" stage_failed + staged_bundle_matches "$stage/bundle" "$bundle_id" "$payload_hash" || + fail "$operation" source_changed + "$JQ" -cn --arg classification "$CLASSIFICATION" \ + '{schema:1,classification:$classification,resolved:false}' >"$stage/decision.json" || + fail "$operation" stage_failed + chmod 400 "$stage/decision.json" && chmod 500 "$stage/bundle" || + fail "$operation" stage_failed + sync + chmod 500 "$stage" || fail "$operation" stage_failed + mv "$stage" "$destination" || fail "$operation" stage_failed + STAGE= + sync + state=staged + ;; + exact_replay|stale_ancestor) + state=noop + ;; + bundle_id_collision|incompatible|fork|history_required|unrelated_lineage) + destination=$SYNC_ROOT/quarantine/$bundle_id + if [ ! -e "$destination" ]; then + stage=$SYNC_ROOT/quarantine/.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" quarantine_failed + copy_bundle "$source" "$stage/bundle" || fail "$operation" quarantine_failed + staged_bundle_matches "$stage/bundle" "$bundle_id" "$payload_hash" || + fail "$operation" source_changed + "$JQ" -cn --arg classification "$CLASSIFICATION" \ + '{schema:1,classification:$classification,resolved:false}' >"$stage/decision.json" || + fail "$operation" quarantine_failed + chmod 400 "$stage/decision.json" && chmod 500 "$stage/bundle" || + fail "$operation" quarantine_failed + sync + chmod 500 "$stage" || fail "$operation" quarantine_failed + mv "$stage" "$destination" || fail "$operation" quarantine_failed + STAGE= + sync + fi + state=review_required + ;; + *) fail "$operation" state_invalid ;; + esac + unlock + result true "$operation" "$state" "\"classification\":\"$CLASSIFICATION\"" +} + +resolve_bundle() { + operation=resolve + bundle_id=$1 + decision=$2 + safe_bundle_id "$bundle_id" || fail "$operation" invalid_bundle + case "$decision" in accept_incoming|keep_current) ;; *) fail "$operation" invalid_decision ;; esac + lock || fail "$operation" busy + review=$SYNC_ROOT/quarantine/$bundle_id + safe_directory "$review" && safe_directory "$review/bundle" && + regular_file "$review/decision.json" || fail "$operation" review_unavailable + classification=$("$JQ" -er .classification "$review/decision.json" 2>/dev/null) || + fail "$operation" review_invalid + case "$classification" in incompatible|fork|unrelated_lineage) ;; + bundle_id_collision|history_required) fail "$operation" non_resolvable ;; + *) fail "$operation" review_invalid ;; + esac + if [ "$decision" = keep_current ]; then + marker=$review/resolution.json + [ ! -e "$marker" ] || fail "$operation" already_resolved + TEMP_FILE_A=$marker.tmp.$$ + "$JQ" -cn --arg classification "$classification" \ + '{schema:1,classification:$classification,decision:"keep_current"}' >"$marker.tmp.$$" || + fail "$operation" resolution_failed + chmod 400 "$marker.tmp.$$" && sync && mv "$marker.tmp.$$" "$marker" || + fail "$operation" resolution_failed + TEMP_FILE_A= + sync + unlock + result true "$operation" current_retained "\"classification\":\"$classification\"" + return + fi + destination=$SYNC_ROOT/candidates/$bundle_id + [ ! -e "$destination" ] || fail "$operation" candidate_exists + stage=$SYNC_ROOT/candidates/.resolve.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" resolution_failed + copy_bundle "$review/bundle" "$stage/bundle" || fail "$operation" resolution_failed + "$JQ" -cn --arg classification "$classification" \ + '{schema:1,classification:$classification,resolved:true}' >"$stage/decision.json" || + fail "$operation" resolution_failed + chmod 400 "$stage/decision.json" && chmod 500 "$stage/bundle" && chmod 500 "$stage" || + fail "$operation" resolution_failed + sync + mv "$stage" "$destination" || fail "$operation" resolution_failed + STAGE= + sync + unlock + result true "$operation" staged "\"classification\":\"$classification\"" +} + +inactive_session() { + state=IDLE + if [ -r "$SESSION_STATE" ]; then IFS= read -r state <"$SESSION_STATE" || true; fi + case "$state" in IDLE|STOPPED|FAILED) return 0 ;; *) return 1 ;; esac +} + +write_current_record() { + manifest=$1 + envelope=$2 + previous=$3 + handle=$(record_handle "$manifest") || return 1 + destination=$SYNC_ROOT/current/$handle.json + if [ -e "$destination" ] && ! regular_file "$destination"; then return 1; fi + CURRENT_DESTINATION=$destination + CURRENT_TEMP=$destination.tmp.$$ + if [ -n "$previous" ]; then + "$JQ" -cn --slurpfile incoming "$envelope" --slurpfile old "$previous" ' + (reduce ($old[0].ancestry + [$old[0].bundle.manifest.payload.sha256])[] as $hash + ([]; if index($hash) == null then . + [$hash] else . end) | + .[-64:]) as $ancestry | + {schema:1,bundle:$incoming[0],ancestry:$ancestry} + ' >"$CURRENT_TEMP" || return 1 + else + "$JQ" -cn --slurpfile incoming "$envelope" \ + '{schema:1,bundle:$incoming[0],ancestry:[]}' >"$CURRENT_TEMP" || return 1 + fi + chmod 400 "$CURRENT_TEMP" && sync && mv "$CURRENT_TEMP" "$destination" || return 1 + CURRENT_TEMP= + CURRENT_WRITTEN=1 +} + +publish_bundle() { + operation=publish + bundle_id=$1 + relative=$2 + expected=$3 + safe_bundle_id "$bundle_id" || fail "$operation" invalid_bundle + case "$expected" in new_lineage|fast_forward|incompatible|fork|unrelated_lineage) ;; + *) fail "$operation" invalid_resolution ;; + esac + ensure_root || fail "$operation" unavailable + lock || fail "$operation" busy + inactive_session || fail "$operation" active_session + candidate=$SYNC_ROOT/candidates/$bundle_id + safe_directory "$candidate" && safe_directory "$candidate/bundle" && + regular_file "$candidate/decision.json" || fail "$operation" candidate_unavailable + classification=$("$JQ" -er .classification "$candidate/decision.json" 2>/dev/null) || + fail "$operation" candidate_invalid + [ "$classification" = "$expected" ] || fail "$operation" resolution_mismatch + resolved=$("$JQ" -r 'if .resolved == true then "true" elif .resolved == false then "false" else error("invalid") end' \ + "$candidate/decision.json" 2>/dev/null) || + fail "$operation" candidate_invalid + case "$classification" in + new_lineage|fast_forward) ;; + *) [ "$resolved" = true ] || fail "$operation" resolution_required ;; + esac + validate_bundle_internal "$candidate/bundle" + status=$? + if [ "$status" -ne 0 ]; then clear_validation; fail "$operation" candidate_invalid; fi + [ "$VALID_BUNDLE_ID" = "$bundle_id" ] || + { clear_validation; fail "$operation" candidate_invalid; } + kind=$("$JQ" -er .save_kind "$VALID_MANIFEST") || + { clear_validation; fail "$operation" candidate_invalid; } + manifest_copy=$SYNC_ROOT/.publish-manifest.$$ + TEMP_MANIFEST=$manifest_copy + cp "$VALID_MANIFEST" "$manifest_copy" || + { clear_validation; fail "$operation" unavailable; } + clear_validation + case "$kind" in + save) payload_root=$SAVE_ROOT/saves ;; + state) payload_root=$SAVE_ROOT/states ;; + *) rm -f "$manifest_copy"; fail "$operation" candidate_invalid ;; + esac + path_parent_under "$payload_root" "$relative" || + { rm -f "$manifest_copy"; fail "$operation" destination_unsafe; } + payload_hash=$("$JQ" -er .payload.sha256 "$manifest_copy") || + fail "$operation" candidate_invalid + classify_bundle "$manifest_copy" "$bundle_id" "$payload_hash" || + fail "$operation" state_invalid + [ "$CLASSIFICATION" = "$classification" ] || fail "$operation" state_changed + transaction=$SYNC_ROOT/transactions/$bundle_id + [ ! -e "$transaction" ] || fail "$operation" transaction_exists + [ -x "$SNAPSHOT" ] || fail "$operation" snapshot_unavailable + snapshot_output=$(BLOOM_SD_ROOT="$SD_ROOT" BLOOM_JQ_BIN="$JQ" \ + "$SNAPSHOT" create sync-import 2>/dev/null) || fail "$operation" snapshot_failed + snapshot_id=$(printf '%s\n' "$snapshot_output" | "$JQ" -er \ + 'select(.schema == 1 and .status == "created") | .id') || fail "$operation" snapshot_failed + stage=$SYNC_ROOT/transactions/.$$ + STAGE=$stage + mkdir "$stage" || fail "$operation" transaction_failed + cp "$candidate/bundle/payload.bin" "$stage/incoming" || fail "$operation" transaction_failed + incoming_hash=$(sha256sum "$stage/incoming" | awk '{print $1}') || + fail "$operation" transaction_failed + expected_hash=$("$JQ" -er .payload.sha256 "$manifest_copy") || + fail "$operation" transaction_failed + [ "$incoming_hash" = "$expected_hash" ] || fail "$operation" transaction_failed + had_previous=false + if [ -e "$DESTINATION" ]; then + cp "$DESTINATION" "$stage/previous" || fail "$operation" transaction_failed + had_previous=true + fi + handle=$(record_handle "$manifest_copy") || fail "$operation" transaction_failed + previous_record=$SYNC_ROOT/current/$handle.json + if regular_file "$previous_record"; then + cp "$previous_record" "$stage/previous-current.json" || fail "$operation" transaction_failed + fi + temporary=$DESTINATION.bloom-sync.$$.tmp + PUBLISH_TEMP=$temporary + [ ! -e "$temporary" ] || fail "$operation" destination_unsafe + cp "$stage/incoming" "$temporary" && chmod 600 "$temporary" || + fail "$operation" publish_failed + [ "$(sha256sum "$temporary" | awk '{print $1}')" = "$expected_hash" ] || + fail "$operation" publish_failed + sync + mv -f "$temporary" "$DESTINATION" || fail "$operation" publish_failed + PUBLISH_TEMP= + LIVE_REPLACED=1 + LIVE_HAD_PREVIOUS=$had_previous + LIVE_EXPECTED_HASH=$expected_hash + sync + [ "$(sha256sum "$DESTINATION" | awk '{print $1}')" = "$expected_hash" ] || + fail "$operation" publish_failed + previous_for_record= + if regular_file "$stage/previous-current.json"; then + previous_for_record=$stage/previous-current.json + fi + write_current_record "$manifest_copy" "$candidate/bundle/bundle.json" "$previous_for_record" || + fail "$operation" state_write_failed + "$JQ" -cn --arg bundle "$bundle_id" --arg classification "$classification" \ + --arg snapshot "$snapshot_id" --argjson had_previous "$had_previous" ' + {schema:1,status:"applied",bundle_id:$bundle,classification:$classification, + snapshot_id:$snapshot,had_previous:$had_previous,rollback_retained:true} + ' >"$stage/transaction.json" || fail "$operation" transaction_failed + chmod 400 "$stage"/* || fail "$operation" transaction_failed + sync + chmod 500 "$stage" || fail "$operation" transaction_failed + mv "$stage" "$transaction" || fail "$operation" transaction_failed + STAGE= + LIVE_REPLACED=0 + CURRENT_WRITTEN=0 + sync + rm -f "$manifest_copy" + TEMP_MANIFEST= + unlock + result true "$operation" applied \ + "\"classification\":\"$classification\",\"rollback_retained\":true" +} + +status_command() { + operation=status + [ -x "$JQ" ] && [ -x "$OPENSSL" ] || fail "$operation" unavailable + if [ ! -e "$SYNC_ROOT" ]; then + result true "$operation" ready \ + '"initialized":false,"candidates":0,"quarantined":0,"transport":"none"' + return + fi + safe_directory "$SYNC_ROOT" && safe_directory "$SYNC_ROOT/candidates" && + safe_directory "$SYNC_ROOT/quarantine" || fail "$operation" state_invalid + if read_identity; then initialized=true; else initialized=false; fi + candidates=$(find "$SYNC_ROOT/candidates" -mindepth 1 -maxdepth 1 -type d ! -name '.*' | + wc -l | tr -d ' ') + quarantined=$(find "$SYNC_ROOT/quarantine" -mindepth 1 -maxdepth 1 -type d ! -name '.*' | + wc -l | tr -d ' ') + result true "$operation" ready \ + "\"initialized\":$initialized,\"candidates\":$candidates,\"quarantined\":$quarantined,\"transport\":\"none\"" +} + +usage() { + printf 'Usage: bloom-sync status\n' >&2 + printf ' bloom-sync identity init\n' >&2 + printf ' bloom-sync pair authorize DEVICE_ID KEY_EPOCH PUBLIC_KEY_DER\n' >&2 + printf ' bloom-sync pair deauthorize DEVICE_ID\n' >&2 + printf ' bloom-sync export MANIFEST_DRAFT SAVE_RELATIVE_PATH EXPORT_ID\n' >&2 + printf ' bloom-sync validate BUNDLE_DIRECTORY\n' >&2 + printf ' bloom-sync import BUNDLE_DIRECTORY\n' >&2 + printf ' bloom-sync resolve BUNDLE_ID {accept_incoming|keep_current}\n' >&2 + printf ' bloom-sync publish BUNDLE_ID SAVE_RELATIVE_PATH {new_lineage|fast_forward|incompatible|fork|unrelated_lineage}\n' >&2 +} + +case "$#:${1:-}:${2:-}" in + 1:status:) status_command ;; + 2:identity:init) identity_init ;; + 5:pair:authorize) pair_authorize "$3" "$4" "$5" ;; + 3:pair:deauthorize) pair_deauthorize "$3" ;; + 4:export:*) export_bundle "$2" "$3" "$4" ;; + 2:validate:*) validate_bundle "$2" ;; + 2:import:*) import_bundle "$2" ;; + 3:resolve:*) resolve_bundle "$2" "$3" ;; + 4:publish:*) publish_bundle "$2" "$3" "$4" ;; + *) usage; exit 2 ;; +esac diff --git a/static/build/.tmp_update/bin/bloomctl b/static/build/.tmp_update/bin/bloomctl index 968e59529..970f56084 100755 --- a/static/build/.tmp_update/bin/bloomctl +++ b/static/build/.tmp_update/bin/bloomctl @@ -15,6 +15,7 @@ BLOOM_UPDATE_ROLLBACK="${BLOOM_UPDATE_ROLLBACK_BIN:-$SCRIPT_DIR/bloom-update-rol BLOOM_UPDATE_CHANNEL="${BLOOM_UPDATE_CHANNEL_BIN:-$SCRIPT_DIR/bloom-update-channel}" BLOOM_SYSTEM_HEALTH="${BLOOM_SYSTEM_HEALTH_BIN:-$SCRIPT_DIR/bloom-health-system}" BLOOM_SAVE_SNAPSHOT="${BLOOM_SAVE_SNAPSHOT_BIN:-$SCRIPT_DIR/bloom-save-snapshot}" +BLOOM_SYNC="${BLOOM_SYNC_BIN:-$SCRIPT_DIR/bloom-sync}" BLOOM_DIAGNOSTICS_EXPORT="${BLOOM_DIAGNOSTICS_EXPORT_BIN:-$SCRIPT_DIR/bloom-diagnostics-export}" BLOOM_GAME_SMOKE="${BLOOM_GAME_SMOKE_BIN:-$SCRIPT_DIR/bloom-game-smoke}" BLOOM_RA="${BLOOM_RA_BIN:-$SCRIPT_DIR/bloom-ra}" @@ -387,6 +388,7 @@ usage() { printf 'Usage: bloomctl {info|health} [--json]\n' >&2 printf ' bloomctl update {status|channel [stable|beta|nightly]|stage MANIFEST SIGNATURE ARCHIVE|prepare VERSION|bootstrap VERSION|arm VERSION|activate VERSION|confirm|rollback}\n' >&2 printf ' bloomctl saves snapshots\n' >&2 + printf ' bloomctl sync {status|identity init|pair authorize DEVICE_ID KEY_EPOCH PUBLIC_KEY_DER|pair deauthorize DEVICE_ID|export MANIFEST_DRAFT SAVE_RELATIVE_PATH EXPORT_ID|validate BUNDLE_DIRECTORY|import BUNDLE_DIRECTORY|resolve BUNDLE_ID DECISION|publish BUNDLE_ID SAVE_RELATIVE_PATH RESOLUTION}\n' >&2 printf ' bloomctl logs export\n' >&2 printf ' bloomctl platform capabilities\n' >&2 printf ' bloomctl settings {status|values|import-onion|sync-onion|reconcile-onion|materialize-onion|activate-bloom|rollback-authority|reset-defaults|first-run-status|complete-first-run|set FIELD VALUE}\n' >&2 @@ -815,6 +817,16 @@ saves_command() { esac } +sync_command() { + [ -x "$BLOOM_SYNC" ] || { printf 'bloomctl: save sync service is unavailable\n' >&2; return 1; } + case "$#:${1:-}:${2:-}" in + 1:status:|2:identity:init|5:pair:authorize|3:pair:deauthorize|4:export:*|2:validate:*|2:import:*|3:resolve:*|4:publish:*) + "$BLOOM_SYNC" "$@" + ;; + *) usage; return 2 ;; + esac +} + update_command() { action="${1:-}" case "$action" in @@ -890,6 +902,10 @@ case "${1:-}" in shift saves_command "$@" ;; + sync) + shift + sync_command "$@" + ;; logs) shift logs_command "$@" diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index 9f4380b7c..bc722292e 100644 --- a/test/shell/legacy_manifest.bats +++ b/test/shell/legacy_manifest.bats @@ -6,7 +6,7 @@ --manifest /workspace/build/legacy-manifest.json [ "$status" -eq 0 ] - [[ "$output" == "legacy manifest validate: 326 components" ]] + [[ "$output" == "legacy manifest validate: 327 components" ]] [ ! -e "/workspace/static/packages/RApp/SNK - Neo Geo (GnGeo)" ] [ ! -e "/workspace/static/packages/RApp/PICO-8 (PICO-8 standalone)" ] [ ! -e "/workspace/static/packages/RApp/SCUMM (ScummVM standalone)" ] @@ -114,7 +114,7 @@ import sys components = json.load(open(sys.argv[1], encoding="utf-8"))["components"] runtime = [item for item in components if item["id"].startswith("runtime-")] bloom = [item for item in runtime if item["source"] == "https://github.com/boburning/BloomOS"] -assert len(bloom) == 59 +assert len(bloom) == 60 assert all(item["resolution"] == "source-build" for item in bloom) assert all(item["source_revision"] == "release-commit" for item in bloom) PY diff --git a/test/shell/network_sync.bats b/test/shell/network_sync.bats index b0b6daf0b..92b1d4898 100644 --- a/test/shell/network_sync.bats +++ b/test/shell/network_sync.bats @@ -43,6 +43,20 @@ write_ack() { [ "$(sed -n '2p' "$CLIENT_LOG")" = 'events ack sync 7' ] } +@test "associated sync event recognizes the transport-neutral engine without starting transport" { + engine="$BATS_TEST_TMPDIR/bloom-sync" + printf '#!/bin/sh\n[ "$*" = status ]\n' >"$engine" + chmod +x "$engine" + export BLOOM_NETWORK_SYNC_ENGINE_BIN="$engine" + write_event associated 11 true + write_ack associated 11 + + run "$SERVICE" reconcile + + [ "$status" -eq 0 ] + [[ "$output" == *'"disposition":"engine_ready"'* ]] +} + @test "offline sync event receives an explicit offline disposition" { write_event offline 8 true write_ack offline 8 diff --git a/test/shell/save_sync_engine.bats b/test/shell/save_sync_engine.bats new file mode 100644 index 000000000..94c940500 --- /dev/null +++ b/test/shell/save_sync_engine.bats @@ -0,0 +1,482 @@ +#!/usr/bin/env bats + +load 'support/test_helper' + +setup() { + setup_bloom_fixture + export SYNC=/workspace/static/build/.tmp_update/bin/bloom-sync + export SNAPSHOT=/workspace/static/build/.tmp_update/bin/bloom-save-snapshot + export BLOOM_SD_ROOT="$SDCARD" + export BLOOM_SYNC_ROOT="$SDCARD/.bloom/sync" + export BLOOM_SYNC_SAVE_ROOT="$SDCARD/Saves/CurrentProfile" + export BLOOM_SYNC_CONFIG_ROOT="$SDCARD/.tmp_update/config" + export BLOOM_SYNC_CORE_POLICY="$SDCARD/.tmp_update/config/core-selection-policy.json" + export BLOOM_SYNC_JQ_BIN=/usr/bin/jq + export BLOOM_SYNC_OPENSSL_BIN=/usr/bin/openssl + export BLOOM_SYNC_SNAPSHOT_BIN="$SNAPSHOT" + export BLOOM_SYNC_SESSION_STATE_FILE="$BLOOM_TEST_ROOT/session-state" + mkdir -p "$BLOOM_SYNC_SAVE_ROOT/saves/mGBA" "$BLOOM_SYNC_SAVE_ROOT/states/mGBA" \ + "$BLOOM_SYNC_SAVE_ROOT/saves/Imported" + cp /workspace/build/core-selection-policy.json "$BLOOM_SYNC_CORE_POLICY" + printf 'IDLE\n' >"$BLOOM_SYNC_SESSION_STATE_FILE" + printf 'synthetic-save-v1\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + run "$SYNC" identity init + [ "$status" -eq 0 ] +} + +teardown() { teardown_bloom_fixture; } + +write_draft() { + destination=$1 + generation=${2:-1} + parent=${3:-none} + lineage=${4:-bloom-save-lineage-v1:66666666666666666666666666666666} + jq -cn --argjson generation "$generation" --arg parent "$parent" --arg lineage "$lineage" ' + { + schema:1, + bloom_game_id:"bloom-game-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + content_identity:"bloom-content:raw-file:v1:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + system_id:"gba", + save_kind:"save", + compatibility_id:"gba-sram-v1", + backend:{ + emulator_id:"libretro:mgba", + core_id:"mgba", + component_id:"mgba-core", + component_sha256:"4444444444444444444444444444444444444444444444444444444444444444" + }, + lineage_id:$lineage, + generation:$generation, + parent_payload_sha256:$parent, + software:{ + bloomos_version:"0.1.0-dev-test", + source_commit:"7777777777777777777777777777777777777777" + } + } + ' >"$destination" +} + +export_bundle() { + draft=$BLOOM_TEST_ROOT/draft.json + write_draft "$draft" "${1:-1}" "${2:-none}" \ + "${3:-bloom-save-lineage-v1:66666666666666666666666666666666}" + run "$SYNC" export "$draft" mGBA/Fixture.srm "${4:-fixture}" + [ "$status" -eq 0 ] + BUNDLE_ID="$(printf '%s\n' "$output" | jq -er .bundle_id)" + BUNDLE_DIR="$BLOOM_SYNC_ROOT/exports/${4:-fixture}" +} + +@test "initializes one private Ed25519 identity and reports only aggregate status" { + run "$SYNC" identity init + [ "$status" -eq 0 ] + [ "$(stat -c %a "$BLOOM_SYNC_ROOT/identity")" = "500" ] + [ "$(stat -c %a "$BLOOM_SYNC_ROOT/identity/private.pem")" = "600" ] + [ "$(wc -c <"$BLOOM_SYNC_ROOT/identity/public.der" | tr -d ' ')" -eq 44 ] + + run "$SYNC" status + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e ' + .ok == true and .initialized == true and .candidates == 0 and + .quarantined == 0 and .transport == "none" + ' >/dev/null + [[ "$output" != *"device_id"* ]] + [[ "$output" != *"key_id"* ]] +} + +@test "status is read-only before identity initialization" { + fresh="$SDCARD/.bloom/fresh-sync" + run env BLOOM_SYNC_ROOT="$fresh" "$SYNC" status + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e ' + .state == "ready" and .initialized == false and .candidates == 0 and + .quarantined == 0 and .transport == "none" + ' >/dev/null + [ ! -e "$fresh" ] +} + +@test "OpenSSL operations own the shipped runtime library path" { + fresh="$SDCARD/.bloom/library-path-sync" + library_root="$BLOOM_TEST_ROOT/openssl-lib" + wrapper="$BLOOM_TEST_ROOT/openssl-wrapper" + mkdir "$library_root" + cat >"$wrapper" <<'EOF' +#!/bin/sh +[ "${LD_LIBRARY_PATH%%:*}" = "$BLOOM_EXPECTED_OPENSSL_LIB" ] || exit 97 +exec /usr/bin/openssl "$@" +EOF + chmod +x "$wrapper" + export BLOOM_EXPECTED_OPENSSL_LIB="$library_root" + + run env BLOOM_SYNC_ROOT="$fresh" BLOOM_SYNC_OPENSSL_BIN="$wrapper" \ + BLOOM_SYNC_OPENSSL_LIB_ROOT="$library_root" "$SYNC" identity init + + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "initialized"' >/dev/null +} + +@test "exports and verifies a closed immutable Ed25519 bundle" { + export_bundle + + run "$SYNC" validate "$BUNDLE_DIR" + + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | jq -er .bundle_id)" = "$BUNDLE_ID" ] + [ "$(find "$BUNDLE_DIR" -mindepth 1 -maxdepth 1 -type f | wc -l)" -eq 3 ] + [ "$(jq -cS .manifest "$BUNDLE_DIR/bundle.json" | sha256sum | awk '{print $1}')" = "${BUNDLE_ID#*:}" ] + [ "$(jq -r .manifest.payload.sha256 "$BUNDLE_DIR/bundle.json")" = \ + "$(sha256sum "$BUNDLE_DIR/payload.bin" | awk '{print $1}')" ] +} + +@test "exports and validates an empty but real save payload" { + : >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle + + run "$SYNC" validate "$BUNDLE_DIR" + + [ "$status" -eq 0 ] + [ "$(jq -er .manifest.payload.bytes "$BUNDLE_DIR/bundle.json")" -eq 0 ] + [ "$(jq -er .manifest.payload.sha256 "$BUNDLE_DIR/bundle.json")" = \ + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ] +} + +@test "export rejects an active session and a payload changed during staging" { + draft=$BLOOM_TEST_ROOT/draft.json + write_draft "$draft" + printf 'RUNNING\n' >"$BLOOM_SYNC_SESSION_STATE_FILE" + + run "$SYNC" export "$draft" mGBA/Fixture.srm active + + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "active_session"' >/dev/null + [ ! -e "$BLOOM_SYNC_ROOT/exports/active" ] + + printf 'IDLE\n' >"$BLOOM_SYNC_SESSION_STATE_FILE" + mock_bin=$BLOOM_TEST_ROOT/export-race-bin + mkdir "$mock_bin" + cat >"$mock_bin/cp" <<'EOF' +#!/bin/sh +case "$1" in + */mGBA/Fixture.srm) printf 'changed-during-copy\n' >>"$1" ;; +esac +exec /bin/cp "$@" +EOF + chmod +x "$mock_bin/cp" + + run env PATH="$mock_bin:$PATH" "$SYNC" export "$draft" mGBA/Fixture.srm changed + + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "payload_changed"' >/dev/null + [ ! -e "$BLOOM_SYNC_ROOT/exports/changed" ] + [ -z "$(find "$BLOOM_SYNC_ROOT/exports" -mindepth 1 -maxdepth 1 -name '.*' -print -quit)" ] +} + +@test "tampered payloads and open envelopes fail before candidate storage" { + export_bundle + chmod 600 "$BUNDLE_DIR/payload.bin" + printf 'tampered\n' >>"$BUNDLE_DIR/payload.bin" + + run "$SYNC" import "$BUNDLE_DIR" + + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "identity_mismatch"' >/dev/null + [ -z "$(find "$BLOOM_SYNC_ROOT/candidates" -mindepth 1 -maxdepth 1 -print -quit)" ] + + export_bundle 1 none \ + bloom-save-lineage-v1:77777777777777777777777777777777 second + chmod 600 "$BUNDLE_DIR/bundle.json" + jq '.transport_path = "/Saves/private.srm"' "$BUNDLE_DIR/bundle.json" >"$BUNDLE_DIR/open.json" + mv "$BUNDLE_DIR/open.json" "$BUNDLE_DIR/bundle.json" + run "$SYNC" validate "$BUNDLE_DIR" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "schema_invalid"' >/dev/null +} + +@test "stages a new lineage and publishes only after a verified snapshot" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] || printf 'import output: %s\n' "$output" >&3 + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "staged" and .classification == "new_lineage"' >/dev/null + + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e ' + .state == "applied" and .classification == "new_lineage" and .rollback_retained == true + ' >/dev/null + cmp "$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" \ + "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" + [ -f "$BLOOM_SYNC_ROOT/transactions/$BUNDLE_ID/transaction.json" ] + [ "$(find "$SDCARD/Saves/BloomSnapshots" -mindepth 1 -maxdepth 1 -type d ! -name .lock | wc -l)" -eq 1 ] +} + +@test "fast-forward publication retains the replaced bytes and ancestry" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + [ "$status" -eq 0 ] + first_hash="$(sha256sum "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" | awk '{print $1}')" + + printf 'synthetic-save-v2\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 "$first_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 second + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "fast_forward"' >/dev/null + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm fast_forward + [ "$status" -eq 0 ] + + grep -Fx synthetic-save-v2 "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" + grep -Fx synthetic-save-v1 "$BLOOM_SYNC_ROOT/transactions/$BUNDLE_ID/previous" + current="$(find "$BLOOM_SYNC_ROOT/current" -type f -name '*.json' -print -quit)" + jq -e --arg hash "$first_hash" '.ancestry | index($hash) != null' "$current" >/dev/null +} + +@test "publication reclassifies under lock and preserves a superseded candidate" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + [ "$status" -eq 0 ] + first_hash="$(sha256sum "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" | awk '{print $1}')" + + printf 'candidate-two-a\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 "$first_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 candidate-a + candidate_a_id=$BUNDLE_ID + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "fast_forward"' >/dev/null + + printf 'candidate-two-b\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 "$first_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 candidate-b + candidate_b_id=$BUNDLE_ID + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + run "$SYNC" publish "$candidate_b_id" Imported/Fixture.srm fast_forward + [ "$status" -eq 0 ] + + run "$SYNC" publish "$candidate_a_id" Imported/Fixture.srm fast_forward + + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "state_changed"' >/dev/null + grep -Fx candidate-two-b "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" + [ -d "$BLOOM_SYNC_ROOT/candidates/$candidate_a_id" ] +} + +@test "active sessions and mismatched resolutions preserve live data" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf 'RUNNING\n' >"$BLOOM_SYNC_SESSION_STATE_FILE" + + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "active_session"' >/dev/null + [ ! -e "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" ] + + printf 'IDLE\n' >"$BLOOM_SYNC_SESSION_STATE_FILE" + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm fast_forward + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "resolution_mismatch"' >/dev/null + [ ! -e "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" ] +} + +@test "late state publication failure restores live bytes and cleans private staging" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + manifest="$BUNDLE_DIR/bundle.json" + handle="$(jq -cS '.manifest | {bloom_game_id,save_kind}' "$manifest" | sha256sum | awk '{print $1}')" + mkdir "$BLOOM_SYNC_ROOT/current/$handle.json" + + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "state_write_failed"' >/dev/null + [ ! -e "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" ] + [ -z "$(find "$BLOOM_SYNC_ROOT/transactions" -mindepth 1 -maxdepth 1 -name '.*' -print -quit)" ] + [ -z "$(find "$BLOOM_SYNC_ROOT/current" -mindepth 1 -maxdepth 1 -type f -name '*.tmp.*' -print -quit)" ] + [ -d "$BLOOM_SYNC_ROOT/candidates/$BUNDLE_ID" ] + [ "$(find "$SDCARD/Saves/BloomSnapshots" -mindepth 1 -maxdepth 1 -type d ! -name .lock | wc -l)" -eq 1 ] +} + +@test "forks gaps and unrelated lineages are preserved for review" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + [ "$status" -eq 0 ] + first_hash="$(sha256sum "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" | awk '{print $1}')" + + printf 'fork\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 \ + 9999999999999999999999999999999999999999999999999999999999999999 \ + bloom-save-lineage-v1:66666666666666666666666666666666 fork + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "review_required" and .classification == "fork"' >/dev/null + + export_bundle 4 "$first_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 gap + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "history_required"' >/dev/null + + export_bundle 1 none \ + bloom-save-lineage-v1:88888888888888888888888888888888 unrelated + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "unrelated_lineage"' >/dev/null +} + +@test "explicit review can promote a preserved fork but cannot bypass missing history" { + export_bundle + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + run "$SYNC" publish "$BUNDLE_ID" Imported/Fixture.srm new_lineage + [ "$status" -eq 0 ] + + printf 'reviewed-fork\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 \ + 9999999999999999999999999999999999999999999999999999999999999999 \ + bloom-save-lineage-v1:66666666666666666666666666666666 fork + fork_id="$BUNDLE_ID" + fork_dir="$BUNDLE_DIR" + run "$SYNC" import "$fork_dir" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "fork"' >/dev/null + run "$SYNC" resolve "$fork_id" accept_incoming + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "staged" and .classification == "fork"' >/dev/null + run "$SYNC" publish "$fork_id" Imported/Fixture.srm fork + [ "$status" -eq 0 ] + grep -Fx reviewed-fork "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" + [ -d "$BLOOM_SYNC_ROOT/quarantine/$fork_id" ] + + printf 'gap\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + current_hash="$(sha256sum "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" | awk '{print $1}')" + export_bundle 5 "$current_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 gap + gap_id="$BUNDLE_ID" + run "$SYNC" import "$BUNDLE_DIR" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "history_required"' >/dev/null + run "$SYNC" resolve "$gap_id" accept_incoming + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "non_resolvable"' >/dev/null +} + +@test "exact replay stale ancestry incompatibility and bundle collisions use ordered outcomes" { + export_bundle + first_bundle="$BUNDLE_DIR" + first_id="$BUNDLE_ID" + run "$SYNC" import "$first_bundle" + [ "$status" -eq 0 ] + run "$SYNC" publish "$first_id" Imported/Fixture.srm new_lineage + [ "$status" -eq 0 ] + + run "$SYNC" import "$first_bundle" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "noop" and .classification == "exact_replay"' >/dev/null + + incompatible=$BLOOM_TEST_ROOT/incompatible.json + write_draft "$incompatible" 1 none + jq '.content_identity = "bloom-content:raw-file:v1:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"' \ + "$incompatible" >"$incompatible.tmp" + mv "$incompatible.tmp" "$incompatible" + run "$SYNC" export "$incompatible" mGBA/Fixture.srm incompatible + [ "$status" -eq 0 ] + incompatible_bundle="$BLOOM_SYNC_ROOT/exports/incompatible" + run "$SYNC" import "$incompatible_bundle" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "incompatible"' >/dev/null + + first_hash="$(sha256sum "$BLOOM_SYNC_SAVE_ROOT/saves/Imported/Fixture.srm" | awk '{print $1}')" + printf 'synthetic-save-v2\n' >"$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Fixture.srm" + export_bundle 2 "$first_hash" \ + bloom-save-lineage-v1:66666666666666666666666666666666 second + second_bundle="$BUNDLE_DIR" + second_id="$BUNDLE_ID" + run "$SYNC" import "$second_bundle" + [ "$status" -eq 0 ] + run "$SYNC" publish "$second_id" Imported/Fixture.srm fast_forward + [ "$status" -eq 0 ] + + run "$SYNC" import "$first_bundle" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.state == "noop" and .classification == "stale_ancestor"' >/dev/null + + current="$(find "$BLOOM_SYNC_ROOT/current" -type f -name '*.json' -print -quit)" + chmod 600 "$current" + jq '.bundle.manifest.payload.sha256 = "9999999999999999999999999999999999999999999999999999999999999999"' \ + "$current" >"$current.tmp" + mv "$current.tmp" "$current" + run "$SYNC" import "$second_bundle" + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.classification == "bundle_id_collision"' >/dev/null +} + +@test "authorized peer keys validate and deauthorization rejects new input" { + peer_root="$SDCARD/.bloom/peer-sync" + run env BLOOM_SYNC_ROOT="$peer_root" "$SYNC" identity init + [ "$status" -eq 0 ] + peer_device="$(jq -er .device_id "$peer_root/identity/identity.json")" + run "$SYNC" pair authorize "$peer_device" 1 "$peer_root/identity/public.der" + [ "$status" -eq 0 ] + + draft=$BLOOM_TEST_ROOT/peer-draft.json + write_draft "$draft" + run env BLOOM_SYNC_ROOT="$peer_root" "$SYNC" export "$draft" mGBA/Fixture.srm peer + [ "$status" -eq 0 ] + peer_bundle="$peer_root/exports/peer" + run "$SYNC" validate "$peer_bundle" + [ "$status" -eq 0 ] + + run "$SYNC" pair deauthorize "$peer_device" + [ "$status" -eq 0 ] + run "$SYNC" validate "$peer_bundle" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "unauthorized"' >/dev/null +} + +@test "unsafe save paths and divergent peer keys fail closed" { + draft=$BLOOM_TEST_ROOT/draft.json + write_draft "$draft" + printf 'outside\n' >"$BLOOM_TEST_ROOT/outside.srm" + ln -s "$BLOOM_TEST_ROOT/outside.srm" "$BLOOM_SYNC_SAVE_ROOT/saves/mGBA/Linked.srm" + run "$SYNC" export "$draft" mGBA/Linked.srm linked + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "payload_unsafe"' >/dev/null + + peer_device=bloom-device-v1:99999999999999999999999999999999 + openssl genpkey -algorithm ED25519 -out "$BLOOM_TEST_ROOT/one.pem" >/dev/null 2>&1 + openssl pkey -in "$BLOOM_TEST_ROOT/one.pem" -pubout -outform DER \ + -out "$BLOOM_TEST_ROOT/one.der" >/dev/null 2>&1 + run "$SYNC" pair authorize "$peer_device" 1 "$BLOOM_TEST_ROOT/one.der" + [ "$status" -eq 0 ] + openssl genpkey -algorithm ED25519 -out "$BLOOM_TEST_ROOT/two.pem" >/dev/null 2>&1 + openssl pkey -in "$BLOOM_TEST_ROOT/two.pem" -pubout -outform DER \ + -out "$BLOOM_TEST_ROOT/two.der" >/dev/null 2>&1 + run "$SYNC" pair authorize "$peer_device" 2 "$BLOOM_TEST_ROOT/two.der" + [ "$status" -eq 1 ] + printf '%s\n' "$output" | jq -e '.state == "clone_or_rotation_requires_repair"' >/dev/null +} + +@test "bloomctl dispatches only the bounded sync command grammar" { + controller=/workspace/static/build/.tmp_update/bin/bloomctl + mock_sync="$BLOOM_TEST_ROOT/mock-sync" + printf '#!/bin/sh\nprintf "%%s\\n" "$*"\n' >"$mock_sync" + chmod +x "$mock_sync" + + run env BLOOM_SYNC_BIN="$mock_sync" "$controller" sync status + [ "$status" -eq 0 ] + [ "$output" = status ] || printf 'bloomctl output: %s\n' "$output" >&3 + [ "$output" = status ] + + run env BLOOM_SYNC_BIN="$mock_sync" "$controller" sync arbitrary + [ "$status" -eq 2 ] + [[ "$output" == *"Usage: bloomctl"* ]] +} diff --git a/tools/legacy_manifest.py b/tools/legacy_manifest.py index 327634506..598d666d6 100644 --- a/tools/legacy_manifest.py +++ b/tools/legacy_manifest.py @@ -78,6 +78,7 @@ "runtime-tmp-update-bin-bloom-ra-log", "runtime-tmp-update-bin-bloom-save-snapshot", "runtime-tmp-update-bin-bloom-save-migrate", + "runtime-tmp-update-bin-bloom-sync", "runtime-tmp-update-bin-bloom-session", "runtime-tmp-update-bin-bloom-sftp-gate", "runtime-tmp-update-bin-bloom-shell-guard", From 9ce3b5ddb148382f39ac15fc4d9e0b0963248574 Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 15:44:50 -0400 Subject: [PATCH 2/3] test(build): avoid stale legacy inventory fixtures --- test/test_retroarch_build_identity.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/test_retroarch_build_identity.py b/test/test_retroarch_build_identity.py index 6d0cf706a..3fd75f722 100644 --- a/test/test_retroarch_build_identity.py +++ b/test/test_retroarch_build_identity.py @@ -2,6 +2,7 @@ """Regression checks for the exact RetroArch ARM cache identity.""" import pathlib +import re import subprocess import sys import tempfile @@ -36,9 +37,19 @@ def test_identity_is_stable_for_unrelated_inventory_totals(self): self.assertRegex(baseline.stdout.strip(), r"^[0-9a-f]{64}$") changed = LOCK.read_text(encoding="utf-8") - changed = changed.replace("component_count = 326", "component_count = 999") - changed = changed.replace("file_count = 1967", "file_count = 9999") - changed = changed.replace("byte_count = 260659274", "byte_count = 999999999") + for field, value in ( + ("component_count", 999), + ("file_count", 9999), + ("byte_count", 999999999), + ): + pattern = rf"(?ms)(^\[legacy_inventory\]\n.*?^{field} = )\d+$" + changed, replacement_count = re.subn( + pattern, + rf"\g<1>{value}", + changed, + count=1, + ) + self.assertEqual(replacement_count, 1, field) self.assertNotEqual(changed, LOCK.read_text(encoding="utf-8")) with tempfile.TemporaryDirectory() as directory: candidate = pathlib.Path(directory) / "dependencies.lock" From a9fd690627738985a016cb7df824574d6de14b4c Mon Sep 17 00:00:00 2001 From: boburning Date: Mon, 31 Aug 2026 16:01:14 -0400 Subject: [PATCH 3/3] docs(program): record SYNC-02 device gate --- docs/program/BloomOS_Work_Program_v3.json | 15 +++++++++++---- test/shell/save_sync_engine.bats | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 5d3f04b5c..d95c2ec40 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -2088,16 +2088,23 @@ "id": "SYNC-02", "track": "Sync", "title": "Transport-neutral bloom-sync engine", - "status": "planned", + "status": "device_gate_pending", "dependencies": [ "SYNC-01" ], - "github_prs": [], + "github_prs": [651], "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "a01f2a7fa7cfc3877405b4ba2005d600cbf2ec70", + "evidence": [ + "issue #650 tracks the transport-neutral transactional SYNC-02 runtime owner after SYNC-01 merged, with transport selection, synchronization daemons, Save Sync UI, and physical certification explicitly out of scope", + "PR #651 adds the bounded bloom-sync identity, peer trust, immutable export, ordered validation and conflict classification, private staging and quarantine, explicit resolution, verified snapshot, same-directory atomic publication, and rollback owner without adding a cloud provider, socket, listener, or transport dependency", + "the exact runtime commit 8077e94481c202a93f64d512a05bbf7c85384952 passed 595 native tests, 595 AddressSanitizer tests, 849 Shell tests, 47 focused sync/network/provenance/FAT tests, 10 deterministic UI gallery cases with zero changed pixels, full cppcheck, and a clean pinned ARM build ending ci-build READY", + "a strict key-only read-only Mini Plus capability probe found that the shipped OpenSSL required BloomOS-owned runtime libraries; PR #651 now scopes those libraries inside bloom-sync, and the corrected tmp-only probe passed Ed25519 public-key generation, signing, canonical jq ordering, and cleanup without reading or writing Saves or installing a candidate", + "PR #651 exact head 9ce3b5ddb148382f39ac15fc4d9e0b0963248574 passed hosted native, AddressSanitizer, exhaustive Shell, static-analysis, and clean ARM checks after replacing a stale hard-coded inventory fixture with a structural regression test", + "no SYNC-02 candidate has been deployed: the Mini Plus remains healthy on the sole signed UI-12 candidate v0.1.0-dev-e9ce0c30 in testing with its prior version and verified pre-update snapshot retained, no development overlay, an idle network transaction, and a clean key-only listener audit; SYNC-02 remains device-gate pending until the UI-12 physical shutdown observation frees the signed candidate boundary" + ] }, { "id": "SYNC-03", diff --git a/test/shell/save_sync_engine.bats b/test/shell/save_sync_engine.bats index 94c940500..95a234fa0 100644 --- a/test/shell/save_sync_engine.bats +++ b/test/shell/save_sync_engine.bats @@ -480,3 +480,26 @@ EOF [ "$status" -eq 2 ] [[ "$output" == *"Usage: bloomctl"* ]] } + +@test "v3 ledger records SYNC-02 publication without promoting later packages" { + run jq -e ' + (.packages | map(select(.id == "SYNC-02"))) as $sync02_rows | + (.packages | map(select(.id == "SYNC-03"))) as $sync03_rows | + (.packages | map(select(.id == "SYNC-04"))) as $sync04_rows | + ($sync02_rows | length) == 1 and + ($sync03_rows | length) == 1 and + ($sync04_rows | length) == 1 and + ($sync02_rows[0].status == "device_gate_pending") and + ($sync02_rows[0].dependencies == ["SYNC-01"]) and + ($sync02_rows[0].github_prs == [651]) and + ($sync02_rows[0].risk_tier == 3) and + ($sync02_rows[0].deployment_tier == "warm") and + ($sync02_rows[0].device_gate_required == true) and + any($sync02_rows[0].evidence[]; contains("PR #651")) and + ($sync03_rows[0].status == "planned") and + ($sync03_rows[0].dependencies | index("SYNC-02") != null) and + ($sync04_rows[0].status == "planned") and + ($sync04_rows[0].dependencies | index("SYNC-02") != null) + ' /workspace/docs/program/BloomOS_Work_Program_v3.json + [ "$status" -eq 0 ] +}