From d43d372c01a7504900cc35dcd18b20760bee90d9 Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 06:52:50 -0400 Subject: [PATCH 01/29] feat(network): add canonical transfer policy --- .gitattributes | 1 + Makefile | 4 + build/dependencies.lock | 8 +- build/legacy-manifest.json | 25 +- build/transfer-policy.json | 53 ++++ build/transfer-policy.schema.json | 45 +++ docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 13 + docs/DEVELOPMENT.md | 7 + docs/SECURITY.md | 7 + docs/program/BloomOS_Work_Program_v3.json | 17 +- static/build/.tmp_update/bin/bloom-dev-deploy | 17 +- static/build/.tmp_update/bin/bloom-dev-ssh | 37 ++- static/build/.tmp_update/bin/bloom-sftp-gate | 8 + .../.tmp_update/bin/bloom-transfer-policy | 157 ++++++++++ test/shell/developer_ssh.bats | 11 + test/shell/legacy_manifest.bats | 5 +- test/shell/network_services.bats | 3 + test/shell/transfer_policy.bats | 117 +++++++ tools/build-sftp-server.sh | 1 + tools/ci-scope.sh | 17 +- tools/dev-deploy-manifest.py | 14 + .../patches/openssh-sftp-atomic-replace.patch | 285 ++++++++++++++++++ tools/validate_transfer_policy.py | 112 +++++++ .../03-network-features/03-ssh-sftp.md | 74 ++--- 25 files changed, 955 insertions(+), 85 deletions(-) create mode 100644 build/transfer-policy.json create mode 100644 build/transfer-policy.schema.json create mode 100755 static/build/.tmp_update/bin/bloom-transfer-policy create mode 100755 test/shell/transfer_policy.bats create mode 100644 tools/patches/openssh-sftp-atomic-replace.patch create mode 100755 tools/validate_transfer_policy.py diff --git a/.gitattributes b/.gitattributes index 100f5edd5..33a00e9d8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ *.py text eol=lf *.json text eol=lf build/openbor/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab +tools/patches/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab tools/bloom-device text eol=lf tools/*.sh text eol=lf tools/bloom-mini-test text eol=lf diff --git a/Makefile b/Makefile index 03ec1e9ef..bd8043d73 100644 --- a/Makefile +++ b/Makefile @@ -172,6 +172,7 @@ 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/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 # Build dependencies for installer @@ -274,6 +275,9 @@ package-release-unsigned: --policy $(ROOT_DIR)/build/save-migration-policy.json \ --schema $(ROOT_DIR)/build/save-migration-policy.schema.json \ --core-policy $(ROOT_DIR)/build/core-selection-policy.json + @python3 $(ROOT_DIR)/tools/validate_transfer_policy.py \ + --policy $(ROOT_DIR)/build/transfer-policy.json \ + --schema $(ROOT_DIR)/build/transfer-policy.schema.json @python3 $(ROOT_DIR)/tools/legacy_manifest.py validate \ --repository $(ROOT_DIR) \ --manifest $(ROOT_DIR)/build/legacy-manifest.json diff --git a/build/dependencies.lock b/build/dependencies.lock index b815f9ade..bdec5911f 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -184,10 +184,10 @@ legacy_channels = ["development"] [legacy_inventory] manifest = "build/legacy-manifest.json" schema = 1 -component_count = 329 -file_count = 2128 -byte_count = 314611699 -source_build_components = 225 +component_count = 330 +file_count = 2129 +byte_count = 314620962 +source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index ca8032314..725496651 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 29280, - "tree_sha256": "25ea32af0dcb972020ec907a244910b95eb95358208e01b2e3c46d237a6a0a75", + "byte_count": 30096, + "tree_sha256": "e474bc2bc7816eb1f976affd5c5952c9b3616ded762630bb3f0333220b62d13b", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -501,8 +501,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-ssh", "file_count": 1, - "byte_count": 12254, - "tree_sha256": "5b3e19adbaf8ffd584980316584a38f5287822f8db69b98038de50d76b3cbc20", + "byte_count": 13206, + "tree_sha256": "c9bfb3e24cfa689bbc3ead0325de50e1b27b0308feeb4d949cfe42df890ec8ab", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -787,8 +787,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-sftp-gate", "file_count": 1, - "byte_count": 695, - "tree_sha256": "b8bb9b4ffae1e679834ab32a331baec358d9b0e9167c0ea2852f1b32f0375d99", + "byte_count": 1158, + "tree_sha256": "5e055f61f1d2d7348dab611b481e77b1eef549ad6086185c1c049eee335cc0fc", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -847,6 +847,19 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-transfer-policy", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-transfer-policy", + "file_count": 1, + "byte_count": 7032, + "tree_sha256": "0418050e9f0daf8e3f165ba9f222ae9c661bbad242c3a81159a5657de724d16a", + "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-update-activate", "kind": "runtime", diff --git a/build/transfer-policy.json b/build/transfer-policy.json new file mode 100644 index 000000000..25ab28776 --- /dev/null +++ b/build/transfer-policy.json @@ -0,0 +1,53 @@ +{ + "schema": 1, + "policy_id": "bloomos-transfer-policy-v1", + "publication": "same_filesystem_atomic_replace", + "limits": { + "max_path_bytes": 1024, + "max_components": 32, + "max_component_bytes": 255, + "max_upload_bytes": 4294967295 + }, + "roots": [ + { + "id": "roms", + "virtual_path": "/Roms", + "device_path": "Roms", + "access": "read_write" + }, + { + "id": "bios", + "virtual_path": "/BIOS", + "device_path": "BIOS", + "access": "read_write" + }, + { + "id": "artwork", + "virtual_path": "/Artwork", + "device_path": "Imgs", + "access": "read_write" + }, + { + "id": "screenshots", + "virtual_path": "/Screenshots", + "device_path": "Screenshots", + "access": "read_write" + }, + { + "id": "themes", + "virtual_path": "/Themes", + "device_path": "Themes", + "access": "read_write" + }, + { + "id": "saves", + "virtual_path": "/Saves", + "device_path": "Saves", + "access": "read_only" + } + ], + "private_device_roots": [ + ".bloom", + ".tmp_update" + ] +} diff --git a/build/transfer-policy.schema.json b/build/transfer-policy.schema.json new file mode 100644 index 000000000..d891c1063 --- /dev/null +++ b/build/transfer-policy.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.dev/schemas/transfer-policy-v1.json", + "type": "object", + "additionalProperties": false, + "required": ["schema", "policy_id", "publication", "limits", "roots", "private_device_roots"], + "properties": { + "schema": {"const": 1}, + "policy_id": {"const": "bloomos-transfer-policy-v1"}, + "publication": {"const": "same_filesystem_atomic_replace"}, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_path_bytes", "max_components", "max_component_bytes", "max_upload_bytes"], + "properties": { + "max_path_bytes": {"type": "integer", "minimum": 1, "maximum": 4096}, + "max_components": {"type": "integer", "minimum": 1, "maximum": 64}, + "max_component_bytes": {"type": "integer", "minimum": 1, "maximum": 255}, + "max_upload_bytes": {"type": "integer", "minimum": 1, "maximum": 4294967295} + } + }, + "roots": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "virtual_path", "device_path", "access"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,31}$"}, + "virtual_path": {"type": "string", "pattern": "^/[A-Za-z][A-Za-z0-9_-]{0,31}$"}, + "device_path": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"}, + "access": {"enum": ["read_only", "read_write"]} + } + } + }, + "private_device_roots": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^\\.[a-z][a-z0-9_-]{0,31}$"} + } + } +} diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 4737c5e92..c8a8f194b 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -46,7 +46,7 @@ shoulder button. | Themes | Declarative bounded manifest; Classic/Dense/Large Text/High Contrast; fallback on malformed input; immutable Safe Mode/recovery/credential UI | | Settings | One sectioned surface; inline values; capability filtering; one-layer details; hidden Developer section; optional overlapping L1/R1 list paging | | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | -| Network services | Desired/runtime separation; Wi-Fi reconciliation; SSH shell and SFTP independent key-only states; SD confinement; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | +| Network services | Desired/runtime separation; Wi-Fi reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b0985b7e..f418d0028 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -436,6 +436,19 @@ keys, authorized-key storage, update state, JFFS, or the firmware filesystem. Stopping or reconciling the transport unmounts the allowlist, and a failed transport restart rolls desired markers back to the prior matrix. +`transfer-policy.json` is the versioned authority behind that chroot and the +future Web Transfer boundary. `bloom-transfer-policy mounts` is the only source +for the SFTP root/mode projection: Roms, BIOS, Artwork, Screenshots, and Themes +are read-write; Saves is read-only; `.bloom` and `.tmp_update` are never +exposed. The same helper authorizes bounded virtual paths, rejects traversal, +control characters, overlong components, hidden Bloom work names, and every +existing symlink boundary, and maps no path outside those six roots. Its +`publish` operation accepts only a regular file from the private staging root, +copies it to a private sibling on the destination filesystem, verifies the byte +count, syncs, and renames it over the destination. A failed copy therefore +cannot expose a partial final file. The Web service must consume this helper +rather than recreating the root or publication rules. + `bloom-listener-audit` is the privacy-bounded process/listener check for this boundary. It reports only reviewed or prohibited service classes, never local addresses or credentials. A clean result permits Bloom's key-only Dropbear diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e8b872a8b..2dbac31a4 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -171,6 +171,13 @@ data roots are rejected. Ordinary activation refuses `network-helpers`; remote network mutations use a separate rollback-timed workflow so the only working SSH path cannot be replaced by this command. +The SFTP chroot consumer, `bloom-transfer-policy`, and +`config/transfer-policy.json` form one guarded `network-helpers` deployment +group. If a candidate contains any member, host and device manifest validation +require all three. Activate that bundle only through the rollback-timed network +overlay path, then validate policy status, exact mount modes, key-only access, +listener state, and rollback through an independent connection. + `launch-session` is an indivisible Warm bundle. Its manifest must contain the exact Bloom Shell, GameSwitcher, keymon, RetroAchievements, launch, session, override, preparation, and trace-helper runtime set. Partial manifests are diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b3915acb9..2928c9134 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -20,6 +20,13 @@ stops their known processes. `bloom-listener-audit` provides a bounded, process-aware check that names service classes without publishing addresses. The reviewed key-only Dropbear transport is the only stable remote listener; SFTP is independently gated and confined through that same transport. +Its chroot is materialized from the versioned canonical transfer policy rather +than a second root list. Only Roms, BIOS, Artwork, Screenshots, and Themes are +read-write; Saves is read-only. Credential, update, and other private roots are +absent. Shared path authorization rejects traversal, links, hidden Bloom work +names, and bounded-length violations. Programmatic uploads publish from a +private staging root through a same-filesystem temporary file and atomic rename; +future Web Transfer is required to use this same helper and policy. Developer component deployment is a separate, visible overlay rather than an update or release channel. It requires Developer Mode, a second local enablement diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 756243949..d50ddf078 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -412,7 +412,7 @@ "id": "CORE-03", "track": "Core", "title": "Shared native bloomCore resolver and consumer adapters", - "status": "published", + "status": "merged", "dependencies": [ "CORE-02" ], @@ -750,20 +750,21 @@ "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "9ad774455fd7aed81bb6ac5278f27807083c61e7", + "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", "evidence": [ "candidate 4beb681ae16416d5e7c48d2d63a8854e6e0e69ad provides schema-2 desired/runtime separation, bounded reasons, canonical non-secret settings migration, v1 compatibility projections, and PLAT-01 capability joins", "native and ASan passed 441/441, the complete shell gate passed 629/629, focused transaction/deployment passed 27/27, and static analysis, formatting, ARM EABI5, manifest, dependency-lock, provenance, and FAT payload gates passed", "the exact-hash Mini Plus Warm bundle independently confirmed key-only SSH with Wi-Fi associated, SFTP and Samba stopped, one idempotent migration, unchanged settings/key hashes across repeated reads, and no listener restart for an already-enabled SSH request", "device testing exposed redundant prior-state replay after overlay rollback; corrective commit 4beb681a skipped replay when the restored state already matched, and a fresh explicit rollback completed without restarting tracked Dropbear PID 1197", - "machine-readable evidence is docs/validation/network/plus-net01-network-service-v2-20260826.json; temporary deployments were cleaned, installed settings and harness bytes were restored exactly, aggregate health is green, and remote evidence does not claim physical or other-device certification" + "machine-readable evidence is docs/validation/network/plus-net01-network-service-v2-20260826.json; temporary deployments were cleaned, installed settings and harness bytes were restored exactly, aggregate health is green, and remote evidence does not claim physical or other-device certification", + "GitHub PR #481 passed every required check and squash-merged to main as 9f339e90a773a288014bd5612fcb8bcc23790641 on 2026-08-26" ] }, { "id": "NET-02", "track": "Network", "title": "Canonical transfer/share policy", - "status": "planned", + "status": "in_progress", "dependencies": [ "NET-01" ], @@ -771,8 +772,12 @@ "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", + "evidence": [ + "host candidate defines one schema-validated transfer authority for five read-write roots, read-only Saves, private-root denial, bounded paths, and same-filesystem atomic publication consumed by SFTP mount and write gates", + "the complete pinned shell gate passed 634/634; focused policy and inventory validation passed 28/28; legacy inventory, development provenance, shell syntax, patch hunk counts, and CI scope gates passed", + "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server; Mini Plus Warm validation and rollback remain required before publication" + ] }, { "id": "NET-03", diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index d96209c13..48d934e59 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -73,9 +73,12 @@ allowed_path() { network-helpers:bin/bloom-settings|network-helpers:bin/bloom-network|\ network-helpers:bin/bloom-network-services|network-helpers:bin/bloom-network-transaction|\ network-helpers:bin/bloom-platform|network-helpers:bin/bloom-dev-ssh|\ - network-helpers:bin/bloom-sftp-gate|network-helpers:bin/bloom-remote-keys|\ + network-helpers:bin/bloom-sftp-gate|network-helpers:bin/bloom-sftp-server|\ + network-helpers:bin/bloom-transfer-policy|\ + network-helpers:bin/bloom-remote-keys|\ network-helpers:bin/bloomctl|\ network-helpers:bin/bloom-listener-audit|network-helpers:bin/bloom-wifi|\ + network-helpers:config/transfer-policy.json|\ network-helpers:script/network/update_networking.sh|\ network-helpers:script/network/wpsclient.sh) return 0 ;; *) return 1 ;; @@ -168,6 +171,12 @@ validate_manifest() { bin/keymon \ script/bloom-launch-trace.sh | sort)" [ "$actual_paths" = "$expected_paths" ] || fail "launch/session/RA bundle is incomplete" + elif [ "$manifest_component" = network-helpers ]; then + actual_paths="$($JQ -r '.files[].path' "$manifest" | sort)" + transfer_count="$(printf '%s\n' "$actual_paths" | grep -Ec \ + '^(bin/bloom-dev-ssh|bin/bloom-sftp-gate|bin/bloom-sftp-server|bin/bloom-transfer-policy|config/transfer-policy.json)$' || true)" + [ "$transfer_count" -eq 0 ] || [ "$transfer_count" -eq 5 ] || + fail "network transfer policy bundle is incomplete" fi } @@ -337,7 +346,11 @@ readiness() { ;; network-helpers) output="$($SYSTEM_ROOT/bin/bloom-network-services status 2>/dev/null)" || return 1 - case "$output" in *'"service":"bloom-network-services"'*) return 0 ;; *) return 1 ;; esac + case "$output" in *'"service":"bloom-network-services"'*) ;; *) return 1 ;; esac + if [ -x "$SYSTEM_ROOT/bin/bloom-transfer-policy" ]; then + "$SYSTEM_ROOT/bin/bloom-transfer-policy" validate >/dev/null 2>&1 || return 1 + fi + return 0 ;; launch-session) output="$($SYSTEM_ROOT/bin/bloom-session status --json 2>/dev/null)" || return 1 diff --git a/static/build/.tmp_update/bin/bloom-dev-ssh b/static/build/.tmp_update/bin/bloom-dev-ssh index 12650486f..af031845d 100755 --- a/static/build/.tmp_update/bin/bloom-dev-ssh +++ b/static/build/.tmp_update/bin/bloom-dev-ssh @@ -22,6 +22,7 @@ HOST_KEY_DIR="$SD_ROOT/.bloom/ssh" HOST_KEY="$HOST_KEY_DIR/dropbear_ed25519_host_key" SFTP_SERVER=${BLOOM_SFTP_SERVER:-$SYS_DIR/bin/bloom-sftp-server} SFTP_CHROOT="$ROOT/tmp/bloom-sftp-root" +TRANSFER_POLICY=${BLOOM_TRANSFER_POLICY_BIN:-$SYS_DIR/bin/bloom-transfer-policy} DEV_NULL=${BLOOM_DEV_NULL:-$ROOT/dev/null} NETWORK_TRANSACTION=${BLOOM_NETWORK_TRANSACTION_BIN:-$SYS_DIR/bin/bloom-network-transaction} @@ -88,18 +89,19 @@ start_shadow() { } cleanup_sftp_chroot() { - for target in \ - "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" \ - "$SFTP_CHROOT/Roms" "$SFTP_CHROOT/BIOS" "$SFTP_CHROOT/Saves" \ - "$SFTP_CHROOT/Screenshots" "$SFTP_CHROOT/Themes" "$SFTP_CHROOT/Artwork" + mount_names="Roms BIOS Saves Screenshots Themes Artwork" + if [ -x "$TRANSFER_POLICY" ]; then + policy_names="$($TRANSFER_POLICY mounts 2>/dev/null | awk -F '\t' '{print $1}' | tr '\n' ' ' || true)" + [ -z "$policy_names" ] || mount_names=$policy_names + fi + for target in "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" do umount "$target" >/dev/null 2>&1 || true done + for name in $mount_names; do umount "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true; done rm -f "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" rmdir "$SFTP_CHROOT/.bloom-bin" "$SFTP_CHROOT/dev" >/dev/null 2>&1 || true - for name in Roms BIOS Saves Screenshots Themes Artwork; do - rmdir "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true - done + for name in $mount_names; do rmdir "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true; done rmdir "$SFTP_CHROOT" >/dev/null 2>&1 || true } @@ -150,21 +152,32 @@ stop_dropbear() { prepare_sftp_chroot() { [ -x "$SFTP_SERVER" ] && [ -c "$DEV_NULL" ] || return 1 + [ -x "$TRANSFER_POLICY" ] && "$TRANSFER_POLICY" validate || return 1 [ ! -L "$SFTP_CHROOT" ] || return 1 mkdir -p "$SFTP_CHROOT/.bloom-bin" "$SFTP_CHROOT/dev" || return 1 - for name in Roms BIOS Saves Screenshots Themes Artwork; do - if [ "$name" = Artwork ]; then source="$SD_ROOT/Imgs"; else source="$SD_ROOT/$name"; fi + mounts_file="$ROOT/tmp/bloom-transfer-mounts.$$" + [ ! -e "$mounts_file" ] && [ ! -L "$mounts_file" ] || return 1 + "$TRANSFER_POLICY" mounts >"$mounts_file" || { rm -f "$mounts_file"; return 1; } + tab="$(printf '\t')" + while IFS="$tab" read -r name source_path access; do + case "$name" in ''|*[!A-Za-z0-9_-]*) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + case "$source_path" in ''|*[!A-Za-z0-9_-]*) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + case "$access" in read_only|read_write) ;; *) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + source="$SD_ROOT/$source_path" [ -d "$source" ] && [ ! -L "$source" ] || continue - mkdir -p "$SFTP_CHROOT/$name" || { cleanup_sftp_chroot; return 1; } + mkdir -p "$SFTP_CHROOT/$name" || { rm -f "$mounts_file"; cleanup_sftp_chroot; return 1; } mount -o bind "$source" "$SFTP_CHROOT/$name" || { + rm -f "$mounts_file" cleanup_sftp_chroot return 1 } - if [ "$name" = Saves ] && ! mount -o remount,bind,ro "$SFTP_CHROOT/$name"; then + if [ "$access" = read_only ] && ! mount -o remount,bind,ro "$SFTP_CHROOT/$name"; then + rm -f "$mounts_file" cleanup_sftp_chroot return 1 fi - done + done <"$mounts_file" + rm -f "$mounts_file" : >"$SFTP_CHROOT/.bloom-bin/sftp-server" || { cleanup_sftp_chroot; return 1; } mount -o bind "$SFTP_SERVER" "$SFTP_CHROOT/.bloom-bin/sftp-server" || { cleanup_sftp_chroot diff --git a/static/build/.tmp_update/bin/bloom-sftp-gate b/static/build/.tmp_update/bin/bloom-sftp-gate index 9a035db72..de87ba38f 100755 --- a/static/build/.tmp_update/bin/bloom-sftp-gate +++ b/static/build/.tmp_update/bin/bloom-sftp-gate @@ -5,6 +5,7 @@ ROOT=${BLOOM_ROOT:-} SD_ROOT="$ROOT/mnt/SDCARD" STATE_DIR="$SD_ROOT/.bloom/network-services" SETTINGS=${BLOOM_SETTINGS_BIN:-$SD_ROOT/.tmp_update/bin/bloom-settings} +TRANSFER_POLICY=${BLOOM_TRANSFER_POLICY_BIN:-$SD_ROOT/.tmp_update/bin/bloom-transfer-policy} SFTP_ROOT="$ROOT/tmp/bloom-sftp-root" SERVER="$SFTP_ROOT/.bloom-bin/sftp-server" CHROOT=${BLOOM_SFTP_CHROOT_BIN:-$ROOT/usr/sbin/chroot} @@ -14,5 +15,12 @@ case "$enabled" in true) ;; false) exit 1 ;; *) [ -f "$STATE_DIR/sftp.enabled" ] [ -d "$SFTP_ROOT" ] && [ ! -L "$SFTP_ROOT" ] || exit 1 [ -x "$SERVER" ] || exit 1 [ -x "$CHROOT" ] || exit 1 +[ -x "$TRANSFER_POLICY" ] && "$TRANSFER_POLICY" validate || exit 1 +BLOOM_TRANSFER_WRITE_ROOTS="$($TRANSFER_POLICY mounts | awk -F '\t' '$3 == "read_write" {if (value != "") value = value ":"; value = value $1} END {print value}')" +case "$BLOOM_TRANSFER_WRITE_ROOTS" in + Roms:BIOS:Artwork:Screenshots:Themes) ;; + *) exit 1 ;; +esac +export BLOOM_TRANSFER_WRITE_ROOTS exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \ -P home-directory,users-groups-by-id diff --git a/static/build/.tmp_update/bin/bloom-transfer-policy b/static/build/.tmp_update/bin/bloom-transfer-policy new file mode 100755 index 000000000..511bd1395 --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-transfer-policy @@ -0,0 +1,157 @@ +#!/bin/sh +set -eu +umask 077 + +ROOT=${BLOOM_ROOT:-} +SD_ROOT=${BLOOM_SD_ROOT:-$ROOT/mnt/SDCARD} +SYSTEM_ROOT=${BLOOM_SYSTEM_ROOT:-$SD_ROOT/.tmp_update} +POLICY=${BLOOM_TRANSFER_POLICY:-$SYSTEM_ROOT/config/transfer-policy.json} +JQ=${BLOOM_JQ_BIN:-$SYSTEM_ROOT/bin/jq} +STAGING_ROOT=${BLOOM_TRANSFER_STAGING_ROOT:-$SD_ROOT/.bloom/transfer-staging} +TEMPORARY= + +fail() { printf 'bloom-transfer-policy: %s\n' "$*" >&2; exit 1; } +cleanup() { [ -z "$TEMPORARY" ] || rm -f "$TEMPORARY"; } +trap cleanup EXIT HUP INT TERM + +validate_policy() { + [ -x "$JQ" ] || return 1 + [ -f "$POLICY" ] && [ ! -L "$POLICY" ] || return 1 + "$JQ" -e ' + (keys | sort) == ["limits", "policy_id", "private_device_roots", "publication", "roots", "schema"] and + .schema == 1 and .policy_id == "bloomos-transfer-policy-v1" and + .publication == "same_filesystem_atomic_replace" and + .limits == {"max_path_bytes":1024,"max_components":32,"max_component_bytes":255,"max_upload_bytes":4294967295} and + [.roots[] | [.id, .virtual_path, .device_path, .access]] == [ + ["roms", "/Roms", "Roms", "read_write"], + ["bios", "/BIOS", "BIOS", "read_write"], + ["artwork", "/Artwork", "Imgs", "read_write"], + ["screenshots", "/Screenshots", "Screenshots", "read_write"], + ["themes", "/Themes", "Themes", "read_write"], + ["saves", "/Saves", "Saves", "read_only"] + ] and .private_device_roots == [".bloom", ".tmp_update"] + ' "$POLICY" >/dev/null 2>&1 +} + +validate_path_text() { + value=$1 + [ "$(printf %s "$value" | wc -c | tr -d ' ')" -le 1024 ] || return 1 + case "$value" in + /*) ;; + *) return 1 ;; + esac + case "$value" in + /|*//*|*/./*|*/../*|*/.|*/..|*/|*\\*) return 1 ;; + esac + "$JQ" -en --arg value "$value" ' + ($value | test("[\u0000-\u001f\u007f]")) | not + ' >/dev/null 2>&1 +} + +load_path() { + TRANSFER_PATH=$1 + validate_policy || return 1 + validate_path_text "$TRANSFER_PATH" || return 1 + without_slash=${TRANSFER_PATH#/} + first=${without_slash%%/*} + VIRTUAL_ROOT=/$first + ROOT_RECORD="$($JQ -ce --arg root "$VIRTUAL_ROOT" '[.roots[] | select(.virtual_path == $root)] | select(length == 1) | .[0]' "$POLICY" 2>/dev/null)" || return 1 + ROOT_ID="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.id')" || return 1 + DEVICE_PATH="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.device_path')" || return 1 + ACCESS="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.access')" || return 1 + if [ "$TRANSFER_PATH" = "$VIRTUAL_ROOT" ]; then RELATIVE=; else RELATIVE=${TRANSFER_PATH#"$VIRTUAL_ROOT"/}; fi + component_count=0 + old_ifs=$IFS + IFS=/ + for component in $RELATIVE; do + component_count=$((component_count + 1)) + [ "$component_count" -le 32 ] || { IFS=$old_ifs; return 1; } + [ "$(printf %s "$component" | wc -c | tr -d ' ')" -le 255 ] || { IFS=$old_ifs; return 1; } + case "$component" in ''|.|..|.bloom*) IFS=$old_ifs; return 1 ;; esac + done + IFS=$old_ifs + TARGET="$SD_ROOT/$DEVICE_PATH" + [ -z "$RELATIVE" ] || TARGET="$TARGET/$RELATIVE" +} + +safe_target_boundary() { + source_root="$SD_ROOT/$DEVICE_PATH" + [ -d "$source_root" ] && [ ! -L "$source_root" ] || return 1 + [ -z "$RELATIVE" ] && return 0 + current=$source_root + old_ifs=$IFS + IFS=/ + for component in $RELATIVE; do + current="$current/$component" + if [ -L "$current" ]; then IFS=$old_ifs; return 1; fi + if [ -e "$current" ] && [ "$current" != "$TARGET" ] && [ ! -d "$current" ]; then + IFS=$old_ifs + return 1 + fi + done + IFS=$old_ifs +} + +command_mounts() { + validate_policy || fail "policy is unavailable or invalid" + "$JQ" -er '.roots[] | [.virtual_path[1:], .device_path, .access] | @tsv' "$POLICY" +} + +command_authorize() { + operation=$1 + load_path "$2" || fail "path is outside the canonical transfer policy" + case "$operation" in + read) ;; + write) [ "$ACCESS" = read_write ] || fail "root is read-only" ;; + *) fail "operation is unsupported" ;; + esac + safe_target_boundary || fail "path crosses a symlink or non-directory boundary" + "$JQ" -cn --arg operation "$operation" --arg root "$ROOT_ID" \ + --arg access "$ACCESS" --arg virtual_path "$TRANSFER_PATH" --arg device_path "$TARGET" \ + '{schema:1,service:"bloom-transfer-policy",authorized:true,operation:$operation,root:$root,access:$access,virtual_path:$virtual_path,device_path:$device_path}' +} + +command_publish() { + load_path "$1" || fail "path is outside the canonical transfer policy" + [ "$ACCESS" = read_write ] || fail "root is read-only" + [ -n "$RELATIVE" ] || fail "a root cannot be replaced" + safe_target_boundary || fail "path crosses a symlink or non-directory boundary" + parent=${TARGET%/*} + [ -d "$parent" ] && [ ! -L "$parent" ] || fail "destination parent is unavailable" + [ ! -L "$TARGET" ] || fail "destination is a symlink" + staged=$2 + case "$staged" in "$STAGING_ROOT"/*) ;; *) fail "staged file is outside the private staging root" ;; esac + [ -d "$STAGING_ROOT" ] && [ ! -L "$STAGING_ROOT" ] || fail "staging root is unsafe" + [ -f "$staged" ] && [ ! -L "$staged" ] || fail "staged file is unavailable" + size="$(wc -c <"$staged" | tr -d ' ')" + case "$size" in ''|*[!0-9]*) fail "staged file size is invalid" ;; esac + [ "$size" -le 4294967295 ] || fail "staged file exceeds the policy limit" + TEMPORARY="$parent/.bloom-replace-$$" + [ ! -e "$TEMPORARY" ] && [ ! -L "$TEMPORARY" ] || fail "atomic replacement slot is occupied" + cp "$staged" "$TEMPORARY" || fail "cannot copy staged bytes into the destination filesystem" + chmod 0644 "$TEMPORARY" || fail "cannot set replacement permissions" + [ "$(wc -c <"$TEMPORARY" | tr -d ' ')" = "$size" ] || fail "replacement byte count changed" + sync + mv -f "$TEMPORARY" "$TARGET" || fail "cannot atomically publish replacement" + TEMPORARY= + sync + rm -f "$staged" + "$JQ" -cn --arg root "$ROOT_ID" --arg virtual_path "$TRANSFER_PATH" --argjson size "$size" \ + '{schema:1,service:"bloom-transfer-policy",operation:"publish",published:true,publication:"same_filesystem_atomic_replace",root:$root,virtual_path:$virtual_path,size:$size}' +} + +command_status() { + validate_policy || fail "policy is unavailable or invalid" + sha="$(sha256sum "$POLICY" | awk '{print $1}')" + "$JQ" -cn --arg sha256 "$sha" \ + '{schema:1,service:"bloom-transfer-policy",state:"ready",policy_id:"bloomos-transfer-policy-v1",policy_sha256:$sha256,roots:6,read_write_roots:5,read_only_roots:1,publication:"same_filesystem_atomic_replace"}' +} + +case "$#:${1:-}" in + 1:validate) validate_policy || fail "policy is unavailable or invalid" ;; + 1:mounts) command_mounts ;; + 1:status) command_status ;; + 3:authorize) command_authorize "$2" "$3" ;; + 3:publish) command_publish "$2" "$3" ;; + *) fail "usage: status | validate | mounts | authorize | publish " ;; +esac diff --git a/test/shell/developer_ssh.bats b/test/shell/developer_ssh.bats index 16237b859..7e11fabea 100755 --- a/test/shell/developer_ssh.bats +++ b/test/shell/developer_ssh.bats @@ -14,10 +14,18 @@ load 'support/test_helper' grep -F 'export LDFLAGS="-static -Wl,--gc-sections"' \ /workspace/tools/build-sftp-server.sh grep -F 'getuid() != 0' /workspace/tools/patches/openssh-sftp-static-user.patch + grep -F 'bloom_atomic_open' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'fsync(handles[handle].fd)' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'rename(handles[handle].atomic_name' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'getenv("BLOOM_TRANSFER_WRITE_ROOTS")' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '*scan < 0x20 || *scan == 0x7f' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '++components > 32' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F 'exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \' \ /workspace/static/build/.tmp_update/bin/bloom-sftp-gate grep -F -- '-P home-directory,users-groups-by-id' \ /workspace/static/build/.tmp_update/bin/bloom-sftp-gate + grep -F '"$TRANSFER_POLICY" mounts' \ + /workspace/static/build/.tmp_update/bin/bloom-dev-ssh } setup() { @@ -46,6 +54,9 @@ EOF export BLOOM_DROPBEAR="$MOCK_BIN/dropbear" mock_command sftp-server export BLOOM_SFTP_SERVER="$MOCK_BIN/sftp-server" + export BLOOM_TRANSFER_POLICY_BIN=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" export BLOOM_DEV_NULL=/dev/null export BLOOM_NETWORK_TRANSACTION_BIN="$MOCK_BIN/network-transaction" cat >"$BLOOM_NETWORK_TRANSACTION_BIN" <<'EOF' diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index 770ec65bb..af4b33949 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: 329 components" ]] + [[ "$output" == "legacy manifest validate: 330 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)" ] @@ -44,6 +44,7 @@ assert components["runtime-tmp-update-bin-bloomctl"]["path"] == "static/build/.t assert components["runtime-tmp-update-bin-bloom-ra-login"]["path"] == "static/build/.tmp_update/bin/bloom-ra-login" assert components["runtime-tmp-update-bin-bloom-listener-audit"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-dev-deploy"]["resolution"] == "source-build" +assert components["runtime-tmp-update-bin-bloom-transfer-policy"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-network-transaction"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-remote-keys"]["resolution"] == "source-build" assert components["runtime-tmp-update-script-bloom-launch-trace-sh"]["resolution"] == "source-build" @@ -103,7 +104,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) == 52 +assert len(bloom) == 53 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_services.bats b/test/shell/network_services.bats index df6802fe1..ff6dc678d 100644 --- a/test/shell/network_services.bats +++ b/test/shell/network_services.bats @@ -237,6 +237,9 @@ exec "$root$program" "$@" EOF chmod +x "$chroot" export MOCK_CHROOT_ARGS="$BATS_TEST_TMPDIR/chroot-arguments" + export BLOOM_TRANSFER_POLICY_BIN=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" run env BLOOM_ROOT="$BLOOM_NETWORK_SERVICES_ROOT" BLOOM_SFTP_CHROOT_BIN="$chroot" \ sh "$gate" [ "$status" -ne 0 ] diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats new file mode 100755 index 000000000..4c9384dcc --- /dev/null +++ b/test/shell/transfer_policy.bats @@ -0,0 +1,117 @@ +#!/usr/bin/env bats + +load 'support/test_helper' + +setup() { + setup_bloom_fixture + export POLICY_HELPER=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_ROOT="$BLOOM_TEST_ROOT" + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" + export BLOOM_TRANSFER_STAGING_ROOT="$SDCARD/.bloom/transfer-staging" + mkdir -p "$SDCARD/Roms/GBA" "$SDCARD/BIOS" "$SDCARD/Imgs" \ + "$SDCARD/Screenshots" "$SDCARD/Themes" "$SDCARD/Saves" \ + "$BLOOM_TRANSFER_STAGING_ROOT" +} + +teardown() { + teardown_bloom_fixture +} + +@test "canonical policy has five read-write roots, read-only Saves, and private denials" { + run python3 /workspace/tools/validate_transfer_policy.py \ + --policy /workspace/build/transfer-policy.json \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -eq 0 ] + [ "$output" = "transfer policy valid: 6 roots, 5 read-write, 1 read-only" ] + + run "$POLICY_HELPER" status + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e ' + .state == "ready" and .roots == 6 and .read_write_roots == 5 and + .read_only_roots == 1 and .publication == "same_filesystem_atomic_replace" + ' + + run "$POLICY_HELPER" mounts + [ "$status" -eq 0 ] + [ "$output" = $'Roms\tRoms\tread_write\nBIOS\tBIOS\tread_write\nArtwork\tImgs\tread_write\nScreenshots\tScreenshots\tread_write\nThemes\tThemes\tread_write\nSaves\tSaves\tread_only' ] +} + +@test "authorization rejects private roots, traversal, symlinks, and writes to Saves" { + run "$POLICY_HELPER" authorize write /Roms/GBA/game.gba + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.authorized and .root == "roms" and .access == "read_write"' + + run "$POLICY_HELPER" authorize read /Saves/CurrentProfile/save.srm + [ "$status" -eq 0 ] + run "$POLICY_HELPER" authorize write /Saves/CurrentProfile/save.srm + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /.bloom/authorized_keys + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /Roms/../.bloom/authorized_keys + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /Roms//GBA/game.gba + [ "$status" -ne 0 ] + + ln -s "$SDCARD/BIOS" "$SDCARD/Roms/linked" + run "$POLICY_HELPER" authorize read /Roms/linked/private.bin + [ "$status" -ne 0 ] +} + +@test "publish copies into the destination filesystem and atomically replaces the target" { + printf 'old-bytes\n' >"$SDCARD/Roms/GBA/game.gba" + staged="$BLOOM_TRANSFER_STAGING_ROOT/upload-1" + printf 'new-complete-bytes\n' >"$staged" + + run "$POLICY_HELPER" publish /Roms/GBA/game.gba "$staged" + [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 + printf '%s\n' "$output" | jq -e '.published and .publication == "same_filesystem_atomic_replace" and .root == "roms"' + [ "$(cat "$SDCARD/Roms/GBA/game.gba")" = "new-complete-bytes" ] + [ ! -e "$staged" ] + [ -z "$(find "$SDCARD/Roms/GBA" -name '.bloom-replace-*' -print)" ] + + printf 'blocked\n' >"$BLOOM_TRANSFER_STAGING_ROOT/upload-2" + run "$POLICY_HELPER" publish /Saves/slot.srm "$BLOOM_TRANSFER_STAGING_ROOT/upload-2" + [ "$status" -ne 0 ] + [ ! -e "$SDCARD/Saves/slot.srm" ] + [ -f "$BLOOM_TRANSFER_STAGING_ROOT/upload-2" ] +} + +@test "validator rejects altered access and newly exposed roots" { + candidate="$BATS_TEST_TMPDIR/policy.json" + jq '.roots |= map(if .id == "saves" then .access = "read_write" else . end)' \ + /workspace/build/transfer-policy.json >"$candidate" + run python3 /workspace/tools/validate_transfer_policy.py --policy "$candidate" \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -ne 0 ] + + jq '.roots += [{"id":"private","virtual_path":"/Private","device_path":".bloom","access":"read_write"}]' \ + /workspace/build/transfer-policy.json >"$candidate" + run python3 /workspace/tools/validate_transfer_policy.py --policy "$candidate" \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -ne 0 ] +} + +@test "Warm deployment requires the policy, consumer, and authority together" { + payload="$BATS_TEST_TMPDIR/payload" + manifest="$BATS_TEST_TMPDIR/manifest.json" + mkdir -p "$payload/bin" "$payload/config" + cp /workspace/static/build/.tmp_update/bin/bloom-dev-ssh "$payload/bin/" + cp /workspace/static/build/.tmp_update/bin/bloom-sftp-gate "$payload/bin/" + cp /workspace/static/build/.tmp_update/bin/bloom-transfer-policy "$payload/bin/" + printf '#!/bin/sh\nexit 0\n' >"$payload/bin/bloom-sftp-server" + cp /workspace/build/transfer-policy.json "$payload/config/" + chmod 0755 "$payload/bin/bloom-dev-ssh" "$payload/bin/bloom-sftp-gate" \ + "$payload/bin/bloom-sftp-server" "$payload/bin/bloom-transfer-policy" + + run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ + net02-fixture 0123456789abcdef0123456789abcdef01234567 "$manifest" + [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 + jq -e '[.files[].path] == ["bin/bloom-dev-ssh", "bin/bloom-sftp-gate", "bin/bloom-sftp-server", "bin/bloom-transfer-policy", "config/transfer-policy.json"]' "$manifest" + + rm "$payload/config/transfer-policy.json" + run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ + net02-incomplete 0123456789abcdef0123456789abcdef01234567 "$manifest" + [ "$status" -ne 0 ] + [[ "$output" == *"network transfer policy bundle is incomplete"* ]] +} diff --git a/tools/build-sftp-server.sh b/tools/build-sftp-server.sh index 454ce1342..6c131927f 100755 --- a/tools/build-sftp-server.sh +++ b/tools/build-sftp-server.sh @@ -22,6 +22,7 @@ cleanup() { trap cleanup EXIT git -C "$SOURCE_REPO" archive HEAD | tar -x -C "$SOURCE_DIR" patch -d "$SOURCE_DIR" -p1 <"$ROOT_DIR/tools/patches/openssh-sftp-static-user.patch" +patch -d "$SOURCE_DIR" -p1 <"$ROOT_DIR/tools/patches/openssh-sftp-atomic-replace.patch" touch "$SOURCE_DIR/configure" TOOL_PREFIX="${CROSS_COMPILE:-arm-linux-gnueabihf-}" diff --git a/tools/ci-scope.sh b/tools/ci-scope.sh index 6d181095c..5eb640c9f 100755 --- a/tools/ci-scope.sh +++ b/tools/ci-scope.sh @@ -172,8 +172,9 @@ while IFS= read -r path; do build/dependencies.lock|build/legacy-manifest.json|build/core-selection-policy*.json|\ build/core-inventory-disposition*.json|\ build/save-migration-policy*.json|\ + build/transfer-policy*.json|\ static/*|test/*|docs/*|tools/legacy_manifest.py|\ - tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/ci-scope.sh) + tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/validate_transfer_policy.py|tools/ci-scope.sh) ;; *) full=1 @@ -203,6 +204,20 @@ while IFS= read -r path; do add_shell_target /workspace/test/shell/save_migration_policy.bats add_shell_target /workspace/test/shell/save_migration.bats ;; + build/transfer-policy*.json|tools/validate_transfer_policy.py|\ + static/build/.tmp_update/bin/bloom-transfer-policy) + raise_risk 2 + add_shell_target /workspace/test/shell/transfer_policy.bats + add_shell_target /workspace/test/shell/developer_ssh.bats + ;; + tools/build-sftp-server.sh|tools/patches/openssh-sftp-*.patch|\ + static/build/.tmp_update/bin/bloom-dev-ssh|\ + static/build/.tmp_update/bin/bloom-sftp-gate) + raise_risk 3 + add_shell_target /workspace/test/shell/transfer_policy.bats + add_shell_target /workspace/test/shell/developer_ssh.bats + add_shell_target /workspace/test/shell/network_services.bats + ;; test/test_bloom_ui*.cpp) raise_risk 1 add_native_filter 'BloomUi*' diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index a09b8ccf2..598b57d83 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -47,10 +47,13 @@ "bin/bloom-platform", "bin/bloom-dev-ssh", "bin/bloom-sftp-gate", + "bin/bloom-sftp-server", + "bin/bloom-transfer-policy", "bin/bloom-remote-keys", "bin/bloomctl", "bin/bloom-listener-audit", "bin/bloom-wifi", + "config/transfer-policy.json", "script/network/update_networking.sh", "script/network/wpsclient.sh", }, @@ -68,6 +71,14 @@ "save-migration": EXACT_PATHS["save-migration"], } +NETWORK_TRANSFER_PATHS = { + "bin/bloom-dev-ssh", + "bin/bloom-sftp-gate", + "bin/bloom-sftp-server", + "bin/bloom-transfer-policy", + "config/transfer-policy.json", +} + def allowed(component, path): if component == "ui-assets": @@ -118,6 +129,9 @@ def build_manifest(component, root, deployment_id, base_commit): raise ValueError("payload does not contain an allowlisted file") required = REQUIRED_PATHS.get(component) present = {item["path"] for item in files} + if component == "network-helpers" and present.intersection(NETWORK_TRANSFER_PATHS): + if not NETWORK_TRANSFER_PATHS.issubset(present): + raise ValueError("network transfer policy bundle is incomplete") if required is not None and present != required: missing = sorted(required - present) unexpected = sorted(present - required) diff --git a/tools/patches/openssh-sftp-atomic-replace.patch b/tools/patches/openssh-sftp-atomic-replace.patch new file mode 100644 index 000000000..aa695605f --- /dev/null +++ b/tools/patches/openssh-sftp-atomic-replace.patch @@ -0,0 +1,285 @@ +diff --git a/sftp-server.c b/sftp-server.c +--- a/sftp-server.c ++++ b/sftp-server.c +@@ -314,6 +314,7 @@ struct Handle { + int fd; + int flags; + char *name; ++ char *atomic_name; + u_int64_t bytes_read, bytes_write; + int next_unused; + }; +@@ -359,6 +360,7 @@ handle_new(int use, const char *name, int fd, int flags, DIR *dirp) + handles[i].fd = fd; + handles[i].flags = flags; + handles[i].name = xstrdup(name); ++ handles[i].atomic_name = NULL; + handles[i].bytes_read = handles[i].bytes_write = 0; + + return i; +@@ -443,6 +445,15 @@ handle_to_flags(int handle) + return 0; + } + ++static int ++handle_set_atomic_name(int handle, char *atomic_name) ++{ ++ if (!handle_is_ok(handle, HANDLE_FILE) || atomic_name == NULL) ++ return -1; ++ handles[handle].atomic_name = atomic_name; ++ return 0; ++} ++ + static void + handle_update_read(int handle, ssize_t bytes) + { +@@ -477,7 +488,28 @@ handle_close(int handle) +- int ret = -1; ++ int ret = -1, saved_errno = 0; + + if (handle_is_ok(handle, HANDLE_FILE)) { +- ret = close(handles[handle].fd); ++ if (handles[handle].atomic_name == NULL) { ++ ret = close(handles[handle].fd); ++ } else if (fsync(handles[handle].fd) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ close(handles[handle].fd); ++ unlink(handles[handle].atomic_name); ++ } else if (close(handles[handle].fd) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ unlink(handles[handle].atomic_name); ++ } else if (rename(handles[handle].atomic_name, ++ handles[handle].name) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ unlink(handles[handle].atomic_name); ++ } else { ++ ret = 0; ++ } ++ if (ret == -1 && saved_errno != 0) ++ errno = saved_errno; ++ free(handles[handle].atomic_name); + free(handles[handle].name); + handle_unused(handle); + } else if (handle_is_ok(handle, HANDLE_DIR)) { +@@ -511,7 +537,12 @@ handle_log_exit(void) + + for (i = 0; i < num_handles; i++) + if (handles[i].use != HANDLE_UNUSED) + handle_log_close(i, "forced"); ++ for (i = 0; i < num_handles; i++) { ++ if (handles[i].use == HANDLE_FILE && ++ handles[i].atomic_name != NULL) ++ unlink(handles[i].atomic_name); ++ } + } + + static int +@@ -738,6 +770,158 @@ send_statvfs(u_int32_t id, struct statvfs *st) + sshbuf_free(msg); + } + ++static int ++bloom_safe_write_path(const char *name) ++{ ++ const char *allowed, *end, *root_end; ++ const unsigned char *scan; ++ char *copy, *cursor, *slash; ++ struct stat st; ++ u_int components = 0; ++ ++ if (name == NULL || *name == '\0' || strlen(name) > 1024 || ++ strstr(name, "//") != NULL) ++ return 0; ++ for (scan = (const unsigned char *)name; *scan != '\0'; scan++) { ++ if (*scan < 0x20 || *scan == 0x7f || *scan == '\\') ++ return 0; ++ } ++ cursor = (char *)name; ++ while (*cursor == '/') ++ cursor++; ++ root_end = strchr(cursor, '/'); ++ if (root_end == NULL || root_end == cursor || ++ (allowed = getenv("BLOOM_TRANSFER_WRITE_ROOTS")) == NULL) ++ return 0; ++ while (*allowed != '\0') { ++ end = strchr(allowed, ':'); ++ if (end == NULL) ++ end = allowed + strlen(allowed); ++ if ((size_t)(end - allowed) == (size_t)(root_end - cursor) && ++ memcmp(allowed, cursor, end - allowed) == 0) ++ break; ++ allowed = *end == ':' ? end + 1 : end; ++ } ++ if (*allowed == '\0') ++ return 0; ++ copy = xstrdup(name); ++ cursor = copy; ++ while (*cursor == '/') ++ cursor++; ++ while ((slash = strchr(cursor, '/')) != NULL) { ++ *slash = '\0'; ++ if (*cursor == '\0' || strcmp(cursor, ".") == 0 || ++ strcmp(cursor, "..") == 0 || strlen(cursor) > 255) { ++ free(copy); ++ return 0; ++ } ++ if (++components > 32) { ++ free(copy); ++ return 0; ++ } ++ *slash = '/'; ++ if (slash != copy && slash[1] != '\0') { ++ *slash = '\0'; ++ if (lstat(copy, &st) == -1 || !S_ISDIR(st.st_mode) || ++ S_ISLNK(st.st_mode)) { ++ free(copy); ++ return 0; ++ } ++ *slash = '/'; ++ } ++ cursor = slash + 1; ++ } ++ if (*cursor == '\0' || strcmp(cursor, ".") == 0 || ++ strcmp(cursor, "..") == 0 || strlen(cursor) > 255 || ++ ++components > 32) { ++ free(copy); ++ return 0; ++ } ++ if (lstat(name, &st) == 0 && S_ISLNK(st.st_mode)) { ++ free(copy); ++ return 0; ++ } ++ free(copy); ++ return 1; ++} ++ ++static int ++bloom_copy_existing(int source, int destination) ++{ ++ char buffer[32768]; ++ ssize_t got, offset, written; ++ ++ while ((got = read(source, buffer, sizeof(buffer))) > 0) { ++ for (offset = 0; offset < got; offset += written) { ++ written = write(destination, buffer + offset, got - offset); ++ if (written <= 0) ++ return -1; ++ } ++ } ++ return got == 0 ? 0 : -1; ++} ++ ++static int ++bloom_atomic_open(const char *name, int flags, mode_t mode, char **atomic_name) ++{ ++ static u_int counter; ++ struct stat st; ++ int fd = -1, source = -1, attempt, exists, saved_errno; ++ ++ *atomic_name = NULL; ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ return -1; ++ } ++ if (lstat(name, &st) == 0) ++ exists = 1; ++ else if (errno == ENOENT) ++ exists = 0; ++ else ++ return -1; ++ if (!exists && (flags & O_CREAT) == 0) { ++ errno = ENOENT; ++ return -1; ++ } ++ if (exists && (flags & O_CREAT) != 0 && ++ (flags & O_EXCL) != 0) { ++ errno = EEXIST; ++ return -1; ++ } ++ for (attempt = 0; attempt < 32; attempt++) { ++ xasprintf(atomic_name, "%s.bloom-sftp-%ld-%u", name, ++ (long)getpid(), counter++); ++ fd = open(*atomic_name, (flags & ~(O_TRUNC|O_CREAT)) | ++ O_CREAT | O_EXCL, mode); ++ if (fd != -1) ++ break; ++ free(*atomic_name); ++ *atomic_name = NULL; ++ if (errno != EEXIST) ++ return -1; ++ } ++ if (fd == -1) ++ return -1; ++ if (exists && fchmod(fd, st.st_mode & 0777) == -1) { ++ saved_errno = errno; ++ close(fd); unlink(*atomic_name); free(*atomic_name); ++ *atomic_name = NULL; errno = saved_errno; return -1; ++ } ++ if (exists && (flags & O_TRUNC) == 0) { ++ source = open(name, O_RDONLY); ++ if (source == -1 || bloom_copy_existing(source, fd) == -1) { ++ saved_errno = errno; ++ if (source != -1) ++ close(source); ++ close(fd); unlink(*atomic_name); free(*atomic_name); ++ *atomic_name = NULL; errno = saved_errno; return -1; ++ } ++ } ++ if (source != -1) ++ close(source); ++ return fd; ++} ++ + static void + process_open(u_int32_t id) + { +@@ -745,6 +862,7 @@ process_open(u_int32_t id) + u_int32_t pflags; + Attrib a; + char *name; ++ char *atomic_name = NULL; + int r, handle, fd, flags, mode, status = SSH2_FX_FAILURE; + + if ((r = sshbuf_get_cstring(iqueue, &name, NULL)) != 0 || +@@ -762,14 +880,23 @@ process_open(u_int32_t id) + verbose("Refusing open request in read-only mode"); + status = SSH2_FX_PERMISSION_DENIED; + } else { +- fd = open(name, flags, mode); ++ if ((flags & O_ACCMODE) == O_RDONLY) ++ fd = open(name, flags, mode); ++ else ++ fd = bloom_atomic_open(name, flags, mode, &atomic_name); + if (fd == -1) { + status = errno_to_portable(errno); + } else { + handle = handle_new(HANDLE_FILE, name, fd, flags, NULL); + if (handle < 0) { + close(fd); ++ if (atomic_name != NULL) { ++ unlink(atomic_name); ++ free(atomic_name); ++ } + } else { ++ if (atomic_name != NULL) ++ handle_set_atomic_name(handle, atomic_name); + send_handle(id, handle); + status = SSH2_FX_OK; + } +diff --git a/sftp-server.8 b/sftp-server.8 +--- a/sftp-server.8 ++++ b/sftp-server.8 +@@ -46,6 +46,10 @@ is a program that speaks the server side of SFTP protocol to standard + input and sends replies to standard output. + .Nm + is not intended to be called directly, but from ++.Pp ++The BloomOS build stages writable file handles beside their destination, ++synchronizes completed bytes, and atomically renames them into place on close. ++Interrupted writes do not truncate the previous final file. + .Xr sshd 8 + using the + .Cm Subsystem diff --git a/tools/validate_transfer_policy.py b/tools/validate_transfer_policy.py new file mode 100755 index 000000000..0e52265d1 --- /dev/null +++ b/tools/validate_transfer_policy.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Validate BloomOS's canonical transfer/share policy.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath + + +EXPECTED_ROOTS = { + "roms": ("/Roms", "Roms", "read_write"), + "bios": ("/BIOS", "BIOS", "read_write"), + "artwork": ("/Artwork", "Imgs", "read_write"), + "screenshots": ("/Screenshots", "Screenshots", "read_write"), + "themes": ("/Themes", "Themes", "read_write"), + "saves": ("/Saves", "Saves", "read_only"), +} + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as source: + value = json.load(source) + if not isinstance(value, dict): + raise ValueError(f"{path}: root must be an object") + return value + + +def validate(policy: dict, schema: dict) -> list[str]: + errors: list[str] = [] + expected_keys = { + "schema", "policy_id", "publication", "limits", "roots", "private_device_roots" + } + if set(policy) != expected_keys: + errors.append("policy keys do not match the version-1 contract") + if policy.get("schema") != 1 or policy.get("policy_id") != "bloomos-transfer-policy-v1": + errors.append("unsupported transfer policy identity") + if policy.get("publication") != "same_filesystem_atomic_replace": + errors.append("writes must use same-filesystem atomic replacement") + if schema.get("$id") != "https://bloomos.dev/schemas/transfer-policy-v1.json": + errors.append("unsupported transfer policy schema identity") + + limits = policy.get("limits") + expected_limits = { + "max_path_bytes": 1024, + "max_components": 32, + "max_component_bytes": 255, + "max_upload_bytes": 4294967295, + } + if limits != expected_limits: + errors.append("transfer limits do not match the bounded version-1 contract") + + roots = policy.get("roots") + if not isinstance(roots, list): + errors.append("roots must be an array") + roots = [] + seen_ids: set[str] = set() + seen_virtual: set[str] = set() + seen_device: set[str] = set() + actual: dict[str, tuple[str, str, str]] = {} + for index, root in enumerate(roots): + if not isinstance(root, dict) or set(root) != {"id", "virtual_path", "device_path", "access"}: + errors.append(f"roots[{index}] does not match the root contract") + continue + identifier = root.get("id") + virtual = root.get("virtual_path") + device = root.get("device_path") + access = root.get("access") + if not all(isinstance(value, str) for value in (identifier, virtual, device, access)): + errors.append(f"roots[{index}] fields must be strings") + continue + if identifier in seen_ids or virtual in seen_virtual or device in seen_device: + errors.append(f"roots[{index}] duplicates an id or path") + seen_ids.add(identifier) + seen_virtual.add(virtual) + seen_device.add(device) + path = PurePosixPath(virtual) + if not virtual.startswith("/") or len(path.parts) != 2 or any(part in {".", ".."} for part in path.parts): + errors.append(f"roots[{index}] virtual path is not a single bounded component") + if "/" in device or "\\" in device or device in {"", ".", ".."}: + errors.append(f"roots[{index}] device path is unsafe") + actual[identifier] = (virtual, device, access) + if actual != EXPECTED_ROOTS: + errors.append("root dispositions must exactly cover ROMs, BIOS, artwork, screenshots, themes, and saves") + + private = policy.get("private_device_roots") + if private != [".bloom", ".tmp_update"]: + errors.append("private device roots must explicitly deny .bloom and .tmp_update") + if isinstance(private, list) and seen_device.intersection(private): + errors.append("a private root is exposed by the transfer policy") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--policy", type=Path, default=Path("build/transfer-policy.json")) + parser.add_argument("--schema", type=Path, default=Path("build/transfer-policy.schema.json")) + args = parser.parse_args() + try: + errors = validate(load_json(args.policy), load_json(args.schema)) + except (OSError, json.JSONDecodeError, ValueError) as error: + errors = [str(error)] + if errors: + for error in errors: + print(f"transfer policy invalid: {error}") + return 1 + print("transfer policy valid: 6 roots, 5 read-write, 1 read-only") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/website/docs/07-apps/03-network-features/03-ssh-sftp.md b/website/docs/07-apps/03-network-features/03-ssh-sftp.md index ebe2d481e..a6bdd21b3 100644 --- a/website/docs/07-apps/03-network-features/03-ssh-sftp.md +++ b/website/docs/07-apps/03-network-features/03-ssh-sftp.md @@ -9,14 +9,20 @@ slug: /network/ssh SSH provides a secure command line method to communicate with your Miyoo Mini+. -SFTP provides a secure file transfer method +SFTP provides an encrypted, public-key-only file transfer method. + +BloomOS exposes only `Roms`, `BIOS`, `Artwork`, `Screenshots`, `Themes`, and +read-only `Saves`. System, credential, update, and other private directories are +not visible through SFTP. Enable it separately from shell access in Bloom +Settings. Upload clients should finish transfers with a rename; Bloom's shared +transfer policy provides atomic publication for Bloom-managed transfer tools. ## Features *![](https://github.com/OnionUI/Onion/assets/47260768/64e1bf60-3670-4e84-b0f8-89f3575cc378)* -- Virtual password auth. +- Public-key-only authentication - Command-Line Interface @@ -27,65 +33,31 @@ SFTP provides a secure file transfer method - Encrypted -## Enabling SSH - -You'll find it in **Apps** › **Tweaks** › **Network** › **SSH: Secure shell** - -You can either use the master toggle to turn the SSH server on with your Right/Left arrows, or press A to enter the submenu. - -In the submenu you'll get 2 options: - -1. **Enable** - Activate SSH access - -2. **Enable authentication** - Activate authentication (username/password) +## Enabling access -![](https://github.com/OnionUI/Onion/assets/47260768/f309e712-2027-4356-b6d2-7e43ace312f5) +Open **Settings** › **Network** and enable SSH shell access, SFTP file access, +or both. The controls are independent and appear only on a device with the +required network capability. +At least one valid public key must already be installed through Bloom's bounded +Authorized Keys workflow. BloomOS does not support password or blank-password +login. Your client must pin the device host key and use the corresponding +private identity. ## Logging in -:::note Default credentials -**Username:** `onion` -**Password:** `onion` -*We're using a new auth system, user defined passwords will come in a future update.* -::: - -Once you've activated your SSH server in Tweaks you'll now be able to connect using the integrated terminal emulator in Windows, Mac or Linux or with separate software such as PuTTY, SolarPuTTY or Hyperterm. You'll also be able to use an SFTP client such as FileZilla to transfer files securely. - -As an example, in most terminal emulators you can connect with the following: +Use the device address shown in Network Settings with your existing key-only +client configuration. For example: ``` -ssh onion@ipaddress -``` -You'll be prompted to enter the password, `onion`, and then you'll be greeted with a shell prompt: - -![](./assets/terminal-ssh.png) - -You'll need the IP of the device, found below (**Tweaks** › **Network**). - -![](https://github.com/OnionUI/Onion/assets/47260768/b7d5bc34-4032-4f38-81a4-79e069cfd2ac) - - -### For developers - -The ultimate `ssh` command for developers (replace `[IP_ADDRESS]` with your device's IP): - -```sh -ssh -o MACs=hmac-sha1 onion@[IP_ADDRESS] -t "cd /mnt/SDCARD; source .tmp_update/script/init_env.sh; ash" +ssh -i /path/to/bloom_key root@[IP_ADDRESS] +sftp -i /path/to/bloom_key root@[IP_ADDRESS] ``` -This will initialize the environment, giving direct access to binaries located in `.tmp_update/bin` (see [init_env.sh](https://github.com/OnionUI/Onion/blob/main/static/build/.tmp_update/script/init_env.sh)). - - -## Disabling authentication - -Although this is not recommended on other peoples Wi-Fi, when you're at home on your own Wi-Fi this is absolutely fine (or any other secure Wi-Fi!) - -Head over to **Tweaks** › **Network** › **SSH** and toggle: - -![](https://github.com/OnionUI/Onion/assets/47260768/4ccc836b-08d6-44cb-8e5d-9f795be0c85f) +SFTP-only mode does not grant a command shell: every authorized key is forced +through the confined file-transfer subsystem and cannot request a PTY, +forwarding, an agent, or arbitrary commands. -Now you can connect with the username `root` and no password. ## Security :::caution Network security From 2415ead2099745b39152c81afa6ad43333cfdd75 Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 07:08:11 -0400 Subject: [PATCH 02/29] fix(network): provision public transfer roots --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 ++-- docs/program/BloomOS_Work_Program_v3.json | 4 +++- static/build/.tmp_update/bin/bloom-dev-ssh | 13 ++++++++++++- test/shell/developer_ssh.bats | 3 ++- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index bdec5911f..e2b4bf2d1 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314620962 +byte_count = 314621295 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 725496651..4b90668e8 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -501,8 +501,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-ssh", "file_count": 1, - "byte_count": 13206, - "tree_sha256": "c9bfb3e24cfa689bbc3ead0325de50e1b27b0308feeb4d949cfe42df890ec8ab", + "byte_count": 13539, + "tree_sha256": "967940060ee432fe115d726afef2ad59ce07d07bcf1e11db5e3e7a71e5514b9f", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index d50ddf078..a29178d3c 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -776,7 +776,9 @@ "evidence": [ "host candidate defines one schema-validated transfer authority for five read-write roots, read-only Saves, private-root denial, bounded paths, and same-filesystem atomic publication consumed by SFTP mount and write gates", "the complete pinned shell gate passed 634/634; focused policy and inventory validation passed 28/28; legacy inventory, development provenance, shell syntax, patch hunk counts, and CI scope gates passed", - "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server; Mini Plus Warm validation and rollback remain required before publication" + "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server", + "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", + "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; RAM-only exact-hash validation is continuing without replacing installed files" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-dev-ssh b/static/build/.tmp_update/bin/bloom-dev-ssh index af031845d..11d048499 100755 --- a/static/build/.tmp_update/bin/bloom-dev-ssh +++ b/static/build/.tmp_update/bin/bloom-dev-ssh @@ -164,7 +164,18 @@ prepare_sftp_chroot() { case "$source_path" in ''|*[!A-Za-z0-9_-]*) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac case "$access" in read_only|read_write) ;; *) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac source="$SD_ROOT/$source_path" - [ -d "$source" ] && [ ! -L "$source" ] || continue + if [ ! -e "$source" ] && [ ! -L "$source" ]; then + [ "$access" = read_write ] && mkdir "$source" || { + rm -f "$mounts_file" + cleanup_sftp_chroot + return 1 + } + fi + [ -d "$source" ] && [ ! -L "$source" ] || { + rm -f "$mounts_file" + cleanup_sftp_chroot + return 1 + } mkdir -p "$SFTP_CHROOT/$name" || { rm -f "$mounts_file"; cleanup_sftp_chroot; return 1; } mount -o bind "$source" "$SFTP_CHROOT/$name" || { rm -f "$mounts_file" diff --git a/test/shell/developer_ssh.bats b/test/shell/developer_ssh.bats index 7e11fabea..f1564424a 100755 --- a/test/shell/developer_ssh.bats +++ b/test/shell/developer_ssh.bats @@ -152,7 +152,7 @@ install_valid_key() { @test "SFTP-only mode forces every authorized key through the bounded subsystem gate" { printf '285\n' >"$BLOOM_TEST_ROOT/tmp/deviceModel" install_valid_key - mkdir -p "$SDCARD/Roms" "$SDCARD/BIOS" "$SDCARD/Saves" "$SDCARD/Imgs" + mkdir -p "$SDCARD/Roms" "$SDCARD/BIOS" "$SDCARD/Saves" export BLOOM_SETTINGS_BIN="$MOCK_BIN/settings" cat >"$BLOOM_SETTINGS_BIN" <<'EOF' #!/bin/sh @@ -172,6 +172,7 @@ EOF ! cmp -s "$SDCARD/.bloom/authorized_keys" "$runtime_key" grep -F "$MOCK_BIN/mount -o bind $SDCARD/Roms $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/Roms" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o bind $SDCARD/BIOS $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/BIOS" "$MOCK_LOG" + [ -d "$SDCARD/Imgs" ] grep -F "$MOCK_BIN/mount -o bind $SDCARD/Imgs $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/Artwork" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o bind $MOCK_BIN/sftp-server $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/.bloom-bin/sftp-server" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o remount,bind,ro $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/.bloom-bin/sftp-server" "$MOCK_LOG" From b6eab760c911e34879b7089b9af9fcc155c48e94 Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 07:31:46 -0400 Subject: [PATCH 03/29] docs(network): record NET-02 device evidence --- docs/program/BloomOS_Work_Program_v3.json | 4 +- .../plus-net02-transfer-policy-20260826.json | 106 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 docs/validation/network/plus-net02-transfer-policy-20260826.json diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index a29178d3c..6e9edf493 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -778,7 +778,9 @@ "the complete pinned shell gate passed 634/634; focused policy and inventory validation passed 28/28; legacy inventory, development provenance, shell syntax, patch hunk counts, and CI scope gates passed", "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server", "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", - "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; RAM-only exact-hash validation is continuing without replacing installed files" + "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", + "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; installed files remained unchanged and the device returned to green health with a clean key-only listener audit", + "interactive OpenSSH cancellation and internal chroot scaffolding visibility remain explicit unresolved limitations, so NET-02 remains in_progress and is not publication-ready" ] }, { diff --git a/docs/validation/network/plus-net02-transfer-policy-20260826.json b/docs/validation/network/plus-net02-transfer-policy-20260826.json new file mode 100644 index 000000000..d66d5144a --- /dev/null +++ b/docs/validation/network/plus-net02-transfer-policy-20260826.json @@ -0,0 +1,106 @@ +{ + "schema": 1, + "package": "NET-02", + "date_utc": "2026-08-26", + "device": { + "model": "mini_plus", + "model_id": "354", + "installed_version": "v0.1.0-dev-b073b423" + }, + "source": { + "base_main": "9f339e90a773a288014bd5612fcb8bcc23790641", + "implementation_commit": "d43d372c01a7504900cc35dcd18b20760bee90d9", + "device_corrective_commit": "2415ead2099745b39152c81afa6ad43333cfdd75" + }, + "candidate": { + "mode": "ram_only_direct_sftp_server", + "durably_activated": false, + "files": { + "bloom-dev-ssh": { + "bytes": 13539, + "sha256": "17ee694e9e53ee7c1d3d6682ac3c44325a999b0fd81acc951d88023528980a02" + }, + "bloom-sftp-gate": { + "bytes": 1158, + "sha256": "895fb9984b8909156eb0d50fb885a875238ec5f2d63151a3c567a7eb7a1e13d6" + }, + "bloom-sftp-server": { + "bytes": 562156, + "sha256": "461de6c579320f2bff30a3414cb5a42242676be0adc784fc7ded605e614ac832" + }, + "bloom-transfer-policy": { + "bytes": 7032, + "sha256": "4d1fd6217873a6429c684c260c6520876984eea7c12a9170196642f4660b3b75" + }, + "transfer-policy.json": { + "bytes": 1103, + "sha256": "68563c3202ce397ddaa90dffa36668e6a23adf162c694374fe3f2e74eb7e2c26" + } + } + }, + "host_validation": { + "complete_shell": "634/634 passed", + "focused_corrective": "40/40 passed", + "arm_binary": "ELF 32-bit LSB ARM EABI5, statically linked, stripped", + "policy_schema_semantics": "passed", + "legacy_manifest_and_dependency_lock": "passed", + "development_provenance": "passed", + "patch_hunk_counts": "passed" + }, + "remote_validation": { + "preflight": { + "session": "STOPPED", + "update_phase": "known_good", + "aggregate_health": true, + "listener_audit_clean_after_inherited_telnet_cleanup": true, + "ssh": "on/running key-only", + "sftp": "off/stopped", + "samba": "off/stopped", + "authorized_key_count": 1 + }, + "policy": { + "candidate_validate": "passed", + "screenshots_write_authorized": true, + "saves_write_denied": true, + "private_device_roots_mounted": false + }, + "sftp": { + "successful_atomic_replace": { + "original_sha256": "db6172bc793afe98145dadef29e44ff7e02b5614f6b354aae95869a769798dde", + "replacement_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8" + }, + "abrupt_transport_loss": { + "prior_final_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8", + "final_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8", + "staging_residue_count": 0 + }, + "saves_write_result": "permission_denied", + "root_entries": [ + "Artwork", + "BIOS", + "Roms", + "Saves", + "Screenshots", + "Themes", + ".bloom-bin", + "dev" + ] + } + }, + "limitations": [ + "The upstream installed Warm deployment allowlist predates the new policy and replacement SFTP paths, so durable activation requires a signed Full migration.", + "No local Bloom release signing key was available, so this run did not replace installed runtime files or claim Warm rollback evidence.", + "An interactive OpenSSH cancel sends a normal CLOSE and published its acknowledged prefix; abrupt transport loss preserved the prior final. Cancel-safe commit semantics remain unresolved.", + "The isolated root exposed the non-device scaffolding entries .bloom-bin and dev in addition to the six policy roots; .bloom, .tmp_update, credentials, and unrelated user data were not mounted.", + "Remote SSH and SFTP evidence does not certify physical controls, LCD, audio, gameplay, save semantics, Mini V1-V4, or Mini Flip." + ], + "cleanup": { + "candidate_mounts_remaining": 0, + "ram_candidate_removed": true, + "sd_fixture_removed": true, + "task_created_artwork_root_removed_empty": true, + "installed_files_replaced": false, + "post_cleanup_health": true, + "post_cleanup_listener_audit_clean": true + } +} From 9fbaea2494cfce1abfd970bc6612cd80d12fabff Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 08:37:54 -0400 Subject: [PATCH 04/29] fix(network): make SFTP publication cancel-safe --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 8 +- static/build/.tmp_update/bin/bloom-sftp-gate | 15 +- .../.tmp_update/bin/bloom-transfer-policy | 8 +- test/shell/developer_ssh.bats | 6 +- test/shell/transfer_policy.bats | 4 + .../patches/openssh-sftp-atomic-replace.patch | 353 ++++++++++++++++-- .../03-network-features/03-ssh-sftp.md | 6 +- 8 files changed, 360 insertions(+), 42 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index e2b4bf2d1..fa783d98d 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314621295 +byte_count = 314622082 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 4b90668e8..84a6813d3 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -787,8 +787,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-sftp-gate", "file_count": 1, - "byte_count": 1158, - "tree_sha256": "5e055f61f1d2d7348dab611b481e77b1eef549ad6086185c1c049eee335cc0fc", + "byte_count": 1700, + "tree_sha256": "87505677c82771b4a49bbb222ac1f163d25b95782944f4d370ae920cdc017fca", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -852,8 +852,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-transfer-policy", "file_count": 1, - "byte_count": 7032, - "tree_sha256": "0418050e9f0daf8e3f165ba9f222ae9c661bbad242c3a81159a5657de724d16a", + "byte_count": 7277, + "tree_sha256": "c2030f81a3648e610495089dc41a7cf18fe1ba12e9436ff4cd55ff7d871e895a", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/static/build/.tmp_update/bin/bloom-sftp-gate b/static/build/.tmp_update/bin/bloom-sftp-gate index de87ba38f..edb79d611 100755 --- a/static/build/.tmp_update/bin/bloom-sftp-gate +++ b/static/build/.tmp_update/bin/bloom-sftp-gate @@ -17,10 +17,23 @@ case "$enabled" in true) ;; false) exit 1 ;; *) [ -f "$STATE_DIR/sftp.enabled" ] [ -x "$CHROOT" ] || exit 1 [ -x "$TRANSFER_POLICY" ] && "$TRANSFER_POLICY" validate || exit 1 BLOOM_TRANSFER_WRITE_ROOTS="$($TRANSFER_POLICY mounts | awk -F '\t' '$3 == "read_write" {if (value != "") value = value ":"; value = value $1} END {print value}')" +BLOOM_TRANSFER_PUBLIC_ROOTS="$($TRANSFER_POLICY mounts | awk -F '\t' '{if (value != "") value = value ":"; value = value $1} END {print value}')" +BLOOM_TRANSFER_LIMITS="$($TRANSFER_POLICY limits)" +tab="$(printf '\t')" case "$BLOOM_TRANSFER_WRITE_ROOTS" in Roms:BIOS:Artwork:Screenshots:Themes) ;; *) exit 1 ;; esac -export BLOOM_TRANSFER_WRITE_ROOTS +case "$BLOOM_TRANSFER_PUBLIC_ROOTS" in + Roms:BIOS:Artwork:Screenshots:Themes:Saves) ;; + *) exit 1 ;; +esac +case "$BLOOM_TRANSFER_LIMITS" in + "1024${tab}32${tab}255${tab}4294967295") ;; + *) exit 1 ;; +esac +BLOOM_TRANSFER_MAX_UPLOAD_BYTES=4294967295 +export BLOOM_TRANSFER_WRITE_ROOTS BLOOM_TRANSFER_PUBLIC_ROOTS \ + BLOOM_TRANSFER_MAX_UPLOAD_BYTES exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \ -P home-directory,users-groups-by-id diff --git a/static/build/.tmp_update/bin/bloom-transfer-policy b/static/build/.tmp_update/bin/bloom-transfer-policy index 511bd1395..329337ff8 100755 --- a/static/build/.tmp_update/bin/bloom-transfer-policy +++ b/static/build/.tmp_update/bin/bloom-transfer-policy @@ -97,6 +97,11 @@ command_mounts() { "$JQ" -er '.roots[] | [.virtual_path[1:], .device_path, .access] | @tsv' "$POLICY" } +command_limits() { + validate_policy || fail "policy is unavailable or invalid" + "$JQ" -er '.limits | [.max_path_bytes, .max_components, .max_component_bytes, .max_upload_bytes] | @tsv' "$POLICY" +} + command_authorize() { operation=$1 load_path "$2" || fail "path is outside the canonical transfer policy" @@ -150,8 +155,9 @@ command_status() { case "$#:${1:-}" in 1:validate) validate_policy || fail "policy is unavailable or invalid" ;; 1:mounts) command_mounts ;; + 1:limits) command_limits ;; 1:status) command_status ;; 3:authorize) command_authorize "$2" "$3" ;; 3:publish) command_publish "$2" "$3" ;; - *) fail "usage: status | validate | mounts | authorize | publish " ;; + *) fail "usage: status | validate | mounts | limits | authorize | publish " ;; esac diff --git a/test/shell/developer_ssh.bats b/test/shell/developer_ssh.bats index f1564424a..631cb6754 100755 --- a/test/shell/developer_ssh.bats +++ b/test/shell/developer_ssh.bats @@ -17,7 +17,11 @@ load 'support/test_helper' grep -F 'bloom_atomic_open' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F 'fsync(handles[handle].fd)' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F 'rename(handles[handle].atomic_name' /workspace/tools/patches/openssh-sftp-atomic-replace.patch - grep -F 'getenv("BLOOM_TRANSFER_WRITE_ROOTS")' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '"BLOOM_TRANSFER_WRITE_ROOTS"' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '"BLOOM_TRANSFER_PUBLIC_ROOTS"' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'getenv("BLOOM_TRANSFER_MAX_UPLOAD_BYTES")' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'handle_set_atomic_ready(handle)' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'SSH2_FX_PERMISSION_DENIED' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F '*scan < 0x20 || *scan == 0x7f' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F '++components > 32' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F 'exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \' \ diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats index 4c9384dcc..ff2f64ae6 100755 --- a/test/shell/transfer_policy.bats +++ b/test/shell/transfer_policy.bats @@ -35,6 +35,10 @@ teardown() { run "$POLICY_HELPER" mounts [ "$status" -eq 0 ] [ "$output" = $'Roms\tRoms\tread_write\nBIOS\tBIOS\tread_write\nArtwork\tImgs\tread_write\nScreenshots\tScreenshots\tread_write\nThemes\tThemes\tread_write\nSaves\tSaves\tread_only' ] + + run "$POLICY_HELPER" limits + [ "$status" -eq 0 ] + [ "$output" = $'1024\t32\t255\t4294967295' ] } @test "authorization rejects private roots, traversal, symlinks, and writes to Saves" { diff --git a/tools/patches/openssh-sftp-atomic-replace.patch b/tools/patches/openssh-sftp-atomic-replace.patch index aa695605f..6b921d881 100644 --- a/tools/patches/openssh-sftp-atomic-replace.patch +++ b/tools/patches/openssh-sftp-atomic-replace.patch @@ -1,23 +1,26 @@ diff --git a/sftp-server.c b/sftp-server.c +index a4abb9f..5c25948 --- a/sftp-server.c +++ b/sftp-server.c -@@ -314,6 +314,7 @@ struct Handle { +@@ -313,6 +313,8 @@ struct Handle { int fd; int flags; char *name; + char *atomic_name; ++ int atomic_ready; u_int64_t bytes_read, bytes_write; int next_unused; }; -@@ -359,6 +360,7 @@ handle_new(int use, const char *name, int fd, int flags, DIR *dirp) +@@ -355,6 +357,8 @@ handle_new(int use, const char *name, int fd, int flags, DIR *dirp) handles[i].fd = fd; handles[i].flags = flags; handles[i].name = xstrdup(name); + handles[i].atomic_name = NULL; ++ handles[i].atomic_ready = 0; handles[i].bytes_read = handles[i].bytes_write = 0; return i; -@@ -443,6 +445,15 @@ handle_to_flags(int handle) +@@ -424,6 +428,25 @@ handle_to_flags(int handle) return 0; } @@ -29,11 +32,24 @@ diff --git a/sftp-server.c b/sftp-server.c + handles[handle].atomic_name = atomic_name; + return 0; +} ++ ++static int ++handle_set_atomic_ready(int handle) ++{ ++ if (!handle_is_ok(handle, HANDLE_FILE) || ++ handles[handle].atomic_name == NULL) ++ return -1; ++ handles[handle].atomic_ready = 1; ++ return 0; ++} + static void handle_update_read(int handle, ssize_t bytes) { -@@ -477,7 +488,28 @@ handle_close(int handle) +@@ -457,10 +480,36 @@ handle_bytes_write(int handle) + static int + handle_close(int handle) + { - int ret = -1; + int ret = -1, saved_errno = 0; @@ -41,6 +57,11 @@ diff --git a/sftp-server.c b/sftp-server.c - ret = close(handles[handle].fd); + if (handles[handle].atomic_name == NULL) { + ret = close(handles[handle].fd); ++ } else if (!handles[handle].atomic_ready) { ++ saved_errno = ECANCELED; ++ ret = -1; ++ close(handles[handle].fd); ++ unlink(handles[handle].atomic_name); + } else if (fsync(handles[handle].fd) == -1) { + saved_errno = errno; + ret = -1; @@ -64,8 +85,7 @@ diff --git a/sftp-server.c b/sftp-server.c free(handles[handle].name); handle_unused(handle); } else if (handle_is_ok(handle, HANDLE_DIR)) { -@@ -511,7 +537,12 @@ handle_log_exit(void) - +@@ -497,6 +546,11 @@ handle_log_exit(void) for (i = 0; i < num_handles; i++) if (handles[i].use != HANDLE_UNUSED) handle_log_close(i, "forced"); @@ -77,32 +97,28 @@ diff --git a/sftp-server.c b/sftp-server.c } static int -@@ -738,6 +770,158 @@ send_statvfs(u_int32_t id, struct statvfs *st) +@@ -738,12 +792,218 @@ process_init(void) sshbuf_free(msg); } +static int -+bloom_safe_write_path(const char *name) ++bloom_allowed_root(const char *name, const char *environment, int allow_root) +{ + const char *allowed, *end, *root_end; -+ const unsigned char *scan; -+ char *copy, *cursor, *slash; -+ struct stat st; -+ u_int components = 0; ++ const char *cursor; + -+ if (name == NULL || *name == '\0' || strlen(name) > 1024 || -+ strstr(name, "//") != NULL) ++ if (name == NULL || *name == '\0' || ++ (allowed = getenv(environment)) == NULL) + return 0; -+ for (scan = (const unsigned char *)name; *scan != '\0'; scan++) { -+ if (*scan < 0x20 || *scan == 0x7f || *scan == '\\') -+ return 0; -+ } -+ cursor = (char *)name; ++ cursor = name; + while (*cursor == '/') + cursor++; ++ if ((*cursor == '\0' || strcmp(cursor, ".") == 0) && allow_root) ++ return 1; + root_end = strchr(cursor, '/'); -+ if (root_end == NULL || root_end == cursor || -+ (allowed = getenv("BLOOM_TRANSFER_WRITE_ROOTS")) == NULL) ++ if (root_end == NULL) ++ root_end = cursor + strlen(cursor); ++ if (root_end == cursor) + return 0; + while (*allowed != '\0') { + end = strchr(allowed, ':'); @@ -110,11 +126,37 @@ diff --git a/sftp-server.c b/sftp-server.c + end = allowed + strlen(allowed); + if ((size_t)(end - allowed) == (size_t)(root_end - cursor) && + memcmp(allowed, cursor, end - allowed) == 0) -+ break; ++ return 1; + allowed = *end == ':' ? end + 1 : end; + } -+ if (*allowed == '\0') ++ return 0; ++} ++ ++static int ++bloom_safe_public_path(const char *name, int allow_root) ++{ ++ const char *path_cursor; ++ const unsigned char *scan; ++ char *copy, *cursor, *slash; ++ struct stat st; ++ u_int components = 0; ++ ++ if (name == NULL || *name == '\0' || strlen(name) > 1024 || ++ strstr(name, "//") != NULL) + return 0; ++ if (!bloom_allowed_root(name, "BLOOM_TRANSFER_PUBLIC_ROOTS", allow_root)) ++ return 0; ++ if (strstr(name, ".bloom-sftp-") != NULL) ++ return 0; ++ for (scan = (const unsigned char *)name; *scan != '\0'; scan++) { ++ if (*scan < 0x20 || *scan == 0x7f || *scan == '\\') ++ return 0; ++ } ++ path_cursor = name; ++ while (*path_cursor == '/') ++ path_cursor++; ++ if ((*path_cursor == '\0' || strcmp(path_cursor, ".") == 0) && allow_root) ++ return 1; + copy = xstrdup(name); + cursor = copy; + while (*cursor == '/') @@ -126,6 +168,10 @@ diff --git a/sftp-server.c b/sftp-server.c + free(copy); + return 0; + } ++ if (strncmp(cursor, ".bloom", 6) == 0) { ++ free(copy); ++ return 0; ++ } + if (++components > 32) { + free(copy); + return 0; @@ -148,6 +194,10 @@ diff --git a/sftp-server.c b/sftp-server.c + free(copy); + return 0; + } ++ if (strncmp(cursor, ".bloom", 6) == 0) { ++ free(copy); ++ return 0; ++ } + if (lstat(name, &st) == 0 && S_ISLNK(st.st_mode)) { + free(copy); + return 0; @@ -157,6 +207,29 @@ diff --git a/sftp-server.c b/sftp-server.c +} + +static int ++bloom_safe_write_path(const char *name) ++{ ++ return bloom_allowed_root(name, "BLOOM_TRANSFER_WRITE_ROOTS", 0) && ++ bloom_safe_public_path(name, 0); ++} ++ ++static int ++bloom_write_within_limit(u_int64_t offset, size_t length) ++{ ++ const char *value = getenv("BLOOM_TRANSFER_MAX_UPLOAD_BYTES"); ++ char *end = NULL; ++ unsigned long long maximum; ++ ++ if (value == NULL || *value == '\0') ++ return 0; ++ errno = 0; ++ maximum = strtoull(value, &end, 10); ++ if (errno != 0 || end == value || *end != '\0') ++ return 0; ++ return offset <= maximum && length <= maximum - offset; ++} ++ ++static int +bloom_copy_existing(int source, int destination) +{ + char buffer[32768]; @@ -236,7 +309,6 @@ diff --git a/sftp-server.c b/sftp-server.c static void process_open(u_int32_t id) { -@@ -745,6 +862,7 @@ process_open(u_int32_t id) u_int32_t pflags; Attrib a; char *name; @@ -244,13 +316,18 @@ diff --git a/sftp-server.c b/sftp-server.c int r, handle, fd, flags, mode, status = SSH2_FX_FAILURE; if ((r = sshbuf_get_cstring(iqueue, &name, NULL)) != 0 || -@@ -762,14 +880,23 @@ process_open(u_int32_t id) +@@ -762,14 +1022,28 @@ process_open(u_int32_t id) verbose("Refusing open request in read-only mode"); status = SSH2_FX_PERMISSION_DENIED; } else { - fd = open(name, flags, mode); -+ if ((flags & O_ACCMODE) == O_RDONLY) -+ fd = open(name, flags, mode); ++ if ((flags & O_ACCMODE) == O_RDONLY) { ++ if (!bloom_safe_public_path(name, 0)) { ++ errno = EACCES; ++ fd = -1; ++ } else ++ fd = open(name, flags, mode); ++ } + else + fd = bloom_atomic_open(name, flags, mode, &atomic_name); if (fd == -1) { @@ -269,17 +346,229 @@ diff --git a/sftp-server.c b/sftp-server.c send_handle(id, handle); status = SSH2_FX_OK; } +@@ -869,7 +1143,9 @@ process_write(u_int32_t id) + + if (fd < 0) + status = SSH2_FX_FAILURE; +- else { ++ else if (!bloom_write_within_limit(off, len)) { ++ status = SSH2_FX_FAILURE; ++ } else { + if (!(handle_to_flags(handle) & O_APPEND) && + lseek(fd, off, SEEK_SET) == -1) { + status = errno_to_portable(errno); +@@ -908,7 +1184,11 @@ process_do_stat(u_int32_t id, int do_lstat) + + debug3("request %u: %sstat", id, do_lstat ? "l" : ""); + verbose("%sstat name \"%s\"", do_lstat ? "l" : "", name); +- r = do_lstat ? lstat(name, &st) : stat(name, &st); ++ if (!bloom_safe_public_path(name, 1)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = do_lstat ? lstat(name, &st) : stat(name, &st); + if (r == -1) { + status = errno_to_portable(errno); + } else { +@@ -995,6 +1275,11 @@ process_setstat(u_int32_t id) + fatal_fr(r, "parse"); + + debug("request %u: setstat name \"%s\"", id, name); ++ if (!bloom_safe_write_path(name)) { ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ free(name); ++ return; ++ } + if (a.flags & SSH2_FILEXFER_ATTR_SIZE) { + logit("set \"%s\" size %llu", + name, (unsigned long long)a.size); +@@ -1107,7 +1392,11 @@ process_opendir(u_int32_t id) + + debug3("request %u: opendir", id); + logit("opendir \"%s\"", path); +- dirp = opendir(path); ++ if (!bloom_safe_public_path(path, 1)) { ++ errno = EACCES; ++ dirp = NULL; ++ } else ++ dirp = opendir(path); + if (dirp == NULL) { + status = errno_to_portable(errno); + } else { +@@ -1150,6 +1439,12 @@ process_readdir(u_int32_t id) + + stats = xcalloc(nstats, sizeof(Stat)); + while ((dp = readdir(dirp)) != NULL) { ++ if ((strcmp(path, "/") == 0 || strcmp(path, ".") == 0) && ++ (strcmp(dp->d_name, ".bloom-bin") == 0 || ++ strcmp(dp->d_name, "dev") == 0)) ++ continue; ++ if (strstr(dp->d_name, ".bloom-sftp-") != NULL) ++ continue; + if (count >= nstats) { + nstats *= 2; + stats = xreallocarray(stats, nstats, sizeof(Stat)); +@@ -1193,7 +1488,11 @@ process_remove(u_int32_t id) + + debug3("request %u: remove", id); + logit("remove name \"%s\"", name); +- r = unlink(name); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = unlink(name); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1214,7 +1513,11 @@ process_mkdir(u_int32_t id) + a.perm & 07777 : 0777; + debug3("request %u: mkdir", id); + logit("mkdir name \"%s\" mode 0%o", name, mode); +- r = mkdir(name, mode); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = mkdir(name, mode); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1231,7 +1534,11 @@ process_rmdir(u_int32_t id) + + debug3("request %u: rmdir", id); + logit("rmdir name \"%s\"", name); +- r = rmdir(name); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = rmdir(name); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1253,7 +1560,9 @@ process_realpath(u_int32_t id) + } + debug3("request %u: realpath", id); + verbose("realpath \"%s\"", path); +- if (sftp_realpath(path, resolvedname) == NULL) { ++ if (!bloom_safe_public_path(path, 1)) { ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ } else if (sftp_realpath(path, resolvedname) == NULL) { + send_status(id, errno_to_portable(errno)); + } else { + Stat s; +@@ -1278,7 +1587,10 @@ process_rename(u_int32_t id) + debug3("request %u: rename", id); + logit("rename old \"%s\" new \"%s\"", oldpath, newpath); + status = SSH2_FX_FAILURE; +- if (lstat(oldpath, &sb) == -1) ++ if (!bloom_safe_write_path(oldpath) || ++ !bloom_safe_write_path(newpath)) ++ status = SSH2_FX_PERMISSION_DENIED; ++ else if (lstat(oldpath, &sb) == -1) + status = errno_to_portable(errno); + else if (S_ISREG(sb.st_mode)) { + /* Race-free rename of regular files */ +@@ -1336,7 +1648,9 @@ process_readlink(u_int32_t id) + + debug3("request %u: readlink", id); + verbose("readlink \"%s\"", path); +- if ((len = readlink(path, buf, sizeof(buf) - 1)) == -1) ++ if (!bloom_safe_public_path(path, 0)) ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ else if ((len = readlink(path, buf, sizeof(buf) - 1)) == -1) + send_status(id, errno_to_portable(errno)); + else { + Stat s; +@@ -1361,9 +1675,8 @@ process_symlink(u_int32_t id) + + debug3("request %u: symlink", id); + logit("symlink old \"%s\" new \"%s\"", oldpath, newpath); +- /* this will fail if 'newpath' exists */ +- r = symlink(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ /* Symlinks are intentionally outside the bounded transfer policy. */ ++ status = SSH2_FX_PERMISSION_DENIED; + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1381,8 +1694,13 @@ process_extended_posix_rename(u_int32_t id) + + debug3("request %u: posix-rename", id); + logit("posix-rename old \"%s\" new \"%s\"", oldpath, newpath); +- r = rename(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ if (!bloom_safe_write_path(oldpath) || ++ !bloom_safe_write_path(newpath)) ++ status = SSH2_FX_PERMISSION_DENIED; ++ else { ++ r = rename(oldpath, newpath); ++ status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ } + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1400,7 +1718,9 @@ process_extended_statvfs(u_int32_t id) + debug3("request %u: statvfs", id); + logit("statvfs \"%s\"", path); + +- if (statvfs(path, &st) != 0) ++ if (!bloom_safe_public_path(path, 1)) ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ else if (statvfs(path, &st) != 0) + send_status(id, errno_to_portable(errno)); + else + send_statvfs(id, &st); +@@ -1439,8 +1759,8 @@ process_extended_hardlink(u_int32_t id) + + debug3("request %u: hardlink", id); + logit("hardlink old \"%s\" new \"%s\"", oldpath, newpath); +- r = link(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ /* Hardlinks are intentionally outside the bounded transfer policy. */ ++ status = SSH2_FX_PERMISSION_DENIED; + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1459,7 +1779,12 @@ process_extended_fsync(u_int32_t id) + status = SSH2_FX_NO_SUCH_FILE; + else if (handle_is_ok(handle, HANDLE_FILE)) { + r = fsync(fd); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ if (r == -1) ++ status = errno_to_portable(errno); ++ else if (handle_set_atomic_ready(handle) == -1) ++ status = SSH2_FX_OP_UNSUPPORTED; ++ else ++ status = SSH2_FX_OK; + } + send_status(id, status); + } +@@ -1476,6 +1801,10 @@ process_extended_lsetstat(u_int32_t id) + fatal_fr(r, "parse"); + + debug("request %u: lsetstat name \"%s\"", id, name); ++ if (!bloom_safe_write_path(name)) { ++ status = SSH2_FX_PERMISSION_DENIED; ++ goto out; ++ } + if (a.flags & SSH2_FILEXFER_ATTR_SIZE) { + /* nonsensical for links */ + status = SSH2_FX_BAD_MESSAGE; diff --git a/sftp-server.8 b/sftp-server.8 +index 5311bf9..becff58 --- a/sftp-server.8 +++ b/sftp-server.8 -@@ -46,6 +46,10 @@ is a program that speaks the server side of SFTP protocol to standard - input and sends replies to standard output. +@@ -47,6 +47,11 @@ is a program that speaks the server side of SFTP protocol + to stdout and expects client requests from stdin. .Nm is not intended to be called directly, but from +.Pp -+The BloomOS build stages writable file handles beside their destination, -+synchronizes completed bytes, and atomically renames them into place on close. -+Interrupted writes do not truncate the previous final file. ++The BloomOS build stages writable file handles beside their destination. ++An explicit fsync extension request marks the staged bytes complete, and close ++then atomically renames them into place. Close without fsync and interrupted ++writes discard staging without truncating the previous final file. .Xr sshd 8 using the .Cm Subsystem diff --git a/website/docs/07-apps/03-network-features/03-ssh-sftp.md b/website/docs/07-apps/03-network-features/03-ssh-sftp.md index a6bdd21b3..9cc575792 100644 --- a/website/docs/07-apps/03-network-features/03-ssh-sftp.md +++ b/website/docs/07-apps/03-network-features/03-ssh-sftp.md @@ -14,8 +14,10 @@ SFTP provides an encrypted, public-key-only file transfer method. BloomOS exposes only `Roms`, `BIOS`, `Artwork`, `Screenshots`, `Themes`, and read-only `Saves`. System, credential, update, and other private directories are not visible through SFTP. Enable it separately from shell access in Bloom -Settings. Upload clients should finish transfers with a rename; Bloom's shared -transfer policy provides atomic publication for Bloom-managed transfer tools. +Settings. Uploads are staged beside their destination and publish only after +the client requests the SFTP `fsync` extension. With OpenSSH, use durable mode +(`put -f local-file /Roms/...`) so a completed upload commits atomically; +canceling or losing the transport preserves the previous final file. ## Features From aff110ba73eca83dfd52d18afd26b316caa9e7fd Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 08:38:45 -0400 Subject: [PATCH 05/29] docs(network): record cancel-safe NET-02 gates --- docs/program/BloomOS_Work_Program_v3.json | 3 +- .../plus-net02-transfer-policy-20260826.json | 30 +++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 6e9edf493..f3e44e7d5 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -780,7 +780,8 @@ "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; installed files remained unchanged and the device returned to green health with a clean key-only listener audit", - "interactive OpenSSH cancellation and internal chroot scaffolding visibility remain explicit unresolved limitations, so NET-02 remains in_progress and is not publication-ready" + "corrective commit 9fbaea2494cfce1abfd970bc6612cd80d12fabff requires explicit SFTP fsync before atomic publication, discards ordinary CLOSE/cancel staging, filters internal chroot scaffolding, fail-closes direct private paths and unbounded mutations, and derives the upload-size gate from the canonical policy", + "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae; signed durable Mini Plus validation remains pending" ] }, { diff --git a/docs/validation/network/plus-net02-transfer-policy-20260826.json b/docs/validation/network/plus-net02-transfer-policy-20260826.json index d66d5144a..fd9223b86 100644 --- a/docs/validation/network/plus-net02-transfer-policy-20260826.json +++ b/docs/validation/network/plus-net02-transfer-policy-20260826.json @@ -10,7 +10,8 @@ "source": { "base_main": "9f339e90a773a288014bd5612fcb8bcc23790641", "implementation_commit": "d43d372c01a7504900cc35dcd18b20760bee90d9", - "device_corrective_commit": "2415ead2099745b39152c81afa6ad43333cfdd75" + "device_corrective_commit": "2415ead2099745b39152c81afa6ad43333cfdd75", + "cancel_safe_corrective_commit": "9fbaea2494cfce1abfd970bc6612cd80d12fabff" }, "candidate": { "mode": "ram_only_direct_sftp_server", @@ -36,12 +37,31 @@ "bytes": 1103, "sha256": "68563c3202ce397ddaa90dffa36668e6a23adf162c694374fe3f2e74eb7e2c26" } + }, + "corrective_files": { + "bloom-sftp-gate": { + "bytes": 1700, + "sha256": "0e3b494c8042c08dc8a70ea8b8dbc80cb563596bfb9265119bcf1b23f8648d9b" + }, + "bloom-sftp-server": { + "bytes": 562156, + "sha256": "ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae" + }, + "bloom-transfer-policy": { + "bytes": 7277, + "sha256": "b35ef875efbdb8d90fde544dd88f89dc2172a786fa87d85f32c55056072c8868" + }, + "transfer-policy.json": { + "bytes": 1103, + "sha256": "68563c3202ce397ddaa90dffa36668e6a23adf162c694374fe3f2e74eb7e2c26" + } } }, "host_validation": { - "complete_shell": "634/634 passed", - "focused_corrective": "40/40 passed", + "complete_shell": "634/634 passed from a read-only repository mount after the cancel-safe correction", + "focused_corrective": "29/29 transfer, SSH, and network service tests passed; dependency and read-only harness checks passed 13/13", "arm_binary": "ELF 32-bit LSB ARM EABI5, statically linked, stripped", + "native_sftp_protocol_probe": "ordinary CLOSE failed and preserved the prior final; explicit fsync committed atomically; Saves and private scaffolding were denied; zero staging residue remained", "policy_schema_semantics": "passed", "legacy_manifest_and_dependency_lock": "passed", "development_provenance": "passed", @@ -90,8 +110,8 @@ "limitations": [ "The upstream installed Warm deployment allowlist predates the new policy and replacement SFTP paths, so durable activation requires a signed Full migration.", "No local Bloom release signing key was available, so this run did not replace installed runtime files or claim Warm rollback evidence.", - "An interactive OpenSSH cancel sends a normal CLOSE and published its acknowledged prefix; abrupt transport loss preserved the prior final. Cancel-safe commit semantics remain unresolved.", - "The isolated root exposed the non-device scaffolding entries .bloom-bin and dev in addition to the six policy roots; .bloom, .tmp_update, credentials, and unrelated user data were not mounted.", + "The cancel-safe correction requires an explicit SFTP fsync extension before CLOSE can publish; clients without durable-upload support receive a close failure and preserve the prior final.", + "The corrective host probe filters .bloom-bin, dev, and staging names and denies their direct access; signed durable device validation of that correction remains pending.", "Remote SSH and SFTP evidence does not certify physical controls, LCD, audio, gameplay, save semantics, Mini V1-V4, or Mini Flip." ], "cleanup": { From ef9dbec54f0faffc0efd2eaf6cac1009957bb307 Mon Sep 17 00:00:00 2001 From: boburning Date: Wed, 26 Aug 2026 20:01:45 -0400 Subject: [PATCH 06/29] fix(network): restore SSH after delayed boot association --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 8 +- docs/program/BloomOS_Work_Program_v3.json | 8 +- .../plus-net02-signed-recovery-20260826.json | 75 +++++++++++++++++++ static/build/.tmp_update/bin/bloom-network | 30 ++++++-- .../.tmp_update/bin/bloom-network-services | 23 ++++++ test/shell/network_services.bats | 51 +++++++++++++ test/shell/network_status.bats | 21 ++++++ 8 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 docs/validation/network/plus-net02-signed-recovery-20260826.json diff --git a/build/dependencies.lock b/build/dependencies.lock index fa783d98d..ac0912ce0 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314622082 +byte_count = 314623899 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 84a6813d3..f2e7522a7 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 5444, - "tree_sha256": "13919974b5733721c9a68e847086d101aa4968850e237c8e8bd1cdeca0161bca", + "byte_count": 6300, + "tree_sha256": "aa8a2c073cfa612793930188a60e81f92e7102451aaac7535a4b04836553b62e", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -618,8 +618,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-services", "file_count": 1, - "byte_count": 11212, - "tree_sha256": "eaa9064c27affd9517c3ab015d74ca6575292749f6f58f8aa08bfbdfff7374e1", + "byte_count": 12173, + "tree_sha256": "2ad6599b8bb8c7d4249f1d63822875878f862aeabe98775af9f03fd057a04788", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index f3e44e7d5..c8912f3b9 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -768,7 +768,9 @@ "dependencies": [ "NET-01" ], - "github_prs": [], + "github_prs": [ + 483 + ], "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, @@ -781,7 +783,9 @@ "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; installed files remained unchanged and the device returned to green health with a clean key-only listener audit", "corrective commit 9fbaea2494cfce1abfd970bc6612cd80d12fabff requires explicit SFTP fsync before atomic publication, discards ordinary CLOSE/cancel staging, filters internal chroot scaffolding, fail-closes direct private paths and unbounded mutations, and derives the upload-size gate from the canonical policy", - "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae; signed durable Mini Plus validation remains pending" + "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae", + "signed Full candidate aff110ba from workflow 32969798436 booted once but did not restore the key-only SSH listener before the update health deadline; the update system retained b073b423 and automatically rolled back, after which aggregate health, listener audit, and the single authorized key were confirmed; source and boot-log reconciliation identified an association-order race and the same PR now gives enabled Wi-Fi a bounded boot-only wait before remote-service reconciliation", + "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the corrected signed durable Mini Plus gate remains pending" ] }, { diff --git a/docs/validation/network/plus-net02-signed-recovery-20260826.json b/docs/validation/network/plus-net02-signed-recovery-20260826.json new file mode 100644 index 000000000..e117d4374 --- /dev/null +++ b/docs/validation/network/plus-net02-signed-recovery-20260826.json @@ -0,0 +1,75 @@ +{ + "schema": 1, + "package": "NET-02", + "date_utc": "2026-08-26", + "device": { + "model": "mini_plus", + "model_id": "354", + "transport": "key-only LAN SSH" + }, + "candidate": { + "commit": "aff110ba73eca83dfd52d18afd26b316caa9e7fd", + "version": "0.1.0-dev-aff110ba", + "workflow_run": 32969798436, + "deployment_tier": "full_signed", + "archive": { + "bytes": 203243467, + "sha256": "21c656282568150d3279226085726e812ec33ae0eeb2e85367b3c38346aa8ed4" + }, + "manifest_sha256": "473329f8d8972f2080ea0f97131c167b2333f656409b455a1c203b60559be9b3", + "signature_sha256": "c892254e7cc52399c7ae26ae728333c636d5d075a4b16e54e1d7a448d7413390" + }, + "result": { + "status": "rejected_and_recovered", + "candidate_boot_attempts": 1, + "observed_failure": "the device associated with Wi-Fi but did not restore the desired key-only SSH listener before the update health deadline", + "recovery_state": { + "phase": "known_good", + "version": "0.1.0-dev-b073b423", + "recovered_from": "0.1.0-dev-aff110ba", + "snapshot_id": "19700101T000117Z-pre-rollback-880" + } + }, + "diagnosis": { + "classification": "boot ordering race", + "evidence": [ + "bloom-wifi starts association and DHCP asynchronously", + "the boot reconciliation path could evaluate carrier before association completed and stop the desired SSH service", + "the collected boot log later showed successful wlan0 association", + "the known-good predecessor predates the network-service-v2 boot ordering" + ], + "corrective_scope": "wait for carrier only during enabled-Wi-Fi boot reconciliation, bounded to 20 one-second checks by default; all ordinary status and disabled or unsupported paths remain non-waiting" + }, + "post_recovery": { + "aggregate_health": true, + "listener_audit_clean": true, + "ssh": "on/running key-only", + "sftp": "off/stopped", + "samba": "off/stopped", + "authorized_key_count": 1, + "authorized_key_suffix": "b9002791", + "shutdown_log": { + "mode": "reboot", + "remount_ro_final": 0, + "umount_recursive": 0 + } + }, + "physical_observation": { + "reported_by": "maintainer", + "context": "update and recovery loop", + "power_button": "shutdown required holding the power button continuously until power-off instead of releasing after the ordinary multi-second hold", + "classification": "not yet reproduced in the idle Bloom Shell; update-loop power ownership remains a limitation requiring a focused physical retest" + }, + "limitations": [ + "The failed candidate could not be inspected over SSH while its listener was unavailable; the boot-race diagnosis joins retained logs with the reviewed source ordering and is an inference.", + "Recovery included maintainer physical restart and power-button interaction, so this run is not evidence of unattended recovery.", + "No signed durable SFTP behavior is claimed from the rejected candidate.", + "Mini Plus remote and maintainer-reported physical evidence does not certify Mini V1-V4 or Mini Flip." + ], + "retained_evidence": { + "signed_artifacts": "retained outside the repository in the bounded D: task workspace", + "diagnostics": "privacy-bounded device and runtime logs retained outside the repository", + "rom_bios_or_save_content_collected": false, + "credentials_or_private_keys_collected": false + } +} diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index 981e01086..d32fef41f 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -9,6 +9,8 @@ BACKEND=${BLOOM_NETWORK_BACKEND_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} COMPAT=${BLOOM_NETWORK_COMPAT_BIN:-/mnt/SDCARD/.tmp_update/script/network/update_networking.sh} SETTINGS=${BLOOM_SETTINGS_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-settings} SERVICES=${BLOOM_NETWORK_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} +SERVICES_WAIT_ATTEMPTS=${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-20} +SERVICES_WAIT_INTERVAL=${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-1} result() { printf '{"schema":1,"service":"bloom-network","available":%s,"wifi_capable":%s,"wifi_enabled":%s,"associated":%s,"state":"%s"}\n' \ @@ -31,7 +33,19 @@ wifi_capability() { } reconcile_services() { - [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1 + wait_for_wifi=$1 + [ ! -x "$SERVICES" ] || + BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI="$wait_for_wifi" \ + BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS="$SERVICES_WAIT_ATTEMPTS" \ + BLOOM_NETWORK_SERVICES_WAIT_INTERVAL="$SERVICES_WAIT_INTERVAL" \ + "$SERVICES" reconcile >/dev/null 2>&1 +} + +wifi_preference() { + [ -x "$JSONVAL" ] || return 1 + value=$(LD_LIBRARY_PATH="/lib:/config/lib:/customer/lib:$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$JSONVAL" wifi 2>/dev/null) || return 1 + case "$value" in 0|1) printf '%s' "$value" ;; *) return 1 ;; esac } status() { @@ -41,8 +55,7 @@ status() { esac [ -x "$JSONVAL" ] || { result false true false false wifi_state_unavailable; return 1; } - enabled=$(LD_LIBRARY_PATH="/lib:/config/lib:/customer/lib:$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - "$JSONVAL" wifi 2>/dev/null) || { result false true false false wifi_state_unavailable; return 1; } + enabled=$(wifi_preference) || { result false true false false wifi_state_unavailable; return 1; } case "$enabled" in 0) result true true false false wifi_disabled; return 0 ;; 1) ;; @@ -64,7 +77,7 @@ status() { reconcile() { wifi=$(wifi_capability) || { operation_result false platform_unavailable; return 1; } if [ "$wifi" = false ]; then - reconcile_services || { operation_result false remote_services_failed; return 1; } + reconcile_services false || { operation_result false remote_services_failed; return 1; } operation_result false no_network_hardware return 0 fi @@ -74,14 +87,16 @@ reconcile() { else status=$? if [ "$status" -eq 3 ] && [ -x "$COMPAT" ] && "$COMPAT" check >/dev/null 2>&1; then - reconcile_services || { operation_result false remote_services_failed; return 1; } + reconcile_services false || { operation_result false remote_services_failed; return 1; } operation_result true compatibility_applied return 0 fi operation_result false backend_failed return 1 fi - reconcile_services || { operation_result false remote_services_failed; return 1; } + preference=$(wifi_preference) || { operation_result false wifi_state_unavailable; return 1; } + if [ "$preference" = 1 ]; then wait_for_wifi=true; else wait_for_wifi=false; fi + reconcile_services "$wait_for_wifi" || { operation_result false remote_services_failed; return 1; } operation_result true applied } @@ -114,7 +129,8 @@ set_preference() { return 1 fi fi - reconcile_services || { + if [ "$operation" = enable ]; then wait_for_wifi=true; else wait_for_wifi=false; fi + reconcile_services "$wait_for_wifi" || { preference_result "$operation" true false remote_services_failed return 1 } diff --git a/static/build/.tmp_update/bin/bloom-network-services b/static/build/.tmp_update/bin/bloom-network-services index b1a4f0279..0fb9e1d46 100755 --- a/static/build/.tmp_update/bin/bloom-network-services +++ b/static/build/.tmp_update/bin/bloom-network-services @@ -13,6 +13,10 @@ SAMBA_HELPER=${BLOOM_SAMBA_SERVICE_BIN:-$SYS_DIR/bin/bloom-samba-service} PLATFORM=${BLOOM_PLATFORM_BIN:-$SYS_DIR/bin/bloom-platform} SETTINGS=${BLOOM_SETTINGS_BIN:-$SYS_DIR/bin/bloom-settings} LISTENER_AUDIT=${BLOOM_LISTENER_AUDIT_BIN:-$SYS_DIR/bin/bloom-listener-audit} +SLEEP=${BLOOM_NETWORK_SERVICES_SLEEP_BIN:-sleep} +WAIT_FOR_WIFI=${BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI:-false} +WAIT_ATTEMPTS=${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-20} +WAIT_INTERVAL=${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-1} boolean() { "$@" >/dev/null 2>&1 && printf true || printf false; } legacy_enabled() { [ -f "$STATE_DIR/$1.enabled" ]; } @@ -40,6 +44,21 @@ wifi_ready() { [ "$(cat "$interface/operstate" 2>/dev/null || true)" = up ] && [ "$(cat "$interface/carrier" 2>/dev/null || true)" = 1 ] } +wait_wifi_ready() { + case "$WAIT_FOR_WIFI" in true) ;; *) return 1 ;; esac + case "$WAIT_ATTEMPTS" in ''|*[!0-9]*) return 1 ;; esac + case "$WAIT_INTERVAL" in ''|*[!0-9]*) return 1 ;; esac + [ "$WAIT_ATTEMPTS" -ge 1 ] && [ "$WAIT_ATTEMPTS" -le 60 ] || return 1 + [ "$WAIT_INTERVAL" -ge 1 ] && [ "$WAIT_INTERVAL" -le 5 ] || return 1 + attempt=1 + while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do + wifi_ready && return 0 + [ "$attempt" -lt "$WAIT_ATTEMPTS" ] || break + "$SLEEP" "$WAIT_INTERVAL" || return 1 + attempt=$((attempt + 1)) + done + return 1 +} settings_get() { [ -x "$SETTINGS" ] || return 1 @@ -142,6 +161,10 @@ status() { reconcile_loaded() { ready=$(boolean wifi_ready) + if [ "$ready" = false ] && + { desired ssh || desired sftp || desired samba; }; then + ready=$(boolean wait_wifi_ready) + fi applied=true if [ "$ready" = true ]; then if { desired ssh || desired sftp; } && available_key && [ -x "$SSH_HELPER" ]; then diff --git a/test/shell/network_services.bats b/test/shell/network_services.bats index ff6dc678d..6d4e1c18a 100644 --- a/test/shell/network_services.bats +++ b/test/shell/network_services.bats @@ -208,6 +208,57 @@ EOF grep -Fx 'reconcile' "$MOCK_LOG" } +@test "boot-scoped reconcile waits for delayed association before restoring SSH" { + sh "$SERVICE" request ssh enable >/dev/null + cat >"$MOCK/ssh-helper" <<'SH' +#!/bin/sh +printf 'ssh:%s\n' "$*" >>"$MOCK_LOG" +SH + chmod +x "$MOCK/ssh-helper" + : >"$MOCK_LOG" + printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" + printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" + sleep_log="$BATS_TEST_TMPDIR/wifi-wait.log" + cat >"$MOCK/sleep" <>"$sleep_log" +printf 'up\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" +printf '1\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" +EOF + chmod +x "$MOCK/sleep" + + run env BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI=true \ + BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS=3 \ + BLOOM_NETWORK_SERVICES_WAIT_INTERVAL=1 \ + BLOOM_NETWORK_SERVICES_SLEEP_BIN="$MOCK/sleep" \ + sh "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"wifi_ready":true'* ]] + [ "$(cat "$sleep_log")" = 1 ] + grep -Fx 'ssh:start' "$MOCK_LOG" + ! grep -Fx 'ssh:stop' "$MOCK_LOG" +} + +@test "boot-scoped Wi-Fi wait rejects unbounded configuration without sleeping" { + sh "$SERVICE" request ssh enable >/dev/null + printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" + printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" + cat >"$MOCK/sleep" <<'SH' +#!/bin/sh +exit 99 +SH + chmod +x "$MOCK/sleep" + + run env BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI=true \ + BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS=61 \ + BLOOM_NETWORK_SERVICES_WAIT_INTERVAL=1 \ + BLOOM_NETWORK_SERVICES_SLEEP_BIN="$MOCK/sleep" \ + sh "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"wifi_ready":false'* ]] + grep -Fx 'stop' "$MOCK_LOG" +} + @test "offline Samba enable provisions deliberately without starting a listener" { printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index 7a4e82619..62fa5d677 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -109,6 +109,27 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] } +@test "reconcile grants canonical services a bounded wait only while Wi-Fi is enabled" { + export SERVICES_ENV_LOG="$BATS_TEST_TMPDIR/services-env.log" + cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' +#!/bin/sh +printf '%s %s %s %s\n' "$*" "${BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI:-}" \ + "${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-}" \ + "${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-}" >"$SERVICES_ENV_LOG" +SH + chmod +x "$BLOOM_NETWORK_SERVICES_BIN" + + run "$SERVICE" request reconcile + [ "$status" -eq 0 ] + [ "$(cat "$SERVICES_ENV_LOG")" = 'reconcile true 20 1' ] + + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_NETWORK_JSONVAL_BIN" + chmod +x "$BLOOM_NETWORK_JSONVAL_BIN" + run "$SERVICE" request reconcile + [ "$status" -eq 0 ] + [ "$(cat "$SERVICES_ENV_LOG")" = 'reconcile false 20 1' ] +} + @test "unsupported Bloom Wi-Fi backend falls back to the complete compatibility check" { printf '#!/bin/sh\nexit 3\n' >"$BLOOM_NETWORK_BACKEND_BIN" chmod +x "$BLOOM_NETWORK_BACKEND_BIN" From 354cb7fb7afdf58ad2d3e38ed27483eedaab6aec Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 00:16:31 -0400 Subject: [PATCH 07/29] fix(network): require DHCP readiness before services --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 16 +-- docs/1.0_TEST_MATRIX.md | 4 +- docs/ARCHITECTURE.md | 14 +++ docs/SECURITY.md | 12 +++ docs/program/BloomOS_Work_Program_v3.json | 8 +- .../plus-net02-signed-recovery-20260826.json | 69 ++++++++++--- static/build/.tmp_update/bin/bloom-network | 29 +++--- .../.tmp_update/bin/bloom-network-services | 29 +----- .../.tmp_update/bin/bloom-network-transaction | 4 +- static/build/.tmp_update/bin/bloom-wifi | 99 ++++++++++++++++++- test/shell/network_services.bats | 57 +++-------- test/shell/network_status.bats | 37 +++---- test/shell/network_transaction.bats | 21 +++- test/shell/wifi_backend.bats | 95 +++++++++++++++++- 15 files changed, 353 insertions(+), 143 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index ac0912ce0..3be96a79c 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314623899 +byte_count = 314626226 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index f2e7522a7..28d82dbdf 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 6300, - "tree_sha256": "aa8a2c073cfa612793930188a60e81f92e7102451aaac7535a4b04836553b62e", + "byte_count": 6016, + "tree_sha256": "7cc3f913ae2e877399f6c63a8738ab246aaf2372cc7e8bbcc71280eeac8702a2", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -618,8 +618,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-services", "file_count": 1, - "byte_count": 12173, - "tree_sha256": "2ad6599b8bb8c7d4249f1d63822875878f862aeabe98775af9f03fd057a04788", + "byte_count": 11448, + "tree_sha256": "8623b11bbf007bd87380f8acd514c41cea1f2b83ecca0a8f5186ef8cbda65a3e", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 24317, - "tree_sha256": "63c23451c6fdfcb24d59fbee2a65ce87d1e2f484674c72a6af5423f780f123ca", + "byte_count": 24233, + "tree_sha256": "4865b6fe6b44454cfa645ec3393e3c944c74ad044dbe9354389ed8898fae0e5a", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -982,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 3258, - "tree_sha256": "8e83b7a25c221b52a046847d5b81c68f3b7f9b04bc7b6cb417d91b5a780ed175", + "byte_count": 6678, + "tree_sha256": "24c3eb5c9f9729cd111da27c664e6cb5243e6b8aa18fff4f5b95edcfd153c390", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index c8a8f194b..d6f3b77b6 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,7 +23,7 @@ observable properties and never substitutes for physical UX review. | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | -| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, and timeout restoration; transaction-bound network overlay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, and timeout restoration; transaction-bound network overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | ## Task-based UX certification @@ -46,7 +46,7 @@ shoulder button. | Themes | Declarative bounded manifest; Classic/Dense/Large Text/High Contrast; fallback on malformed input; immutable Safe Mode/recovery/credential UI | | Settings | One sectioned surface; inline values; capability filtering; one-layer details; hidden Developer section; optional overlapping L1/R1 list paging | | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | -| Network services | Desired/runtime separation; Wi-Fi reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | +| Network services | Desired/runtime separation; carrier without IPv4 remains acquiring/not-ready; canonical DHCP restart and bounded post-address listener reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f418d0028..4ec14496c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,6 +283,16 @@ the same canonical booleans with the same v1 fallback, so no listener consumer retains a parallel marker authority. Key-only SSH and command-forced, allowlisted SFTP remain unchanged. +Carrier alone is not network readiness. The station boundary reports usable +association only after a non-zero global IPv4 address exists. If carrier is up +without an address, `bloom-wifi` replaces any inherited DHCP client with its +fixed client invocation rather than preserving the incomplete state. A +detached, single-instance bounded readiness watcher reconciles listeners after the system DHCP +hook publishes that address and route. The initiating boot or UI request never +waits for the lease, the watcher accepts no command or destination input, and +it expires after 60 one-second checks. A late lease therefore restores +key-only services without another UI action or boot tick. + Bloom Shell applies that policy at a single supervised launch boundary. Only `bloom-native` and reviewed `onion-compatible` rows may stage a command; `mainui-dependent` and `development-only` rows remain visible but cannot launch @@ -465,6 +475,10 @@ publishes `applying` before invoking its canonical owner. `confirm` consumes the capability only while the deadline and `applied` phase remain valid. `rollback` and the watchdog restore canonical preferences, reconcile service state, undo an activated network overlay, audit listeners, and publish a terminal phase. +If an overlay activator has already restored its prior bytes, rollback first +verifies the exact saved network and service preferences. An exact match closes +the lease without replaying service requests, avoiding both unnecessary +listener restarts and failures from unavailable optional helpers. Monotonic deadlines make wall-clock repair irrelevant to recovery. SSH/SFTP helper changes use the fixed `ssh-shadow` transaction operation. The diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2928c9134..e2fb516b8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -28,6 +28,14 @@ names, and bounded-length violations. Programmatic uploads publish from a private staging root through a same-filesystem temporary file and atomic rename; future Web Transfer is required to use this same helper and policy. +Wi-Fi carrier is not treated as proof of a usable remote path. Network and +listener readiness require a non-zero global IPv4 address. An associated +station without one is repaired through Bloom's fixed DHCP invocation. A +detached single-instance watcher checks only that fixed interface for at most 60 seconds and +reconciles reviewed listeners after the system DHCP hook publishes the address +and route. It accepts no caller-selected scripts, interfaces, commands, or +destinations. + Developer component deployment is a separate, visible overlay rather than an update or release channel. It requires Developer Mode, a second local enablement marker, an idle update state, and no running game session. Both host and device @@ -47,6 +55,10 @@ preference operation, one sealed network-helper overlay, or the fixed-port key-only SSH shadow preflight. The shadow never replaces or stops port 22 and is removed on confirmation, rollback, failure, or expiry. Timeout, apply failure, and explicit rollback restore prior preferences and listener policy. +For a network overlay that has already self-restored, rollback verifies the +exact saved preference matrix and listener audit before replaying anything. An +exact match closes the lease directly, so an unavailable optional service +cannot turn an already-safe state into `rollback_failed`. The lease cannot enable Developer Mode, create either local marker, change authorized keys, replace port 22 destructively, or promote update state. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index c8912f3b9..a4d257460 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -777,15 +777,17 @@ "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", "evidence": [ "host candidate defines one schema-validated transfer authority for five read-write roots, read-only Saves, private-root denial, bounded paths, and same-filesystem atomic publication consumed by SFTP mount and write gates", - "the complete pinned shell gate passed 634/634; focused policy and inventory validation passed 28/28; legacy inventory, development provenance, shell syntax, patch hunk counts, and CI scope gates passed", + "the final corrected snapshot passed the complete pinned shell gate 640/640 from an immutable read-only mount, plus 47/47 focused network/transaction tests; legacy inventory, exact dependency totals, development provenance, FAT payload, JSON, POSIX syntax, and CI scope gates passed", "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server", "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; installed files remained unchanged and the device returned to green health with a clean key-only listener audit", "corrective commit 9fbaea2494cfce1abfd970bc6612cd80d12fabff requires explicit SFTP fsync before atomic publication, discards ordinary CLOSE/cancel staging, filters internal chroot scaffolding, fail-closes direct private paths and unbounded mutations, and derives the upload-size gate from the canonical policy", "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae", - "signed Full candidate aff110ba from workflow 32969798436 booted once but did not restore the key-only SSH listener before the update health deadline; the update system retained b073b423 and automatically rolled back, after which aggregate health, listener audit, and the single authorized key were confirmed; source and boot-log reconciliation identified an association-order race and the same PR now gives enabled Wi-Fi a bounded boot-only wait before remote-service reconciliation", - "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the corrected signed durable Mini Plus gate remains pending" + "signed Full candidates aff110ba from workflow 32969798436 and ef9dbec5 from workflow 33025501252 both failed to restore reachable key-only SSH; ef9dbec5 exposed carrier-only readiness with no global IPv4 address, an unresponsive request path, and missing DHCP recovery before automatic rollback to retained known-good b073b423", + "the corrected host candidate requires carrier plus global IPv4, reports carrier-only as acquiring_address, restarts only inherited udhcpc when address acquisition is missing, and performs delayed service reconciliation from a detached bounded watcher so UI and boot request paths remain non-blocking", + "an isolated exact-hash BusyBox ash fixture on the Mini Plus exercised the corrected DHCP recovery without changing its live radio, route, listeners, or processes; a guarded Warm overlay was refused outside the legacy allowlist and an allowlisted one-file attempt self-restored, exposing a legacy rollback replay failure that now has a focused transaction regression", + "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the device is retained on known-good b073b423 with IPv4 and route restored, SSH on and key-only, SFTP and Samba off, an IDLE session, and a clean listener audit; the corrected signed durable Mini Plus gate remains pending and no additional Full or Warm deployment is authorized from this checkpoint" ] }, { diff --git a/docs/validation/network/plus-net02-signed-recovery-20260826.json b/docs/validation/network/plus-net02-signed-recovery-20260826.json index e117d4374..ab9c20506 100644 --- a/docs/validation/network/plus-net02-signed-recovery-20260826.json +++ b/docs/validation/network/plus-net02-signed-recovery-20260826.json @@ -19,41 +19,84 @@ "manifest_sha256": "473329f8d8972f2080ea0f97131c167b2333f656409b455a1c203b60559be9b3", "signature_sha256": "c892254e7cc52399c7ae26ae728333c636d5d075a4b16e54e1d7a448d7413390" }, + "follow_up_candidate": { + "commit": "ef9dbec54f0faffc0efd2eaf6cac1009957bb307", + "version": "0.1.0-dev-ef9dbec5", + "workflow_run": 33025501252, + "deployment_tier": "full_signed", + "archive": { + "bytes": 203232278, + "sha256": "6d4e5628a0ebf76772c7bb81fd92ee035f809ab8404505794d72fd88f94fe417" + }, + "manifest_sha256": "d5dee32cad13211777fb535b9c465b4dcf43f2375b744ab9f31d3ae29792bb68", + "signature_sha256": "fb2582bec6c03ad4bf4c616c1643a5fe7a385c2a6a9fa990998395b591da3e17" + }, "result": { "status": "rejected_and_recovered", - "candidate_boot_attempts": 1, - "observed_failure": "the device associated with Wi-Fi but did not restore the desired key-only SSH listener before the update health deadline", + "candidate_boot_attempts": { + "0.1.0-dev-aff110ba": 1, + "0.1.0-dev-ef9dbec5": 2 + }, + "observed_failure": "both signed candidates treated Wi-Fi carrier as usable networking before wlan0 had a global IPv4 address; the follow-up candidate showed connected in Bloom Shell without LAN details, did not accept the Wi-Fi toggle, and did not restore reachable key-only SSH", "recovery_state": { "phase": "known_good", "version": "0.1.0-dev-b073b423", - "recovered_from": "0.1.0-dev-aff110ba", - "snapshot_id": "19700101T000117Z-pre-rollback-880" + "recovered_from": "0.1.0-dev-ef9dbec5", + "snapshot_id": "19700101T000116Z-pre-rollback-893" } }, "diagnosis": { - "classification": "boot ordering race", + "classification": "carrier-only readiness and missing DHCP recovery", "evidence": [ - "bloom-wifi starts association and DHCP asynchronously", - "the boot reconciliation path could evaluate carrier before association completed and stop the desired SSH service", - "the collected boot log later showed successful wlan0 association", - "the known-good predecessor predates the network-service-v2 boot ordering" + "the rejected follow-up candidate reached carrier while wlan0 had no global IPv4 address and no usable LAN route", + "bloom-network and bloom-network-services accepted carrier as associated and ready, so remote services were reconciled before DHCP had succeeded", + "the follow-up candidate's bounded carrier wait ran in the request path, making the Wi-Fi control appear unresponsive without repairing DHCP", + "the installed known-good predecessor recovered 192.168.1.180/24 and the default route through 192.168.1.1" ], - "corrective_scope": "wait for carrier only during enabled-Wi-Fi boot reconciliation, bounded to 20 one-second checks by default; all ordinary status and disabled or unsupported paths remain non-waiting" + "corrective_scope": "require carrier plus a nonzero global IPv4 address for associated/ready claims; report carrier-only state as acquiring_address; when carrier exists without IPv4, restart the inherited udhcpc client with the fixed interface and script and reconcile remote services from a detached bounded watcher so UI and boot callers return immediately" }, "post_recovery": { - "aggregate_health": true, + "update_state_known_good": true, "listener_audit_clean": true, "ssh": "on/running key-only", "sftp": "off/stopped", "samba": "off/stopped", + "ipv4": "192.168.1.180/24", + "default_route": "192.168.1.1", "authorized_key_count": 1, "authorized_key_suffix": "b9002791", + "session_state": "IDLE", + "screen": "Bloom Shell visible with Wi-Fi 100%; privacy-bounded framebuffer evidence retained outside the repository", "shutdown_log": { "mode": "reboot", "remount_ro_final": 0, "umount_recursive": 0 } }, + "corrected_candidate_validation": { + "device_fixture_bloom_wifi": { + "bytes": 4678, + "sha256": "480b7ca00e0cd9d8676c5b858016532c0209d28f43680d1d4882c6149ee1c4fa" + }, + "current_bloom_wifi": { + "bytes": 6678, + "sha256": "9215ea478f1218eab0b5bdbb66c2072dc3df1dcd23141a39a9338439ae8d03bb" + }, + "host_focused_shell_tests": "47/47 passed, including detached watcher reuse, stale-lock recovery, and the self-restored transaction regression", + "complete_shell_tests": "640/640 passed from an immutable read-only repository mount", + "device_fixture": "the exact current bloom-wifi bytes passed an isolated /tmp BusyBox ash fixture on the Mini Plus, called only mocked pkill/sleep/udhcpc/service commands, and cleaned its single detached watcher; the fixture was removed and installed version, live IPv4, route, listener, service preferences, listener audit, and session state were unchanged", + "current_candidate_device_status": "executed only against isolated mocks; not installed, activated, or executed on the live network path" + }, + "warm_overlay_attempt": { + "deployment_id": "net02-wifi-480b7ca0", + "transaction_id": "tx-1972-2057", + "result": "the legacy development activator self-restored the exact installed bloom-wifi bytes, but the legacy transaction helper replayed prior service preferences and ended rollback_failed when unavailable Samba restoration returned failure", + "installed_bloom_wifi_sha256": "5ec86c0d09d25dd7dcb8196725c9e20935478f1cab582a3bb5952e5a04af4c35", + "overlay_active": false, + "recovery_lease": "inactive", + "cleanup": "complete", + "follow_up": "the source transaction now verifies an already-restored network overlay before replaying preferences; a regression covers an inactive self-restored overlay with an unavailable service helper" + }, "physical_observation": { "reported_by": "maintainer", "context": "update and recovery loop", @@ -61,9 +104,11 @@ "classification": "not yet reproduced in the idle Bloom Shell; update-loop power ownership remains a limitation requiring a focused physical retest" }, "limitations": [ - "The failed candidate could not be inspected over SSH while its listener was unavailable; the boot-race diagnosis joins retained logs with the reviewed source ordering and is an inference.", + "The failed candidates could not be inspected over SSH while their listeners were unavailable; the diagnosis joins retained local state, maintainer-reported UI behavior, installed rollback state, and reviewed source ordering.", "Recovery included maintainer physical restart and power-button interaction, so this run is not evidence of unattended recovery.", "No signed durable SFTP behavior is claimed from the rejected candidate.", + "The corrected DHCP and transaction paths have not received another signed Full deployment; no further device mutation is permitted from this checkpoint until the host and CI gates are green and a separately reviewed deployment path avoids the legacy transaction failure.", + "The installed known-good build lacks the current health aggregator and retains the terminal rollback_failed transaction record as honest evidence even though update state, IPv4, route, key-only SSH, service preferences, session state, and listener audit were independently verified.", "Mini Plus remote and maintainer-reported physical evidence does not certify Mini V1-V4 or Mini Flip." ], "retained_evidence": { diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index d32fef41f..ba2860bbf 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -9,8 +9,7 @@ BACKEND=${BLOOM_NETWORK_BACKEND_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} COMPAT=${BLOOM_NETWORK_COMPAT_BIN:-/mnt/SDCARD/.tmp_update/script/network/update_networking.sh} SETTINGS=${BLOOM_SETTINGS_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-settings} SERVICES=${BLOOM_NETWORK_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} -SERVICES_WAIT_ATTEMPTS=${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-20} -SERVICES_WAIT_INTERVAL=${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-1} +IP=${BLOOM_NETWORK_IP_BIN:-/bin/ip} result() { printf '{"schema":1,"service":"bloom-network","available":%s,"wifi_capable":%s,"wifi_enabled":%s,"associated":%s,"state":"%s"}\n' \ @@ -32,13 +31,12 @@ wifi_capability() { case "$wifi" in available) printf true ;; unavailable|not_applicable) printf false ;; *) return 1 ;; esac } -reconcile_services() { - wait_for_wifi=$1 - [ ! -x "$SERVICES" ] || - BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI="$wait_for_wifi" \ - BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS="$SERVICES_WAIT_ATTEMPTS" \ - BLOOM_NETWORK_SERVICES_WAIT_INTERVAL="$SERVICES_WAIT_INTERVAL" \ - "$SERVICES" reconcile >/dev/null 2>&1 +reconcile_services() { [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1; } + +ipv4_ready() { + [ -x "$IP" ] || return 1 + "$IP" -4 -o addr show dev wlan0 scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' } wifi_preference() { @@ -70,6 +68,7 @@ status() { [ ! -r "$interface/operstate" ] || IFS= read -r operstate <"$interface/operstate" || true [ ! -r "$interface/carrier" ] || IFS= read -r carrier <"$interface/carrier" || true [ "$operstate" = up ] && [ "$carrier" = 1 ] || { result true true true false not_associated; return 0; } + ipv4_ready || { result true true true false acquiring_address; return 0; } result true true true true associated } @@ -77,7 +76,7 @@ status() { reconcile() { wifi=$(wifi_capability) || { operation_result false platform_unavailable; return 1; } if [ "$wifi" = false ]; then - reconcile_services false || { operation_result false remote_services_failed; return 1; } + reconcile_services || { operation_result false remote_services_failed; return 1; } operation_result false no_network_hardware return 0 fi @@ -87,16 +86,15 @@ reconcile() { else status=$? if [ "$status" -eq 3 ] && [ -x "$COMPAT" ] && "$COMPAT" check >/dev/null 2>&1; then - reconcile_services false || { operation_result false remote_services_failed; return 1; } + reconcile_services || { operation_result false remote_services_failed; return 1; } operation_result true compatibility_applied return 0 fi operation_result false backend_failed return 1 fi - preference=$(wifi_preference) || { operation_result false wifi_state_unavailable; return 1; } - if [ "$preference" = 1 ]; then wait_for_wifi=true; else wait_for_wifi=false; fi - reconcile_services "$wait_for_wifi" || { operation_result false remote_services_failed; return 1; } + wifi_preference >/dev/null || { operation_result false wifi_state_unavailable; return 1; } + reconcile_services || { operation_result false remote_services_failed; return 1; } operation_result true applied } @@ -129,8 +127,7 @@ set_preference() { return 1 fi fi - if [ "$operation" = enable ]; then wait_for_wifi=true; else wait_for_wifi=false; fi - reconcile_services "$wait_for_wifi" || { + reconcile_services || { preference_result "$operation" true false remote_services_failed return 1 } diff --git a/static/build/.tmp_update/bin/bloom-network-services b/static/build/.tmp_update/bin/bloom-network-services index 0fb9e1d46..f276fc499 100755 --- a/static/build/.tmp_update/bin/bloom-network-services +++ b/static/build/.tmp_update/bin/bloom-network-services @@ -13,10 +13,7 @@ SAMBA_HELPER=${BLOOM_SAMBA_SERVICE_BIN:-$SYS_DIR/bin/bloom-samba-service} PLATFORM=${BLOOM_PLATFORM_BIN:-$SYS_DIR/bin/bloom-platform} SETTINGS=${BLOOM_SETTINGS_BIN:-$SYS_DIR/bin/bloom-settings} LISTENER_AUDIT=${BLOOM_LISTENER_AUDIT_BIN:-$SYS_DIR/bin/bloom-listener-audit} -SLEEP=${BLOOM_NETWORK_SERVICES_SLEEP_BIN:-sleep} -WAIT_FOR_WIFI=${BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI:-false} -WAIT_ATTEMPTS=${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-20} -WAIT_INTERVAL=${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-1} +IP=${BLOOM_NETWORK_SERVICES_IP_BIN:-/bin/ip} boolean() { "$@" >/dev/null 2>&1 && printf true || printf false; } legacy_enabled() { [ -f "$STATE_DIR/$1.enabled" ]; } @@ -42,22 +39,10 @@ wifi_ready() { interface="$ROOT/sys/class/net/wlan0" [ -r "$interface/operstate" ] && [ -r "$interface/carrier" ] || return 1 [ "$(cat "$interface/operstate" 2>/dev/null || true)" = up ] && - [ "$(cat "$interface/carrier" 2>/dev/null || true)" = 1 ] -} -wait_wifi_ready() { - case "$WAIT_FOR_WIFI" in true) ;; *) return 1 ;; esac - case "$WAIT_ATTEMPTS" in ''|*[!0-9]*) return 1 ;; esac - case "$WAIT_INTERVAL" in ''|*[!0-9]*) return 1 ;; esac - [ "$WAIT_ATTEMPTS" -ge 1 ] && [ "$WAIT_ATTEMPTS" -le 60 ] || return 1 - [ "$WAIT_INTERVAL" -ge 1 ] && [ "$WAIT_INTERVAL" -le 5 ] || return 1 - attempt=1 - while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do - wifi_ready && return 0 - [ "$attempt" -lt "$WAIT_ATTEMPTS" ] || break - "$SLEEP" "$WAIT_INTERVAL" || return 1 - attempt=$((attempt + 1)) - done - return 1 + [ "$(cat "$interface/carrier" 2>/dev/null || true)" = 1 ] || return 1 + [ -x "$IP" ] || return 1 + "$IP" -4 -o addr show dev wlan0 scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' } settings_get() { @@ -161,10 +146,6 @@ status() { reconcile_loaded() { ready=$(boolean wifi_ready) - if [ "$ready" = false ] && - { desired ssh || desired sftp || desired samba; }; then - ready=$(boolean wait_wifi_ready) - fi applied=true if [ "$ready" = true ]; then if { desired ssh || desired sftp; } && available_key && [ -x "$SSH_HELPER" ]; then diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 11b9dad23..55de9a437 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -305,18 +305,16 @@ rollback_locked() { if [ "$shadow_port" -gt 0 ]; then "$SSH_HELPER" shadow-stop >/dev/null 2>&1 || restored=false fi - overlay_restored=false if [ "$operation" = network-overlay ] && [ -n "$overlay" ] && [ -x "$DEV_DEPLOY" ]; then dev_status="$($DEV_DEPLOY status 2>/dev/null || true)" active_overlay="$(printf '%s\n' "$dev_status" | "$JQ" -er '.active.deployment_id // ""' 2>/dev/null || true)" if [ "$active_overlay" = "$overlay" ]; then "$DEV_DEPLOY" rollback >/dev/null 2>&1 || restored=false - [ "$restored" != true ] || overlay_restored=true fi fi if [ "$operation" = ssh-shadow ]; then verify_prior_unchanged || restored=false - elif [ "$overlay_restored" = true ] && verify_prior_unchanged; then + elif [ "$operation" = network-overlay ] && verify_prior_unchanged; then : else restore_prior || restored=false diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index 3c5fad750..84ce46b03 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -12,9 +12,16 @@ WPA_CONFIG=${BLOOM_WIFI_CONFIG:-/appconfigs/wpa_supplicant.conf} UDHCPC=${BLOOM_WIFI_UDHCPC_BIN:-/sbin/udhcpc} UDHCPC_SCRIPT=${BLOOM_WIFI_UDHCPC_SCRIPT:-/etc/init.d/udhcpc.script} IW=${BLOOM_WIFI_IW_BIN:-/mnt/SDCARD/.tmp_update/bin/iw} +IP=${BLOOM_WIFI_IP_BIN:-/bin/ip} PKILL=${BLOOM_WIFI_PKILL_BIN:-/usr/bin/pkill} SLEEP=${BLOOM_WIFI_SLEEP_BIN:-sleep} +START_SLEEP=${BLOOM_WIFI_START_SLEEP_BIN:-sleep} +SERVICES=${BLOOM_WIFI_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} +NOHUP=${BLOOM_WIFI_NOHUP_BIN:-/usr/bin/nohup} +SELF=${BLOOM_WIFI_SELF:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} INTERFACE=${BLOOM_WIFI_INTERFACE:-wlan0} +WATCH_DIR=${BLOOM_WIFI_WATCH_DIR:-$ROOT/tmp/bloom-wifi-reconcile-watcher} +PROC_ROOT=${BLOOM_WIFI_PROC_ROOT:-/proc} result() { printf '{"schema":1,"service":"bloom-wifi","supported":%s,"applied":%s,"state":"%s"}\n' "$1" "$2" "$3" @@ -37,20 +44,94 @@ associated() { [ "$state" = up ] && [ "$carrier" = 1 ] } +ipv4_ready() { + require_executable "$IP" || return 1 + "$IP" -4 -o addr show dev "$INTERFACE" scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' +} + require_executable() { [ -x "$1" ]; } +start_reconcile_watcher() { + [ -x "$SERVICES" ] || return 0 + [ -x "$NOHUP" ] && [ -x "$SELF" ] || return 1 + if [ -e "$WATCH_DIR" ] || [ -L "$WATCH_DIR" ]; then + [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 + start_attempt=0 + while [ ! -f "$WATCH_DIR/pid" ] && [ "$start_attempt" -lt 10 ]; do + [ ! -L "$WATCH_DIR/pid" ] || return 1 + "$START_SLEEP" 0.05 || return 1 + start_attempt=$((start_attempt + 1)) + done + if [ -e "$WATCH_DIR/pid" ] || [ -L "$WATCH_DIR/pid" ]; then + [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 1 + watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) + case "$watcher_pid" in + ''|*[!0-9]*) return 1 ;; + esac + watcher_command=$(tr '\000' ' ' <"$PROC_ROOT/$watcher_pid/cmdline" 2>/dev/null || true) + case "$watcher_command" in + *"$SELF watch"*) kill -0 "$watcher_pid" 2>/dev/null && return 0 ;; + esac + fi + rm -f "$WATCH_DIR/pid" || return 1 + rmdir "$WATCH_DIR" || return 1 + fi + mkdir "$WATCH_DIR" 2>/dev/null || return 0 + "$NOHUP" "$SELF" watch >/dev/null 2>&1 /dev/null || true)" != "$$" ] || rm -f "$WATCH_DIR/pid" + rmdir "$WATCH_DIR" 2>/dev/null || true + } + trap cleanup_watcher EXIT HUP INT TERM + pid_tmp="$WATCH_DIR/pid.$$" + (umask 077; printf '%s\n' "$$" >"$pid_tmp") || return 1 + mv -f "$pid_tmp" "$WATCH_DIR/pid" || return 1 + attempt=0 + while [ "$attempt" -lt 60 ]; do + if ipv4_ready; then + "$SERVICES" reconcile >/dev/null 2>&1 || true + return 0 + fi + attempt=$((attempt + 1)) + "$SLEEP" 1 || return 0 + done +} + start_wifi() { - for dependency in "$RADIO" "$IFCONFIG" "$WPA" "$UDHCPC" "$IW"; do + for dependency in "$RADIO" "$IFCONFIG" "$WPA" "$UDHCPC" "$UDHCPC_SCRIPT" "$IW" "$IP" "$PKILL"; do require_executable "$dependency" || return 1 done [ -r "$WPA_CONFIG" ] && [ ! -L "$WPA_CONFIG" ] || return 1 - [ -r "$UDHCPC_SCRIPT" ] && [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + unset LD_PRELOAD + "$PKILL" -TERM wpa_supplicant >/dev/null 2>&1 || true + "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true "$RADIO" wifion >/dev/null 2>&1 || return 1 "$SLEEP" 2 || return 1 "$IFCONFIG" "$INTERFACE" up >/dev/null 2>&1 || return 1 "$WPA" -B -D nl80211 "-i$INTERFACE" -c "$WPA_CONFIG" >/dev/null 2>&1 || return 1 "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & "$IW" dev "$INTERFACE" set power_save off >/dev/null 2>&1 || return 1 + start_reconcile_watcher +} + +restart_dhcp() { + for dependency in "$UDHCPC" "$UDHCPC_SCRIPT" "$PKILL"; do + require_executable "$dependency" || return 1 + done + [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + unset LD_PRELOAD + "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true + "$SLEEP" 1 || return 1 + "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & + start_reconcile_watcher } stop_wifi() { @@ -68,10 +149,15 @@ reconcile() { [ "$model" = mini_plus ] || { result false false unsupported_model; return 3; } preference=$(enabled) || { result true false preference_unavailable; return 1; } if [ "$preference" = 1 ]; then - if associated; then + if associated && ipv4_ready; then result true false preserved return 0 fi + if associated; then + restart_dhcp || { result true false dhcp_restart_failed; return 1; } + result true true dhcp_restarted + return 0 + fi start_wifi || { result true false start_failed; return 1; } result true true started return 0 @@ -80,5 +166,8 @@ reconcile() { result true true stopped } -[ "$#" -eq 1 ] && [ "$1" = reconcile ] || { result false false invalid_arguments; exit 2; } -reconcile +case "$#:${1:-}" in + 1:reconcile) reconcile ;; + 1:watch) reconcile_watcher ;; + *) result false false invalid_arguments; exit 2 ;; +esac diff --git a/test/shell/network_services.bats b/test/shell/network_services.bats index 6d4e1c18a..a05d0e0a4 100644 --- a/test/shell/network_services.bats +++ b/test/shell/network_services.bats @@ -8,7 +8,7 @@ setup() { mkdir -p "$SDCARD/.bloom" "$SDCARD/.tmp_update/config" "$MOCK" printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey bloom-test' \ >"$SDCARD/.bloom/authorized_keys" - for name in platform ssh-helper sftp-server samba-service settings; do + for name in platform ssh-helper sftp-server samba-service settings ip; do printf '#!/bin/sh\nexit 0\n' >"$MOCK/$name" chmod +x "$MOCK/$name" done @@ -27,6 +27,7 @@ export SETTINGS_READ_FAILURE="$BATS_TEST_TMPDIR/settings-read-failure" export BLOOM_SFTP_SERVER_BIN="$MOCK/sftp-server" export BLOOM_SAMBA_SERVICE_BIN="$MOCK/samba-service" export BLOOM_SETTINGS_BIN="$MOCK/settings" + export BLOOM_NETWORK_SERVICES_IP_BIN="$MOCK/ip" cat >"$MOCK/settings" <<'SH' #!/bin/sh case "$1" in @@ -40,11 +41,17 @@ case "$1" in set) field=${2#network.}; field=${field%_enabled}; printf '%s\n' "$3" >"$SETTINGS_STATE.$field"; [ -z "${SETTINGS_FAIL_AFTER_SET:-}" ] || : >"$SETTINGS_READ_FAILURE" ;; *) exit 2 ;; esac +SH + cat >"$MOCK/ip" <<'SH' +#!/bin/sh +[ -e "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' SH chmod +x "$MOCK/settings" mkdir -p "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0" printf 'up\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" printf '1\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" } @test "status reports bounded service capabilities and defaults" { @@ -208,55 +215,15 @@ EOF grep -Fx 'reconcile' "$MOCK_LOG" } -@test "boot-scoped reconcile waits for delayed association before restoring SSH" { +@test "carrier without a global address keeps reviewed listeners stopped" { sh "$SERVICE" request ssh enable >/dev/null - cat >"$MOCK/ssh-helper" <<'SH' -#!/bin/sh -printf 'ssh:%s\n' "$*" >>"$MOCK_LOG" -SH - chmod +x "$MOCK/ssh-helper" : >"$MOCK_LOG" - printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" - printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" - sleep_log="$BATS_TEST_TMPDIR/wifi-wait.log" - cat >"$MOCK/sleep" <>"$sleep_log" -printf 'up\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" -printf '1\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" -EOF - chmod +x "$MOCK/sleep" - - run env BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI=true \ - BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS=3 \ - BLOOM_NETWORK_SERVICES_WAIT_INTERVAL=1 \ - BLOOM_NETWORK_SERVICES_SLEEP_BIN="$MOCK/sleep" \ - sh "$SERVICE" reconcile - [ "$status" -eq 0 ] - [[ "$output" == *'"wifi_ready":true'* ]] - [ "$(cat "$sleep_log")" = 1 ] - grep -Fx 'ssh:start' "$MOCK_LOG" - ! grep -Fx 'ssh:stop' "$MOCK_LOG" -} - -@test "boot-scoped Wi-Fi wait rejects unbounded configuration without sleeping" { - sh "$SERVICE" request ssh enable >/dev/null - printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" - printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" - cat >"$MOCK/sleep" <<'SH' -#!/bin/sh -exit 99 -SH - chmod +x "$MOCK/sleep" - - run env BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI=true \ - BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS=61 \ - BLOOM_NETWORK_SERVICES_WAIT_INTERVAL=1 \ - BLOOM_NETWORK_SERVICES_SLEEP_BIN="$MOCK/sleep" \ - sh "$SERVICE" reconcile + rm "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" + run sh "$SERVICE" reconcile [ "$status" -eq 0 ] [[ "$output" == *'"wifi_ready":false'* ]] grep -Fx 'stop' "$MOCK_LOG" + ! grep -Fx 'start' "$MOCK_LOG" } @test "offline Samba enable provisions deliberately without starting a listener" { diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index 62fa5d677..3c219917d 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -10,6 +10,7 @@ setup() { export BLOOM_NETWORK_COMPAT_BIN="$BATS_TEST_TMPDIR/update-networking" export BLOOM_SETTINGS_BIN="$BATS_TEST_TMPDIR/bloom-settings" export BLOOM_NETWORK_SERVICES_BIN="$BATS_TEST_TMPDIR/bloom-network-services" + export BLOOM_NETWORK_IP_BIN="$BATS_TEST_TMPDIR/ip" export BACKEND_LOG="$BATS_TEST_TMPDIR/backend.log" export COMPAT_LOG="$BATS_TEST_TMPDIR/compat.log" export SETTINGS_LOG="$BATS_TEST_TMPDIR/settings.log" @@ -17,6 +18,7 @@ setup() { mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" printf up >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" printf '#!/bin/sh\nprintf available\n' >"$BLOOM_PLATFORM_BIN" printf '#!/bin/sh\n[ "$1" = wifi ] || exit 2\nprintf 1\n' >"$BLOOM_NETWORK_JSONVAL_BIN" cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' @@ -35,7 +37,19 @@ SH #!/bin/sh printf '%s\n' "$*" >"$SERVICES_LOG" SH - chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" + cat >"$BLOOM_NETWORK_IP_BIN" <<'SH' +#!/bin/sh +[ -e "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' +SH + chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" "$BLOOM_NETWORK_IP_BIN" +} + +@test "status does not claim a usable association before DHCP publishes IPv4" { + rm "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" + run "$SERVICE" status + [ "$status" -eq 0 ] + [[ "$output" == *'"associated":false,"state":"acquiring_address"'* ]] } @test "status reports associated Wi-Fi without exposing network identity" { @@ -109,27 +123,6 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] } -@test "reconcile grants canonical services a bounded wait only while Wi-Fi is enabled" { - export SERVICES_ENV_LOG="$BATS_TEST_TMPDIR/services-env.log" - cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' -#!/bin/sh -printf '%s %s %s %s\n' "$*" "${BLOOM_NETWORK_SERVICES_WAIT_FOR_WIFI:-}" \ - "${BLOOM_NETWORK_SERVICES_WAIT_ATTEMPTS:-}" \ - "${BLOOM_NETWORK_SERVICES_WAIT_INTERVAL:-}" >"$SERVICES_ENV_LOG" -SH - chmod +x "$BLOOM_NETWORK_SERVICES_BIN" - - run "$SERVICE" request reconcile - [ "$status" -eq 0 ] - [ "$(cat "$SERVICES_ENV_LOG")" = 'reconcile true 20 1' ] - - printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_NETWORK_JSONVAL_BIN" - chmod +x "$BLOOM_NETWORK_JSONVAL_BIN" - run "$SERVICE" request reconcile - [ "$status" -eq 0 ] - [ "$(cat "$SERVICES_ENV_LOG")" = 'reconcile false 20 1' ] -} - @test "unsupported Bloom Wi-Fi backend falls back to the complete compatibility check" { printf '#!/bin/sh\nexit 3\n' >"$BLOOM_NETWORK_BACKEND_BIN" chmod +x "$BLOOM_NETWORK_BACKEND_BIN" diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index c854ff09d..1dbdea5a9 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -59,7 +59,11 @@ EOF #!/bin/sh printf 'dev-deploy %s\n' "$*" >>"$TRANSACTION_LOG" if [ "$1" = status ]; then - printf '%s\n' '{"schema":1,"overlay_active":true,"active":{"deployment_id":"network-test"}}' + if [ "${DEV_OVERLAY_ACTIVE:-1}" = 1 ]; then + printf '%s\n' '{"schema":1,"overlay_active":true,"active":{"deployment_id":"network-test"}}' + else + printf '%s\n' '{"schema":1,"overlay_active":false,"active":null}' + fi fi EOF cat >"$BLOOM_LISTENER_AUDIT_BIN" <<'EOF' @@ -293,3 +297,18 @@ EOF ! grep -Fq 'services request' "$TRANSACTION_LOG" ! grep -Fq 'network request' "$TRANSACTION_LOG" } + +@test "self-restored network overlay closes rollback without replaying prior services" { + begin_transaction + run "$SERVICE" apply 0123456789abcdef0123456789abcdef network-overlay network-test + [ "$status" -eq 0 ] + + run env DEV_OVERLAY_ACTIVE=0 SERVICES_FAIL_REQUEST=1 "$SERVICE" rollback + [ "$status" -eq 0 ] + run "$SERVICE" status + [[ "$output" == *'"phase":"rolled_back"'* ]] + [[ "$output" == *'"failure_reason":"explicit"'* ]] + ! grep -Fq 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" +} diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index 34ef8683c..2b2a1c0e8 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -12,8 +12,15 @@ setup() { export BLOOM_WIFI_UDHCPC_BIN="$BATS_TEST_TMPDIR/udhcpc" export BLOOM_WIFI_UDHCPC_SCRIPT="$BATS_TEST_TMPDIR/udhcpc.script" export BLOOM_WIFI_IW_BIN="$BATS_TEST_TMPDIR/iw" + export BLOOM_WIFI_IP_BIN="$BATS_TEST_TMPDIR/ip" export BLOOM_WIFI_PKILL_BIN="$BATS_TEST_TMPDIR/pkill" export BLOOM_WIFI_SLEEP_BIN="$BATS_TEST_TMPDIR/sleep" + export BLOOM_WIFI_START_SLEEP_BIN=/bin/sleep + export BLOOM_WIFI_SERVICES_BIN="$BATS_TEST_TMPDIR/network-services" + export BLOOM_WIFI_NOHUP_BIN=/usr/bin/nohup + export BLOOM_WIFI_SELF="$SERVICE" + export BLOOM_WIFI_WATCH_DIR="$BATS_TEST_TMPDIR/watcher" + export BLOOM_WIFI_PROC_ROOT=/proc export WIFI_LOG="$BATS_TEST_TMPDIR/wifi.log" mkdir -p "$BLOOM_WIFI_ROOT/sys/class/net/wlan0" printf down >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" @@ -22,30 +29,116 @@ setup() { printf '#!/bin/sh\nprintf 1\n' >"$BLOOM_WIFI_JSONVAL_BIN" : >"$BLOOM_WIFI_CONFIG" : >"$BLOOM_WIFI_UDHCPC_SCRIPT" - for name in axp_test ifconfig wpa_supplicant udhcpc iw pkill sleep; do + chmod +x "$BLOOM_WIFI_UDHCPC_SCRIPT" + for name in axp_test ifconfig wpa_supplicant udhcpc iw pkill; do cat >"$BATS_TEST_TMPDIR/$name" <<'SH' #!/bin/sh printf '%s %s\n' "$(basename "$0")" "$*" >>"$WIFI_LOG" SH chmod +x "$BATS_TEST_TMPDIR/$name" done + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +: >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" +SH + cat >"$BLOOM_WIFI_SERVICES_BIN" <<'SH' +#!/bin/sh +printf 'services %s\n' "$*" >>"$WIFI_LOG" +SH + cat >"$BLOOM_WIFI_IP_BIN" <<'SH' +#!/bin/sh +[ -e "$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' +SH + chmod +x "$BLOOM_WIFI_IP_BIN" "$BLOOM_WIFI_SLEEP_BIN" "$BLOOM_WIFI_SERVICES_BIN" chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_WIFI_JSONVAL_BIN" } @test "associated Plus is preserved without invoking lifecycle commands" { printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" run "$SERVICE" reconcile [ "$status" -eq 0 ] [ "$output" = '{"schema":1,"service":"bloom-wifi","supported":true,"applied":false,"state":"preserved"}' ] [ ! -s "$WIFI_LOG" ] } +@test "associated Plus without an address restarts canonical DHCP" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"dhcp_restarted"'* ]] + grep -Fx 'pkill -TERM udhcpc' "$WIFI_LOG" + grep -Fx 'sleep 1' "$WIFI_LOG" + grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" + ! grep -Fx 'axp_test wifion' "$WIFI_LOG" + for unused in 1 2 3 4 5; do + grep -Fx 'services reconcile' "$WIFI_LOG" >/dev/null && break + sleep 0.1 + done + grep -Fx 'services reconcile' "$WIFI_LOG" + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] +} + +@test "repeated address recovery reuses one bounded detached watcher" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + for unused in 1 2 3 4 5; do + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] && break + sleep 0.05 + done + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ "$(cat "$BLOOM_WIFI_WATCH_DIR/pid")" = "$watcher_pid" ] + kill -TERM "$watcher_pid" + wait "$watcher_pid" 2>/dev/null || true +} + +@test "a watcher lock without an owner is recovered on the next request" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + mkdir "$BLOOM_WIFI_WATCH_DIR" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + for unused in 1 2 3 4 5; do + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] && break + sleep 0.05 + done + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + kill -TERM "$watcher_pid" + wait "$watcher_pid" 2>/dev/null || true +} + @test "enabled disconnected Plus starts the fixed lifecycle" { run "$SERVICE" reconcile [ "$status" -eq 0 ] [[ "$output" == *'"state":"started"'* ]] grep -Fx 'axp_test wifion' "$WIFI_LOG" + grep -Fx 'pkill -TERM wpa_supplicant' "$WIFI_LOG" grep -Fx 'ifconfig wlan0 up' "$WIFI_LOG" grep -Fx "wpa_supplicant -B -D nl80211 -iwlan0 -c $BLOOM_WIFI_CONFIG" "$WIFI_LOG" grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" From 6904df1a35e513307e7c0d709841eda7f13c0630 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 00:23:16 -0400 Subject: [PATCH 08/29] test(network): wait for watcher cleanup --- test/shell/wifi_backend.bats | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index 2b2a1c0e8..d6be92ed8 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -80,6 +80,10 @@ SH sleep 0.1 done grep -Fx 'services reconcile' "$WIFI_LOG" + for unused in 1 2 3 4 5 6 7 8 9 10; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] } From 864049b536621c54c79a4c9df7f9641370b1b003 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 06:53:34 -0400 Subject: [PATCH 09/29] fix(network): verify DHCP watcher startup --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- docs/program/BloomOS_Work_Program_v3.json | 4 +- .../plus-net02-signed-recovery-20260826.json | 14 ++--- static/build/.tmp_update/bin/bloom-wifi | 56 ++++++++++++++++--- test/shell/wifi_backend.bats | 25 +++++++-- 6 files changed, 79 insertions(+), 26 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 3be96a79c..2a31003d5 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314626226 +byte_count = 314627374 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 28d82dbdf..313b6c8ce 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -982,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 6678, - "tree_sha256": "24c3eb5c9f9729cd111da27c664e6cb5243e6b8aa18fff4f5b95edcfd153c390", + "byte_count": 7826, + "tree_sha256": "b1eb6876a865a95ae9f590339c0b20cef2443a732fee29c1b1b9474254252b6f", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index a4d257460..2aeb78d90 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -777,7 +777,7 @@ "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", "evidence": [ "host candidate defines one schema-validated transfer authority for five read-write roots, read-only Saves, private-root denial, bounded paths, and same-filesystem atomic publication consumed by SFTP mount and write gates", - "the final corrected snapshot passed the complete pinned shell gate 640/640 from an immutable read-only mount, plus 47/47 focused network/transaction tests; legacy inventory, exact dependency totals, development provenance, FAT payload, JSON, POSIX syntax, and CI scope gates passed", + "the final corrected snapshot passed the complete pinned shell gate 641/641 from an immutable read-only mount, plus 48/48 focused network/transaction tests; legacy inventory, exact dependency totals, development provenance, FAT payload, JSON, POSIX syntax, and CI scope gates passed", "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server", "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", @@ -785,7 +785,7 @@ "corrective commit 9fbaea2494cfce1abfd970bc6612cd80d12fabff requires explicit SFTP fsync before atomic publication, discards ordinary CLOSE/cancel staging, filters internal chroot scaffolding, fail-closes direct private paths and unbounded mutations, and derives the upload-size gate from the canonical policy", "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae", "signed Full candidates aff110ba from workflow 32969798436 and ef9dbec5 from workflow 33025501252 both failed to restore reachable key-only SSH; ef9dbec5 exposed carrier-only readiness with no global IPv4 address, an unresponsive request path, and missing DHCP recovery before automatic rollback to retained known-good b073b423", - "the corrected host candidate requires carrier plus global IPv4, reports carrier-only as acquiring_address, restarts only inherited udhcpc when address acquisition is missing, and performs delayed service reconciliation from a detached bounded watcher so UI and boot request paths remain non-blocking", + "the corrected host candidate requires carrier plus global IPv4, reports carrier-only as acquiring_address, restarts only inherited udhcpc when address acquisition is missing, and performs delayed service reconciliation from a detached bounded watcher whose startup must be acknowledged and whose launch failure is rejected, so UI and boot request paths remain bounded without silently losing listener restoration", "an isolated exact-hash BusyBox ash fixture on the Mini Plus exercised the corrected DHCP recovery without changing its live radio, route, listeners, or processes; a guarded Warm overlay was refused outside the legacy allowlist and an allowlisted one-file attempt self-restored, exposing a legacy rollback replay failure that now has a focused transaction regression", "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the device is retained on known-good b073b423 with IPv4 and route restored, SSH on and key-only, SFTP and Samba off, an IDLE session, and a clean listener audit; the corrected signed durable Mini Plus gate remains pending and no additional Full or Warm deployment is authorized from this checkpoint" ] diff --git a/docs/validation/network/plus-net02-signed-recovery-20260826.json b/docs/validation/network/plus-net02-signed-recovery-20260826.json index ab9c20506..fe7ca3ae3 100644 --- a/docs/validation/network/plus-net02-signed-recovery-20260826.json +++ b/docs/validation/network/plus-net02-signed-recovery-20260826.json @@ -75,16 +75,16 @@ }, "corrected_candidate_validation": { "device_fixture_bloom_wifi": { - "bytes": 4678, - "sha256": "480b7ca00e0cd9d8676c5b858016532c0209d28f43680d1d4882c6149ee1c4fa" + "bytes": 7826, + "sha256": "22f28287284ce62fe4bc00be487c621f52c6324666ddc7f5c11d0e4ec346e176" }, "current_bloom_wifi": { - "bytes": 6678, - "sha256": "9215ea478f1218eab0b5bdbb66c2072dc3df1dcd23141a39a9338439ae8d03bb" + "bytes": 7826, + "sha256": "22f28287284ce62fe4bc00be487c621f52c6324666ddc7f5c11d0e4ec346e176" }, - "host_focused_shell_tests": "47/47 passed, including detached watcher reuse, stale-lock recovery, and the self-restored transaction regression", - "complete_shell_tests": "640/640 passed from an immutable read-only repository mount", - "device_fixture": "the exact current bloom-wifi bytes passed an isolated /tmp BusyBox ash fixture on the Mini Plus, called only mocked pkill/sleep/udhcpc/service commands, and cleaned its single detached watcher; the fixture was removed and installed version, live IPv4, route, listener, service preferences, listener audit, and session state were unchanged", + "host_focused_shell_tests": "48/48 passed, including detached watcher startup acknowledgement, launch-failure rejection, reuse, termination cleanup, stale-lock recovery, and the self-restored transaction regression", + "complete_shell_tests": "641/641 passed from an immutable read-only repository mount", + "device_fixture": "the exact current bloom-wifi bytes passed an isolated /tmp BusyBox ash fixture on the Mini Plus, called only mocked pkill/sleep/udhcpc/service commands, acknowledged and cleaned its single detached watcher, and removed every fixture path; installed version, live IPv4, route, listener, service preferences, listener audit, and session state were unchanged", "current_candidate_device_status": "executed only against isolated mocks; not installed, activated, or executed on the live network path" }, "warm_overlay_attempt": { diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index 84ce46b03..a0dbdd9da 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -52,13 +52,54 @@ ipv4_ready() { require_executable() { [ -x "$1" ]; } +watcher_is_live() { + watcher_pid=$1 + case "$watcher_pid" in + ''|*[!0-9]*) return 1 ;; + esac + kill -0 "$watcher_pid" 2>/dev/null || return 1 + watcher_command=$(tr '\000' ' ' <"$PROC_ROOT/$watcher_pid/cmdline" 2>/dev/null || true) + case "$watcher_command" in + *"$SELF watch"*) return 0 ;; + *) return 1 ;; + esac +} + +wait_for_started_watcher() { + watcher_child_pid=$1 + start_attempt=0 + while [ "$start_attempt" -lt 20 ]; do + if [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ]; then + watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) + watcher_is_live "$watcher_pid" && return 0 + fi + if ! kill -0 "$watcher_child_pid" 2>/dev/null; then + if wait "$watcher_child_pid"; then + return 0 + else + watcher_status=$? + fi + rm -f "$WATCH_DIR/pid" 2>/dev/null || true + rmdir "$WATCH_DIR" 2>/dev/null || true + return "$watcher_status" + fi + "$START_SLEEP" 0.05 || return 1 + start_attempt=$((start_attempt + 1)) + done + kill -TERM "$watcher_child_pid" 2>/dev/null || true + wait "$watcher_child_pid" 2>/dev/null || true + rm -f "$WATCH_DIR/pid" 2>/dev/null || true + rmdir "$WATCH_DIR" 2>/dev/null || true + return 1 +} + start_reconcile_watcher() { [ -x "$SERVICES" ] || return 0 [ -x "$NOHUP" ] && [ -x "$SELF" ] || return 1 if [ -e "$WATCH_DIR" ] || [ -L "$WATCH_DIR" ]; then [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 start_attempt=0 - while [ ! -f "$WATCH_DIR/pid" ] && [ "$start_attempt" -lt 10 ]; do + while [ ! -f "$WATCH_DIR/pid" ] && [ "$start_attempt" -lt 20 ]; do [ ! -L "$WATCH_DIR/pid" ] || return 1 "$START_SLEEP" 0.05 || return 1 start_attempt=$((start_attempt + 1)) @@ -66,19 +107,15 @@ start_reconcile_watcher() { if [ -e "$WATCH_DIR/pid" ] || [ -L "$WATCH_DIR/pid" ]; then [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 1 watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) - case "$watcher_pid" in - ''|*[!0-9]*) return 1 ;; - esac - watcher_command=$(tr '\000' ' ' <"$PROC_ROOT/$watcher_pid/cmdline" 2>/dev/null || true) - case "$watcher_command" in - *"$SELF watch"*) kill -0 "$watcher_pid" 2>/dev/null && return 0 ;; - esac + watcher_is_live "$watcher_pid" && return 0 fi rm -f "$WATCH_DIR/pid" || return 1 rmdir "$WATCH_DIR" || return 1 fi mkdir "$WATCH_DIR" 2>/dev/null || return 0 "$NOHUP" "$SELF" watch >/dev/null 2>&1 /dev/null || true)" != "$$" ] || rm -f "$WATCH_DIR/pid" rmdir "$WATCH_DIR" 2>/dev/null || true } - trap cleanup_watcher EXIT HUP INT TERM + trap cleanup_watcher EXIT + trap 'exit 0' HUP INT TERM pid_tmp="$WATCH_DIR/pid.$$" (umask 077; printf '%s\n' "$$" >"$pid_tmp") || return 1 mv -f "$pid_tmp" "$WATCH_DIR/pid" || return 1 diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index d6be92ed8..e992c1df5 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -99,10 +99,6 @@ SH run "$SERVICE" reconcile [ "$status" -eq 0 ] - for unused in 1 2 3 4 5; do - [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] && break - sleep 0.05 - done [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") kill -0 "$watcher_pid" @@ -110,7 +106,26 @@ SH [ "$status" -eq 0 ] [ "$(cat "$BLOOM_WIFI_WATCH_DIR/pid")" = "$watcher_pid" ] kill -TERM "$watcher_pid" - wait "$watcher_pid" 2>/dev/null || true + for unused in 1 2 3 4 5 6 7 8 9 10; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] +} + +@test "DHCP recovery fails closed when the detached watcher cannot start" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + export BLOOM_WIFI_NOHUP_BIN="$BATS_TEST_TMPDIR/nohup" + printf '#!/bin/sh\nexit 1\n' >"$BLOOM_WIFI_NOHUP_BIN" + chmod +x "$BLOOM_WIFI_NOHUP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"dhcp_restart_failed"'* ]] + grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" + ! grep -Fx 'services reconcile' "$WIFI_LOG" + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] } @test "a watcher lock without an owner is recovered on the next request" { From 1b35c5d3eb783074c4cc6dac103b872e7452b4c5 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 07:20:09 -0400 Subject: [PATCH 10/29] ci: parallelize fail-fast shell tests --- .github/workflows/shell-test.yml | 5 +- Makefile | 3 +- test/test_ci_scope.sh | 2 + tools/run-shell-tests.sh | 165 +++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) create mode 100755 tools/run-shell-tests.sh diff --git a/.github/workflows/shell-test.yml b/.github/workflows/shell-test.yml index a561b73a4..874322ed9 100644 --- a/.github/workflows/shell-test.yml +++ b/.github/workflows/shell-test.yml @@ -18,6 +18,7 @@ on: - 'tools/core_manifest.py' - 'tools/validate_core_inventory_disposition.py' - 'tools/provenance_policy.py' + - 'tools/run-shell-tests.sh' - 'tools/shell_test_inputs.py' - 'tools/legacy_manifest.py' - 'tools/validate_core_selection_policy.py' @@ -76,9 +77,9 @@ jobs: if [ "$targets" = full ]; then make test-shell elif [ "$targets" != none ]; then - docker run --rm --volume "$PWD:/workspace:ro" \ + ./tools/run-shell-tests.sh "$PWD" \ ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 \ - $targets + 1 $targets fi { echo '| Gate | Seconds | Scope |' diff --git a/Makefile b/Makefile index bd8043d73..92aa308b9 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,7 @@ endif TOOLCHAIN := aemiii91/miyoomini-toolchain@sha256:e5123590ad75d27f0f4c91196e3119a255cad45f3ae15243e29a8e0a2ec50132 SHELL_TEST_IMAGE := ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 +SHELL_TEST_JOBS ?= 8 include ./src/common/commands.mk @@ -371,7 +372,7 @@ format: @find ./src -regex '.*\.\(c\|h\|cpp\|hpp\)' -exec clang-format -style=file -i {} \; test-shell: - docker run --rm --volume "$(ROOT_DIR):/workspace:ro" $(SHELL_TEST_IMAGE) /workspace/test/shell + ./tools/run-shell-tests.sh "$(ROOT_DIR)" "$(SHELL_TEST_IMAGE)" "$(SHELL_TEST_JOBS)" rebuild-shell-test-image: rm -rf $(BUILD_TEST_DIR)/shell-test-inputs diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index 344b8c00d..5a2e99a74 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -121,6 +121,7 @@ tools/validate_save_migration_policy.py" expect "/workspace/test/shell/bloom_device.bats /workspace/test/shell/dev_deploy.bats" shell-tests "test/shell/bloom_device.bats test/shell/dev_deploy.bats" expect "full" shell-tests "static/build/.tmp_update/bin/bloom-dev-deploy" +expect "full" shell-tests "tools/run-shell-tests.sh" expect "none" shell-tests "docs/DEVELOPMENT.md" expect "3" risk "docs/DEVELOPMENT.md static/build/.tmp_update/runtime.sh" @@ -133,6 +134,7 @@ grep -F 'GTEST_INCLUDE_DIR=/usr/include make test' "$root/.github/workflows/test grep -F "sh tools/ci-scope.sh native-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh visual-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh shell-tests" "$root/.github/workflows/shell-test.yml" >/dev/null +grep -F './tools/run-shell-tests.sh "$PWD"' "$root/.github/workflows/shell-test.yml" >/dev/null grep -F "merge_group:" "$root/.github/workflows/test.yml" >/dev/null grep -F 'GTEST_FILTER="$filters"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'GITHUB_STEP_SUMMARY' "$root/.github/workflows/build.yml" >/dev/null diff --git a/tools/run-shell-tests.sh b/tools/run-shell-tests.sh new file mode 100755 index 000000000..4e5bf57b6 --- /dev/null +++ b/tools/run-shell-tests.sh @@ -0,0 +1,165 @@ +#!/bin/sh +set -eu + +[ "$#" -ge 3 ] || { + printf 'usage: run-shell-tests.sh ROOT IMAGE JOBS [TEST ...]\n' >&2 + exit 2 +} +ROOT=$1 +IMAGE=$2 +JOBS=$3 + +[ -n "$ROOT" ] && [ -d "$ROOT/test/shell" ] || { + printf 'shell-test: repository root is unavailable\n' >&2 + exit 2 +} +[ -n "$IMAGE" ] || { + printf 'shell-test: pinned test image is required\n' >&2 + exit 2 +} +case "$JOBS" in + ''|*[!0-9]*|0) printf 'shell-test: job count must be a positive integer\n' >&2; exit 2 ;; +esac +[ "$JOBS" -le 8 ] || { + printf 'shell-test: job count exceeds the bounded maximum of 8\n' >&2 + exit 2 +} +shift 3 + +python3 "$ROOT/tools/legacy_manifest.py" validate \ + --repository "$ROOT" \ + --manifest "$ROOT/build/legacy-manifest.json" +python3 "$ROOT/tools/provenance_policy.py" validate \ + --policy "$ROOT/build/provenance-policy.json" \ + --repository "$ROOT" \ + --require-files +python3 "$ROOT/tools/validate_transfer_policy.py" \ + --policy "$ROOT/build/transfer-policy.json" \ + --schema "$ROOT/build/transfer-policy.schema.json" +python3 - "$ROOT" <<'PY' +import json +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +manifest = json.loads((root / "build/legacy-manifest.json").read_text(encoding="utf-8")) +lock = (root / "build/dependencies.lock").read_text(encoding="utf-8") +components = manifest["components"] +expected = { + "component_count": len(components), + "file_count": sum(item["file_count"] for item in components), + "byte_count": sum(item["byte_count"] for item in components), + "source_build_components": sum(item["resolution"] == "source-build" for item in components), + "unresolved_components": sum( + item["resolution"] == "replace-source-build-or-exclude" for item in components + ), +} +for key, value in expected.items(): + match = re.search(r"^{} = (\d+)$".format(re.escape(key)), lock, re.MULTILINE) + if match is None or int(match.group(1)) != value: + raise SystemExit("dependency inventory mismatch: {} expected {}".format(key, value)) +print("dependency inventory valid: {} components, {} files, {} bytes".format( + expected["component_count"], expected["file_count"], expected["byte_count"] +)) +PY + +if [ "$#" -gt 0 ]; then + exec docker run --rm --name "bloomos-shell-$$-focused" \ + --volume "$ROOT:/workspace:ro" \ + "$IMAGE" "$@" +fi + +TEMP_BASE=${TMPDIR:-/tmp} +OUTPUT_DIR=$(mktemp -d "$TEMP_BASE/bloomos-shell-test.XXXXXX") +RUN_ID=$$ +CONTAINER_PREFIX=bloomos-shell-$RUN_ID +CONTAINERS= + +cleanup() { + for container in $CONTAINERS; do + docker rm -f "$container" >/dev/null 2>&1 || true + done + case "$OUTPUT_DIR" in + "$TEMP_BASE"/bloomos-shell-test.*) rm -rf -- "$OUTPUT_DIR" ;; + esac +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM + +find "$ROOT/test/shell" -maxdepth 1 -type f -name '*.bats' -print | + LC_ALL=C sort >"$OUTPUT_DIR/files" +[ -s "$OUTPUT_DIR/files" ] || { + printf 'shell-test: no Bats files found\n' >&2 + exit 1 +} + +python3 - "$OUTPUT_DIR" "$JOBS" <<'PY' +import pathlib +import re +import sys + +output = pathlib.Path(sys.argv[1]) +jobs = int(sys.argv[2]) +test_pattern = re.compile(r'^\s*@test\s+"((?:[^"\\]|\\.)*)"') +names = [] +for filename in output.joinpath("files").read_text(encoding="utf-8").splitlines(): + for line in pathlib.Path(filename).read_text(encoding="utf-8").splitlines(): + match = test_pattern.match(line) + if match: + names.append(match.group(1)) +if not names: + raise SystemExit("shell-test: no parseable Bats tests found") +if len(names) != len(set(names)): + raise SystemExit("shell-test: test names must be unique for exact parallel filters") +for name in names: + if any(other != name and other.endswith(name) for other in names): + raise SystemExit("shell-test: test names must have unique suffixes for exact parallel filters") +def ere_escape(name): + return "".join("\\" + char if char in r"\.^$|?*+()[]{}" else char for char in name) +for shard in range(jobs): + selected = names[shard::jobs] + expression = "({})$".format("|".join(ere_escape(name) for name in selected)) + output.joinpath("filter.{}".format(shard)).write_text(expression, encoding="utf-8") +output.joinpath("expected-count").write_text(str(len(names)), encoding="ascii") +print("shell-test inventory valid: {} unique tests across {} shards".format(len(names), jobs)) +PY + +PIDS= +shard=0 +while [ "$shard" -lt "$JOBS" ]; do + container="$CONTAINER_PREFIX-$shard" + CONTAINERS="$CONTAINERS $container" + ( + filter=$(cat "$OUTPUT_DIR/filter.$shard") + docker run --rm --name "$container" \ + --volume "$ROOT:/workspace:ro" \ + "$IMAGE" --filter "$filter" /workspace/test/shell + ) >"$OUTPUT_DIR/shard.$shard.log" 2>&1 & + PIDS="$PIDS $!" + shard=$((shard + 1)) +done + +STATUS=0 +for pid in $PIDS; do + if ! wait "$pid"; then + STATUS=1 + fi +done + +ACTUAL_COUNT=$(awk '/^ok [0-9]+ / { count += 1 } END { print count + 0 }' "$OUTPUT_DIR"/shard.*.log) +EXPECTED_COUNT=$(cat "$OUTPUT_DIR/expected-count") +if [ "$ACTUAL_COUNT" -ne "$EXPECTED_COUNT" ]; then + printf 'shell-test: expected %s passing tests but observed %s\n' \ + "$EXPECTED_COUNT" "$ACTUAL_COUNT" >&2 + STATUS=1 +fi + +shard=0 +while [ "$shard" -lt "$JOBS" ]; do + printf '\n# shell-test shard %s/%s\n' "$((shard + 1))" "$JOBS" + cat "$OUTPUT_DIR/shard.$shard.log" + shard=$((shard + 1)) +done +exit "$STATUS" From eb00a085a41fd3c3566497494341ca4057f3fc8d Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 07:33:30 -0400 Subject: [PATCH 11/29] fix(network): reconcile restored rollback failures --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- .../.tmp_update/bin/bloom-network-transaction | 25 ++++++++- test/shell/network_transaction.bats | 37 ++++++++++++++ tools/run-shell-tests.sh | 51 +++++++++++++++++-- 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 2a31003d5..200173740 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314627374 +byte_count = 314628396 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 313b6c8ce..e31f1a9c9 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 24233, - "tree_sha256": "4865b6fe6b44454cfa645ec3393e3c944c74ad044dbe9354389ed8898fae0e5a", + "byte_count": 25255, + "tree_sha256": "57356f5ac403e7f6444127ae2b23a1b4f9f3e15ef0085a789cf695a2cee59385", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 55de9a437..45f0176ce 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -143,6 +143,18 @@ publish_state() { sync } +archive_state() { + [ ! -L "$LAST" ] || fail "prior transaction evidence path is unsafe" + last_tmp="$LAST.tmp.$$" + cp -p "$STATE" "$last_tmp" || fail "cannot preserve prior transaction evidence" + "$JQ" -e . "$last_tmp" >/dev/null || { + rm -f "$last_tmp" + fail "prior transaction evidence is invalid" + } + mv -f "$last_tmp" "$LAST" || fail "cannot publish prior transaction evidence" + sync +} + valid_key() { [ -f "$AUTHORIZED_KEYS" ] && [ ! -L "$AUTHORIZED_KEYS" ] && [ -s "$AUTHORIZED_KEYS" ] || return 1 awk ' @@ -185,7 +197,7 @@ command_begin() { if current="$(read_state 2>/dev/null)"; then phase="$(printf '%s\n' "$current" | "$JQ" -er '.phase')" case "$phase" in armed|applying|applied|rolling_back) fail "another network transaction is active" ;; esac - cp -p "$STATE" "$LAST" || fail "cannot preserve prior transaction evidence" + archive_state elif [ -e "$STATE" ]; then fail "transaction state is invalid" fi @@ -390,6 +402,17 @@ command_rollback() { lock state="$(read_state)" || fail "transaction state is unavailable" phase="$(printf '%s\n' "$state" | "$JQ" -r '.phase')" + if [ "$phase" = rollback_failed ]; then + verify_prior_unchanged || fail "failed rollback baseline is not restored" + archive_state + close_lease || fail "cannot close developer recovery lease" + verify_prior_unchanged || fail "failed rollback baseline changed during reconciliation" + final="$(printf '%s\n' "$state" | "$JQ" -c '.phase="rolled_back"|.failure_reason="explicit"|.recovery_lease="inactive"')" + publish_state "$final" + rm -f "$WATCHDOG_READY" + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"rollback","restored":true,"reconciled":true}' + return + fi case "$phase" in armed|applying|applied) ;; *) fail "transaction is not rollback eligible" ;; esac rollback_locked explicit || fail "network rollback failed" printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"rollback","restored":true}' diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index 1dbdea5a9..16ec2a547 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -312,3 +312,40 @@ EOF ! grep -Fq 'services request' "$TRANSACTION_LOG" ! grep -Fq 'network request' "$TRANSACTION_LOG" } + +@test "failed rollback reconciles only after the exact baseline returns" { + begin_transaction + jq '.phase="rollback_failed"|.operation="network-overlay"|.network_overlay="network-test"|.failure_reason="restore_failed"|.recovery_lease="inactive"' \ + "$TX_ROOT/state.json" >"$TX_ROOT/state.tmp" + mv "$TX_ROOT/state.tmp" "$TX_ROOT/state.json" + + run env DEV_OVERLAY_ACTIVE=0 SERVICES_FAIL_REQUEST=1 "$SERVICE" rollback + [ "$status" -eq 0 ] + [[ "$output" == *'"reconciled":true'* ]] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + [ "$(jq -r .phase "$TX_ROOT/last.json")" = rollback_failed ] + [ "$(jq -r .failure_reason "$TX_ROOT/last.json")" = restore_failed ] + ! grep -Fq 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" +} + +@test "failed rollback reconciliation refuses a changed baseline" { + begin_transaction + jq '.phase="rollback_failed"|.operation="network-overlay"|.network_overlay="network-test"|.failure_reason="restore_failed"|.recovery_lease="inactive"' \ + "$TX_ROOT/state.json" >"$TX_ROOT/state.tmp" + mv "$TX_ROOT/state.tmp" "$TX_ROOT/state.json" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' +EOF + chmod +x "$BLOOM_NETWORK_BIN" + + run "$SERVICE" rollback + [ "$status" -ne 0 ] + [[ "$output" == *'baseline is not restored'* ]] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rollback_failed ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] + [ ! -e "$TX_ROOT/last.json" ] +} diff --git a/tools/run-shell-tests.sh b/tools/run-shell-tests.sh index 4e5bf57b6..2afdaa5df 100755 --- a/tools/run-shell-tests.sh +++ b/tools/run-shell-tests.sh @@ -29,10 +29,53 @@ shift 3 python3 "$ROOT/tools/legacy_manifest.py" validate \ --repository "$ROOT" \ --manifest "$ROOT/build/legacy-manifest.json" -python3 "$ROOT/tools/provenance_policy.py" validate \ - --policy "$ROOT/build/provenance-policy.json" \ - --repository "$ROOT" \ - --require-files +PROVENANCE_MODE=$(python3 - "$ROOT" <<'PY' +import configparser +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +policy = json.loads((root / "build/provenance-policy.json").read_text(encoding="utf-8")) +missing = [] +for component in policy["components"]: + for relative in component["paths"]: + if not (root / relative).exists(): + missing.append(relative) + for field in ("license_file", "inventory", "build_recipe"): + relative = component.get(field) + if relative and not (root / relative).is_file(): + missing.append(relative) +if not missing: + print("complete") + raise SystemExit +config = configparser.ConfigParser() +config.read(root / ".gitmodules", encoding="utf-8") +uninitialized = [] +for section in config.sections(): + relative = config.get(section, "path", fallback="") + if relative and not (root / relative / ".git").exists(): + uninitialized.append(relative.rstrip("/")) +if uninitialized and all( + any(relative == submodule or relative.startswith(submodule + "/") for submodule in uninitialized) + for relative in missing +): + print("deferred") +else: + print("complete") +PY +) +if [ "$PROVENANCE_MODE" = deferred ]; then + python3 "$ROOT/tools/provenance_policy.py" validate \ + --policy "$ROOT/build/provenance-policy.json" \ + --repository "$ROOT" + printf '%s\n' 'provenance file closure deferred: uninitialized submodules' +else + python3 "$ROOT/tools/provenance_policy.py" validate \ + --policy "$ROOT/build/provenance-policy.json" \ + --repository "$ROOT" \ + --require-files +fi python3 "$ROOT/tools/validate_transfer_policy.py" \ --policy "$ROOT/build/transfer-policy.json" \ --schema "$ROOT/build/transfer-policy.schema.json" From 632da91d7b39e6dba569b60149bed47a53989381 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 08:06:58 -0400 Subject: [PATCH 12/29] ci: parallelize exhaustive validation --- .github/workflows/test.yml | 18 ++++++++++-------- Makefile | 9 +++++++-- docs/CI_SCOPING.md | 9 +++++++++ test/test_ci_scope.sh | 9 ++++++++- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89229edd1..e8edd6987 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,13 +77,15 @@ jobs: started=$SECONDS targets='${{ steps.scope.outputs.targets }}' if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$targets" = full ]; then - make static-analysis + make static-analysis CPPCHECK_JOBS="$(nproc)" targets=full elif [ "$targets" != none ]; then - make external-libs # Native, ASAN, and ARM builds cover the real configurations. Bound - # speculative preprocessor expansion for the affected PR components. - cppcheck --max-configs=1 -I include --enable=all $targets + # speculative preprocessor expansion for the affected PR components; + # unused-function analysis needs the full source closure below. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction" \ + STATIC_ANALYSIS_TARGETS="$targets" fi echo "| Static analysis | $((SECONDS - started)) | $targets |" >> "$GITHUB_STEP_SUMMARY" - name: Resolve affected test risk @@ -103,10 +105,10 @@ jobs: started=$SECONDS filters='${{ steps.tests.outputs.filters }}' if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$filters" = full ]; then - GTEST_INCLUDE_DIR=/usr/include make test + GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test filters=full elif [ "$filters" != none ]; then - GTEST_INCLUDE_DIR=/usr/include make test GTEST_FILTER="$filters" + GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test GTEST_FILTER="$filters" fi echo "| Native | $((SECONDS - started)) | risk ${{ steps.tests.outputs.risk || '3' }}, $filters; visuals ${{ steps.tests.outputs.visuals || 'full' }} |" >> "$GITHUB_STEP_SUMMARY" - name: Test with AddressSanitizer @@ -117,9 +119,9 @@ jobs: started=$SECONDS filters='${{ steps.tests.outputs.filters }}' if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$filters" = full ]; then - GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make test + GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make -j"$(nproc)" test filters=full elif [ "$filters" != none ]; then - GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make test GTEST_FILTER="$filters" + GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make -j"$(nproc)" test GTEST_FILTER="$filters" fi echo "| ASan | $((SECONDS - started)) | risk ${{ steps.tests.outputs.risk || '3' }}, $filters |" >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index 92aa308b9..7d42369c2 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,10 @@ endif TOOLCHAIN := aemiii91/miyoomini-toolchain@sha256:e5123590ad75d27f0f4c91196e3119a255cad45f3ae15243e29a8e0a2ec50132 SHELL_TEST_IMAGE := ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 SHELL_TEST_JOBS ?= 8 +CPPCHECK_JOBS ?= 8 +CPPCHECK_BUILD_DIR ?= $(BUILD_TEST_DIR)/cppcheck +CPPCHECK_CONFIG_FLAGS ?= +STATIC_ANALYSIS_TARGETS ?= $(SRC_DIR) include ./src/common/commands.mk @@ -365,8 +369,9 @@ test: external-libs @cp -R $(TEST_SRC_DIR)/infoPanel_test_data $(BUILD_TEST_DIR)/ cd $(BUILD_TEST_DIR) && LD_LIBRARY_PATH=$(ROOT_DIR)/lib/ ./test $(if $(GTEST_FILTER),--gtest_filter=$(GTEST_FILTER),) -static-analysis: external-libs - @cd $(ROOT_DIR) && cppcheck -I $(INCLUDE_DIR) --enable=all $(SRC_DIR) +static-analysis: + @mkdir -p $(CPPCHECK_BUILD_DIR) + @cd $(ROOT_DIR) && cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS) $(CPPCHECK_CONFIG_FLAGS) -I $(INCLUDE_DIR) --enable=all $(STATIC_ANALYSIS_TARGETS) format: @find ./src -regex '.*\.\(c\|h\|cpp\|hpp\)' -exec clang-format -style=file -i {} \; diff --git a/docs/CI_SCOPING.md b/docs/CI_SCOPING.md index c2e4fac6b..2dcb35710 100644 --- a/docs/CI_SCOPING.md +++ b/docs/CI_SCOPING.md @@ -34,6 +34,15 @@ ASan, static-analysis, shell, and ARM build paths. This is the cross-PR integration boundary. Required CI never depends on a personal handheld or live network/provider. +Cppcheck keeps exhaustive configuration analysis for merge-group and full-scope +gates. Pull requests with a narrower affected-component set use one bounded +preprocessor configuration for those components. Both modes use cppcheck's +build directory while analysis runs in parallel. Whole-program checks remain in +the full-source gate; the affected-component gate suppresses unused-function +reports that cannot be proven without the omitted source closure. Native and +ASan compilation also use the hosted runner's available cores; the test +selection and assertions themselves are unchanged. + Each required workflow writes gate duration, selected risk, and selected scope to the GitHub step summary. Collect those records before setting p50/p95 budgets; no maintenance-heavy cache or test container should be introduced diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index 5a2e99a74..e203285d8 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -130,7 +130,14 @@ grep -F 'git config --global --add safe.directory "$GITHUB_WORKSPACE"' \ "$root/.github/workflows/build.yml" >/dev/null grep -F 'tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos third-party/zlib' \ "$root/.github/workflows/test.yml" >/dev/null -grep -F 'GTEST_INCLUDE_DIR=/usr/include make test' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'make static-analysis CPPCHECK_JOBS="$(nproc)"' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction"' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS)' "$root/Makefile" >/dev/null +if grep -E '^static-analysis:.*external-libs' "$root/Makefile" >/dev/null; then + echo "static-analysis must not build unrelated external libraries" >&2 + exit 1 +fi grep -F "sh tools/ci-scope.sh native-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh visual-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh shell-tests" "$root/.github/workflows/shell-test.yml" >/dev/null From a90748a428552847491c93d7c0411869c1e4f622 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 08:21:14 -0400 Subject: [PATCH 13/29] ci: reserve exhaustive cppcheck for merge queue --- .github/workflows/test.yml | 7 ++++++- docs/CI_SCOPING.md | 15 ++++++++------- test/test_ci_scope.sh | 1 + 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8edd6987..fe3c19706 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -76,9 +76,14 @@ jobs: run: | started=$SECONDS targets='${{ steps.scope.outputs.targets }}' - if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$targets" = full ]; then + if [ '${{ github.event_name }}' != 'pull_request' ]; then make static-analysis CPPCHECK_JOBS="$(nproc)" targets=full + elif [ "$targets" = full ]; then + # PR feedback keeps the complete source closure but bounds speculative + # configuration expansion. Merge-group runs retain the exhaustive gate. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS=--max-configs=1 elif [ "$targets" != none ]; then # Native, ASAN, and ARM builds cover the real configurations. Bound # speculative preprocessor expansion for the affected PR components; diff --git a/docs/CI_SCOPING.md b/docs/CI_SCOPING.md index 2dcb35710..3e8d01559 100644 --- a/docs/CI_SCOPING.md +++ b/docs/CI_SCOPING.md @@ -34,13 +34,14 @@ ASan, static-analysis, shell, and ARM build paths. This is the cross-PR integration boundary. Required CI never depends on a personal handheld or live network/provider. -Cppcheck keeps exhaustive configuration analysis for merge-group and full-scope -gates. Pull requests with a narrower affected-component set use one bounded -preprocessor configuration for those components. Both modes use cppcheck's -build directory while analysis runs in parallel. Whole-program checks remain in -the full-source gate; the affected-component gate suppresses unused-function -reports that cannot be proven without the omitted source closure. Native and -ASan compilation also use the hosted runner's available cores; the test +Cppcheck keeps the full-source default configuration exploration for +merge-group gates. Pull-request feedback uses one bounded preprocessor +configuration, over either the complete source closure for a full scope or the +affected components for a narrower scope. All modes use cppcheck's build +directory while analysis runs in parallel. Whole-program checks remain when the +full source closure is present; the affected-component gate suppresses +unused-function reports that cannot be proven without omitted sources. Native +and ASan compilation also use the hosted runner's available cores; the test selection and assertions themselves are unchanged. Each required workflow writes gate duration, selected risk, and selected scope diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index e203285d8..0c9086b9d 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -132,6 +132,7 @@ grep -F 'tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos t "$root/.github/workflows/test.yml" >/dev/null grep -F 'GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test' "$root/.github/workflows/test.yml" >/dev/null grep -F 'make static-analysis CPPCHECK_JOBS="$(nproc)"' "$root/.github/workflows/test.yml" >/dev/null +grep -F "if [ '\${{ github.event_name }}' != 'pull_request' ]; then" "$root/.github/workflows/test.yml" >/dev/null grep -F 'CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS)' "$root/Makefile" >/dev/null if grep -E '^static-analysis:.*external-libs' "$root/Makefile" >/dev/null; then From 0b5c70687744d2c96882c74cf8857b138336012a Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 08:33:28 -0400 Subject: [PATCH 14/29] ci: expose exact-head exhaustive gate --- .github/workflows/test.yml | 1 + docs/CI_SCOPING.md | 5 +++++ test/test_ci_scope.sh | 1 + 3 files changed, 7 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe3c19706..48c8b1b8d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,7 @@ name: Test build on: merge_group: + workflow_dispatch: pull_request: paths: - '.github/workflows/test.yml' diff --git a/docs/CI_SCOPING.md b/docs/CI_SCOPING.md index 3e8d01559..f95432c2a 100644 --- a/docs/CI_SCOPING.md +++ b/docs/CI_SCOPING.md @@ -34,6 +34,11 @@ ASan, static-analysis, shell, and ARM build paths. This is the cross-PR integration boundary. Required CI never depends on a personal handheld or live network/provider. +When the repository does not provide a protected merge queue, dispatch the +`Test build` workflow manually against the exact candidate head before merge. +The dispatch follows the same full-source path as `merge_group`; a green bounded +pull-request run is not a substitute for that exact-head integration gate. + Cppcheck keeps the full-source default configuration exploration for merge-group gates. Pull-request feedback uses one bounded preprocessor configuration, over either the complete source closure for a full scope or the diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index 0c9086b9d..ea8a6b340 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -132,6 +132,7 @@ grep -F 'tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos t "$root/.github/workflows/test.yml" >/dev/null grep -F 'GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test' "$root/.github/workflows/test.yml" >/dev/null grep -F 'make static-analysis CPPCHECK_JOBS="$(nproc)"' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'workflow_dispatch:' "$root/.github/workflows/test.yml" >/dev/null grep -F "if [ '\${{ github.event_name }}' != 'pull_request' ]; then" "$root/.github/workflows/test.yml" >/dev/null grep -F 'CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS)' "$root/Makefile" >/dev/null From 6f3ce2b9aab0c9a40ee455e0b77249d42dad71c2 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 12:39:23 -0400 Subject: [PATCH 15/29] fix(network): make Wi-Fi recovery converge safely --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 12 ++--- src/bloomSettings/bloom_settings.c | 5 +- static/build/.tmp_update/bin/bloom-network | 18 +++++-- .../.tmp_update/bin/bloom-network-transaction | 16 +++++- static/build/.tmp_update/bin/bloom-wifi | 42 +++++++++++++-- test/shell/network_status.bats | 10 ++++ test/shell/network_transaction.bats | 35 +++++++++++++ test/shell/wifi_backend.bats | 51 +++++++++++++++++++ test/test_bloom_settings.cpp | 17 +++++++ 10 files changed, 190 insertions(+), 18 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 200173740..f790768ed 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314628396 +byte_count = 314630246 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index e31f1a9c9..8798cfa6f 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 6016, - "tree_sha256": "7cc3f913ae2e877399f6c63a8738ab246aaf2372cc7e8bbcc71280eeac8702a2", + "byte_count": 6282, + "tree_sha256": "6486eee568949804c5812c1a895a16d8ab04966a8f5311a9613e38d703aca467", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 25255, - "tree_sha256": "57356f5ac403e7f6444127ae2b23a1b4f9f3e15ef0085a789cf695a2cee59385", + "byte_count": 25714, + "tree_sha256": "223ba42fb5c3e5d8c41e51b13e4042db1d6bc083c4b58368b5ae000f300f3d50", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -982,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 7826, - "tree_sha256": "b1eb6876a865a95ae9f590339c0b20cef2443a732fee29c1b1b9474254252b6f", + "byte_count": 8951, + "tree_sha256": "f96c2d31f1657ff470327afb67e0c5fa6c4372ad556a03bad6d9aaa98ff9d0cd", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/src/bloomSettings/bloom_settings.c b/src/bloomSettings/bloom_settings.c index 02be60723..60e4468e8 100644 --- a/src/bloomSettings/bloom_settings.c +++ b/src/bloomSettings/bloom_settings.c @@ -1665,10 +1665,13 @@ int bloom_settings_set(const char *settings_path, const char *onion_system_path, } else if (field_values_equal(current, replacement, policy->type)) { result->generation = current_values.generation; - result->materialized = 1; cJSON_Delete(replacement); cJSON_Delete(root); release_settings_lock(lock); + if (bloom_settings_materialize_onion(settings_path, onion_system_path, + onion_config_root, error, error_size) != 0) + return -1; + result->materialized = 1; return 0; } else if (!cJSON_ReplaceItemInObjectCaseSensitive(target, policy->key, replacement)) { diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index ba2860bbf..6b435ed05 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -112,9 +112,15 @@ set_preference() { preference_result "$operation" false false settings_rejected return 1 } + if [ "$desired" = false ]; then + reconcile_services || { + preference_result "$operation" true false remote_services_failed + return 1 + } + fi [ -x "$BACKEND" ] || { preference_result "$operation" true false backend_unavailable; return 1; } if "$BACKEND" reconcile >/dev/null 2>&1; then - if [ -x "$COMPAT" ]; then + if [ "$desired" = true ] && [ -x "$COMPAT" ]; then "$COMPAT" services >/dev/null 2>&1 || { preference_result "$operation" true false compatibility_failed return 1 @@ -127,10 +133,12 @@ set_preference() { return 1 fi fi - reconcile_services || { - preference_result "$operation" true false remote_services_failed - return 1 - } + if [ "$desired" = true ]; then + reconcile_services || { + preference_result "$operation" true false remote_services_failed + return 1 + } + fi preference_result "$operation" true true applied } diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 45f0176ce..4f327b884 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -305,6 +305,19 @@ verify_prior_unchanged() { [ ! -x "$LISTENER_AUDIT" ] || "$LISTENER_AUDIT" >/dev/null 2>&1 || return 1 } +wait_for_prior_unchanged() { + wait_limit=$1 + case "$wait_limit" in ''|*[!0-9]*|0) return 1 ;; esac + wait_attempt=0 + while [ "$wait_attempt" -lt "$wait_limit" ]; do + verify_prior_unchanged && return 0 + wait_attempt=$((wait_attempt + 1)) + [ "$wait_attempt" -lt "$wait_limit" ] || break + "$SLEEP" 1 || return 1 + done + return 1 +} + rollback_locked() { reason=$1 state="$(read_state)" || fail "transaction state is unavailable" @@ -326,10 +339,11 @@ rollback_locked() { fi if [ "$operation" = ssh-shadow ]; then verify_prior_unchanged || restored=false - elif [ "$operation" = network-overlay ] && verify_prior_unchanged; then + elif [ "$operation" = network-overlay ] && wait_for_prior_unchanged 15; then : else restore_prior || restored=false + [ "$restored" != true ] || wait_for_prior_unchanged 60 || restored=false fi if [ "$restored" = true ]; then final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rolled_back"|.recovery_lease="inactive"')" diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index a0dbdd9da..b2861f6ab 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -52,6 +52,16 @@ ipv4_ready() { require_executable() { [ -x "$1" ]; } +launch_dhcp() { + "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & +} + +restart_dhcp_client() { + "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true + "$SLEEP" 1 || return 1 + launch_dhcp +} + watcher_is_live() { watcher_pid=$1 case "$watcher_pid" in @@ -118,6 +128,28 @@ start_reconcile_watcher() { wait_for_started_watcher "$watcher_child_pid" } +stop_reconcile_watcher() { + [ -e "$WATCH_DIR" ] || [ -L "$WATCH_DIR" ] || return 0 + [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 + if [ -e "$WATCH_DIR/pid" ] || [ -L "$WATCH_DIR/pid" ]; then + [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 1 + watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) + if watcher_is_live "$watcher_pid"; then + kill -TERM "$watcher_pid" 2>/dev/null || return 1 + stop_attempt=0 + while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 20 ]; do + "$START_SLEEP" 0.05 || return 1 + stop_attempt=$((stop_attempt + 1)) + done + kill -0 "$watcher_pid" 2>/dev/null && return 1 + elif kill -0 "$watcher_pid" 2>/dev/null; then + return 1 + fi + rm -f "$WATCH_DIR/pid" || return 1 + fi + rmdir "$WATCH_DIR" 2>/dev/null || [ ! -e "$WATCH_DIR" ] +} + reconcile_watcher() { [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 [ ! -e "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 0 @@ -137,6 +169,9 @@ reconcile_watcher() { "$SERVICES" reconcile >/dev/null 2>&1 || true return 0 fi + case "$attempt" in + 20|40) restart_dhcp_client || return 0 ;; + esac attempt=$((attempt + 1)) "$SLEEP" 1 || return 0 done @@ -155,7 +190,7 @@ start_wifi() { "$SLEEP" 2 || return 1 "$IFCONFIG" "$INTERFACE" up >/dev/null 2>&1 || return 1 "$WPA" -B -D nl80211 "-i$INTERFACE" -c "$WPA_CONFIG" >/dev/null 2>&1 || return 1 - "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & + launch_dhcp "$IW" dev "$INTERFACE" set power_save off >/dev/null 2>&1 || return 1 start_reconcile_watcher } @@ -166,13 +201,12 @@ restart_dhcp() { done [ ! -L "$UDHCPC_SCRIPT" ] || return 1 unset LD_PRELOAD - "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true - "$SLEEP" 1 || return 1 - "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & + restart_dhcp_client || return 1 start_reconcile_watcher } stop_wifi() { + stop_reconcile_watcher || return 1 if require_executable "$PKILL"; then "$PKILL" -TERM wpa_supplicant >/dev/null 2>&1 || true "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index 3c219917d..b3a0cb621 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -15,6 +15,7 @@ setup() { export COMPAT_LOG="$BATS_TEST_TMPDIR/compat.log" export SETTINGS_LOG="$BATS_TEST_TMPDIR/settings.log" export SERVICES_LOG="$BATS_TEST_TMPDIR/services.log" + export APPLY_LOG="$BATS_TEST_TMPDIR/apply.log" mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" printf up >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/carrier" @@ -24,18 +25,22 @@ setup() { cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$BACKEND_LOG" +printf 'backend\n' >>"$APPLY_LOG" SH cat >"$BLOOM_NETWORK_COMPAT_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$COMPAT_LOG" +printf 'compat\n' >>"$APPLY_LOG" SH cat >"$BLOOM_SETTINGS_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$SETTINGS_LOG" +printf 'settings\n' >>"$APPLY_LOG" SH cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$SERVICES_LOG" +printf 'services\n' >>"$APPLY_LOG" SH cat >"$BLOOM_NETWORK_IP_BIN" <<'SH' #!/bin/sh @@ -156,12 +161,17 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] [ "$(cat "$COMPAT_LOG")" = services ] [ "$(cat "$SERVICES_LOG")" = reconcile ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend compat services ' ] + rm -f "$COMPAT_LOG" "$SERVICES_LOG" "$APPLY_LOG" run "$SERVICE" request disable [ "$status" -eq 0 ] [[ "$output" == *'"operation":"disable"'* ]] [ "$(cat "$SETTINGS_LOG")" = 'set device.wifi_enabled false' ] [ "$(cat "$BACKEND_LOG")" = reconcile ] + [ ! -e "$COMPAT_LOG" ] + [ "$(cat "$SERVICES_LOG")" = reconcile ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings services backend ' ] } @test "preference mutation reports settings and apply failures separately" { diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index 16ec2a547..68f0ada31 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -298,6 +298,41 @@ EOF ! grep -Fq 'network request' "$TRANSACTION_LOG" } +@test "network overlay rollback waits for bounded DHCP convergence before replaying prior state" { + begin_transaction + run "$SERVICE" apply 0123456789abcdef0123456789abcdef network-overlay network-test + [ "$status" -eq 0 ] + status_count="$BATS_TEST_TMPDIR/network-status-count" + printf '%s\n' 0 >"$status_count" + export NETWORK_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$NETWORK_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$NETWORK_STATUS_COUNT" + if [ "$count" -lt 3 ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' + else + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + fi + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(cat "$status_count")" -ge 3 ] + grep -F 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" +} + @test "self-restored network overlay closes rollback without replaying prior services" { begin_transaction run "$SERVICE" apply 0123456789abcdef0123456789abcdef network-overlay network-test diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index e992c1df5..7e8818457 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -113,6 +113,29 @@ SH [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] } +@test "address recovery retries only the fixed DHCP client twice before its bounded deadline" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +/bin/sleep 0.01 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"dhcp_restarted"'* ]] + for unused in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + [ "$(grep -Fc "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG")" -eq 3 ] + [ "$(grep -Fc 'pkill -TERM udhcpc' "$WIFI_LOG")" -eq 3 ] + ! grep -Fx 'services reconcile' "$WIFI_LOG" +} + @test "DHCP recovery fails closed when the detached watcher cannot start" { printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" @@ -175,6 +198,34 @@ SH grep -Fx 'axp_test wifioff' "$WIFI_LOG" } +@test "disabling Wi-Fi stops an in-flight address recovery watcher before radio teardown" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +/bin/sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_WIFI_JSONVAL_BIN" + chmod +x "$BLOOM_WIFI_JSONVAL_BIN" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"stopped"'* ]] + ! kill -0 "$watcher_pid" 2>/dev/null + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + grep -Fx 'pkill -TERM wpa_supplicant' "$WIFI_LOG" + grep -Fx 'pkill -TERM udhcpc' "$WIFI_LOG" + grep -Fx 'axp_test wifioff' "$WIFI_LOG" +} + @test "unsupported model requests compatibility fallback without mutations" { printf '#!/bin/sh\nprintf mini_flip\n' >"$BLOOM_PLATFORM_BIN" chmod +x "$BLOOM_PLATFORM_BIN" diff --git a/test/test_bloom_settings.cpp b/test/test_bloom_settings.cpp index c8995952b..453576519 100644 --- a/test/test_bloom_settings.cpp +++ b/test/test_bloom_settings.cpp @@ -945,6 +945,23 @@ TEST_F(BloomSettingsTest, MaterializationFailureKeepsCanonicalMutationAuthoritat std::string outside_content((std::istreambuf_iterator(outside_stream)), std::istreambuf_iterator()); EXPECT_EQ("outside", outside_content); + + std::filesystem::remove(config / "keymap.json"); + BloomSettingsMutationResult retry{}; + ASSERT_EQ(0, bloom_settings_set(settings.c_str(), system.c_str(), config.c_str(), + "device.volume", "18", &retry, error, sizeof(error))) + << error; + EXPECT_EQ(0, retry.changed); + EXPECT_EQ(result.generation, retry.generation); + EXPECT_EQ(1, retry.materialized); + std::ifstream materialized_stream(system, std::ios::binary); + std::string materialized_content((std::istreambuf_iterator(materialized_stream)), + std::istreambuf_iterator()); + cJSON *materialized_system = + cJSON_ParseWithLength(materialized_content.c_str(), materialized_content.size()); + ASSERT_TRUE(cJSON_IsObject(materialized_system)); + EXPECT_EQ(18, cJSON_GetObjectItem(materialized_system, "vol")->valueint); + cJSON_Delete(materialized_system); } TEST_F(BloomSettingsTest, ResetDefaultsBacksUpCanonicalStateAndMaterializesDefaults) From e6399217e7af031632cffeaa2cde68dbe31cf33f Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 13:50:55 -0400 Subject: [PATCH 16/29] fix(network): recover Wi-Fi toggles after partial apply --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- src/bloomShell/bloom_shell_settings.c | 78 ++++++++++++++++++++++++- static/build/.tmp_update/bin/bloom-wifi | 13 ++++- test/shell/wifi_backend.bats | 20 +++++++ test/test_bloom_shell_settings.cpp | 28 +++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index f790768ed..1ca815a2d 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314630246 +byte_count = 314630696 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 8798cfa6f..88710a6b1 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -982,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 8951, - "tree_sha256": "f96c2d31f1657ff470327afb67e0c5fa6c4372ad556a03bad6d9aaa98ff9d0cd", + "byte_count": 9401, + "tree_sha256": "d7dd29d8ae55b7d5f2e8b2febf1bfc318dd60f389e7bab0fe59da12e14f3d7ab", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/src/bloomShell/bloom_shell_settings.c b/src/bloomShell/bloom_shell_settings.c index 702f98bdc..2c1cbe078 100644 --- a/src/bloomShell/bloom_shell_settings.c +++ b/src/bloomShell/bloom_shell_settings.c @@ -715,6 +715,78 @@ static int run_request(const char *path, const char *first, const char *second, return wait_child(child); } +static int run_network_request(const char *path, const char *operation) +{ + if (path == NULL || path[0] != '/' || operation == NULL) + return -1; + pid_t child = fork(); + if (child < 0) + return -1; + if (child == 0) { + if (setpgid(0, 0) != 0) + _exit(127); + FILE *null = fopen("/dev/null", "w"); + if (null != NULL) { + dup2(fileno(null), STDOUT_FILENO); + dup2(fileno(null), STDERR_FILENO); + } + execl(path, path, "request", operation, (char *)NULL); + _exit(127); + } + if (setpgid(child, child) != 0 && errno != EACCES && errno != ESRCH) { + kill(child, SIGKILL); + wait_child(child); + return -1; + } + int status = 0; + for (int attempt = 0; attempt < 100; ++attempt) { + pid_t result = waitpid(child, &status, WNOHANG); + if (result == child) + return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : -1; + if (result < 0 && errno != EINTR) + return -1; + poll(NULL, 0, 50); + } + kill(-child, SIGTERM); + kill(child, SIGTERM); + for (int attempt = 0; attempt < 10; ++attempt) { + pid_t result = waitpid(child, &status, WNOHANG); + if (result == child) { + kill(-child, SIGKILL); + return -1; + } + if (result < 0 && errno != EINTR) + return -1; + poll(NULL, 0, 50); + } + kill(-child, SIGKILL); + kill(child, SIGKILL); + while (waitpid(child, &status, 0) < 0) + if (errno != EINTR) + break; + return -1; +} + +static int load_network_preference(const char *path, int *enabled) +{ + if (enabled == NULL) + return -1; + char json[QUICK_OUTPUT_MAX + 1]; + if (load_output(path, "status", NULL, json, sizeof(json)) != 0) + return -1; + cJSON *root = cJSON_Parse(json); + const cJSON *schema = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "schema"); + const cJSON *service = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "service"); + const cJSON *wifi = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "wifi_enabled"); + int valid = cJSON_IsNumber(schema) && schema->valueint == 1 && cJSON_IsString(service) && + service->valuestring != NULL && strcmp(service->valuestring, "bloom-network") == 0 && + cJSON_IsBool(wifi); + if (valid) + *enabled = cJSON_IsTrue(wifi); + cJSON_Delete(root); + return valid ? 0 : -1; +} + void bloom_shell_network_credential_clear(BloomShellNetworkCredential *credential) { if (credential == NULL) @@ -912,8 +984,12 @@ int bloom_shell_quick_settings_adjust(const BloomShellCapabilities *capabilities int next = direction > 0; if (next == values->wifi_enabled) return 0; - if (run_request(network_path, "request", next ? "enable" : "disable", NULL) != 0) + if (run_network_request(network_path, next ? "enable" : "disable") != 0) { + int persisted = values->wifi_enabled; + if (load_network_preference(network_path, &persisted) == 0) + values->wifi_enabled = persisted; return -1; + } values->wifi_enabled = next; return 0; } diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index b2861f6ab..820fa1f39 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -137,11 +137,20 @@ stop_reconcile_watcher() { if watcher_is_live "$watcher_pid"; then kill -TERM "$watcher_pid" 2>/dev/null || return 1 stop_attempt=0 - while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 20 ]; do + while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 40 ]; do "$START_SLEEP" 0.05 || return 1 stop_attempt=$((stop_attempt + 1)) done - kill -0 "$watcher_pid" 2>/dev/null && return 1 + if kill -0 "$watcher_pid" 2>/dev/null; then + watcher_is_live "$watcher_pid" || return 1 + kill -KILL "$watcher_pid" 2>/dev/null || return 1 + stop_attempt=0 + while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 20 ]; do + "$START_SLEEP" 0.05 || return 1 + stop_attempt=$((stop_attempt + 1)) + done + kill -0 "$watcher_pid" 2>/dev/null && return 1 + fi elif kill -0 "$watcher_pid" 2>/dev/null; then return 1 fi diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index 7e8818457..dca55bc5e 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -226,6 +226,26 @@ SH grep -Fx 'axp_test wifioff' "$WIFI_LOG" } +@test "disabling Wi-Fi force-stops only a verified watcher that ignores TERM" { + mkdir "$BLOOM_WIFI_WATCH_DIR" + sh -c 'trap "" TERM; while :; do sleep 1; done' "$SERVICE watch" & + watcher_pid=$! + printf '%s\n' "$watcher_pid" >"$BLOOM_WIFI_WATCH_DIR/pid" + export BLOOM_WIFI_SELF="$SERVICE" + export BLOOM_WIFI_PROC_ROOT="$BATS_TEST_TMPDIR/proc" + mkdir -p "$BLOOM_WIFI_PROC_ROOT/$watcher_pid" + printf '%s\000%s\000' "$SERVICE" watch >"$BLOOM_WIFI_PROC_ROOT/$watcher_pid/cmdline" + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_WIFI_JSONVAL_BIN" + chmod +x "$BLOOM_WIFI_JSONVAL_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"stopped"'* ]] + ! kill -0 "$watcher_pid" 2>/dev/null + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + grep -Fx 'axp_test wifioff' "$WIFI_LOG" +} + @test "unsupported model requests compatibility fallback without mutations" { printf '#!/bin/sh\nprintf mini_flip\n' >"$BLOOM_PLATFORM_BIN" chmod +x "$BLOOM_PLATFORM_BIN" diff --git a/test/test_bloom_shell_settings.cpp b/test/test_bloom_shell_settings.cpp index 1d1620d5f..0ca1215df 100644 --- a/test/test_bloom_shell_settings.cpp +++ b/test/test_bloom_shell_settings.cpp @@ -361,6 +361,34 @@ TEST(BloomShellSettings, SuccessfulFixedRequestUpdatesValueAndFailureDoesNot) std::filesystem::remove(arguments); } +TEST(BloomShellSettings, FailedWifiApplyReloadsPersistedPreference) +{ + const auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-failure-" + std::to_string(getpid())); + std::filesystem::create_directory(directory); + const auto script = directory / "bloom-network"; + { + std::ofstream output(script); + output << "#!/bin/sh\n" + "case \"$*\" in\n" + " 'request disable') exit 1 ;;\n" + " status) printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-network\"," + "\"wifi_enabled\":false}' ;;\n" + " *) exit 2 ;;\n" + "esac\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + BloomShellCapabilities capabilities{}; + ASSERT_EQ(0, bloom_shell_capabilities_from_model(354, 0, &capabilities)); + BloomShellQuickValues values{}; + values.ready = 1; + values.wifi_enabled = 1; + EXPECT_NE(0, bloom_shell_quick_settings_adjust(&capabilities, &values, 2, -1, + script.c_str(), script.c_str())); + EXPECT_FALSE(values.wifi_enabled); + std::filesystem::remove_all(directory); +} + TEST(BloomShellSettings, ThemeVariantCyclesThroughCanonicalFixedRequests) { const auto directory = std::filesystem::temp_directory_path() / From 121460894290402eb0154631c3c4915cc6eda180 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 13:58:27 -0400 Subject: [PATCH 17/29] ci: run static analysis beside native tests --- .github/workflows/test.yml | 71 +++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48c8b1b8d..29db45446 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,42 +30,19 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Test build + static-analysis: + name: Static analysis runs-on: ubuntu-latest steps: - - name: Install test dependencies + - name: Install cppcheck run: | sudo apt-get update - sudo apt-get install --yes build-essential cmake cppcheck libgtest-dev libsdl1.2-dev libsdl-image1.2-dev libsdl-ttf2.0-dev + sudo apt-get install --yes cppcheck - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 2 - - name: Resolve submodule cache key - id: submodules - shell: bash - run: | - sha=$({ git ls-files --stage third-party/libxml2 third-party/rcheevos third-party/zlib; sha256sum tools/build-libxml2-static.sh; } | sha256sum | cut -d ' ' -f 1) - echo "sha=$sha" >> "$GITHUB_OUTPUT" - - name: Restore submodule object cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: | - .git/modules/third-party/rcheevos - .git/modules/third-party/zlib - .git/modules/third-party/libxml2 - cache/bloom-libxml2-x86_64 - key: test-submodules-${{ runner.os }}-${{ steps.submodules.outputs.sha }} - - name: Checkout pinned test submodules - run: tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos third-party/zlib - - name: Test CI scope policy - run: sh test/test_ci_scope.sh - - name: Initialize timing summary - run: | - echo '| Gate | Seconds | Scope |' >> "$GITHUB_STEP_SUMMARY" - echo '|---|---:|---|' >> "$GITHUB_STEP_SUMMARY" - name: Resolve static-analysis scope id: scope if: github.event_name == 'pull_request' @@ -74,6 +51,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: echo "targets=$(git diff --name-only "$BASE_SHA"...HEAD | sh tools/ci-scope.sh static-analysis)" >> "$GITHUB_OUTPUT" - name: Run static analysis + shell: bash run: | started=$SECONDS targets='${{ steps.scope.outputs.targets }}' @@ -93,7 +71,46 @@ jobs: CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction" \ STATIC_ANALYSIS_TARGETS="$targets" fi + echo "| Gate | Seconds | Scope |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---:|---|" >> "$GITHUB_STEP_SUMMARY" echo "| Static analysis | $((SECONDS - started)) | $targets |" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Test build + runs-on: ubuntu-latest + + steps: + - name: Install test dependencies + run: | + sudo apt-get update + sudo apt-get install --yes build-essential cmake libgtest-dev libsdl1.2-dev libsdl-image1.2-dev libsdl-ttf2.0-dev + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - name: Resolve submodule cache key + id: submodules + shell: bash + run: | + sha=$({ git ls-files --stage third-party/libxml2 third-party/rcheevos third-party/zlib; sha256sum tools/build-libxml2-static.sh; } | sha256sum | cut -d ' ' -f 1) + echo "sha=$sha" >> "$GITHUB_OUTPUT" + - name: Restore submodule object cache + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + .git/modules/third-party/rcheevos + .git/modules/third-party/zlib + .git/modules/third-party/libxml2 + cache/bloom-libxml2-x86_64 + key: test-submodules-${{ runner.os }}-${{ steps.submodules.outputs.sha }} + - name: Checkout pinned test submodules + run: tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos third-party/zlib + - name: Test CI scope policy + run: sh test/test_ci_scope.sh + - name: Initialize timing summary + run: | + echo '| Gate | Seconds | Scope |' >> "$GITHUB_STEP_SUMMARY" + echo '|---|---:|---|' >> "$GITHUB_STEP_SUMMARY" - name: Resolve affected test risk id: tests if: github.event_name == 'pull_request' From 00c1f1e00ec25d0216ac4418d8c18ab8f6e5faec Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 14:57:52 -0400 Subject: [PATCH 18/29] fix(network): make Wi-Fi mutations rollback-safe --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- src/bloomShell/bloom_shell_settings.c | 78 ++++++++++++++++++---- src/bloomShell/bloom_shell_settings.h | 4 ++ src/bloomShell/main.c | 1 + static/build/.tmp_update/bin/bloom-network | 58 +++++++++++----- test/shell/network_status.bats | 68 +++++++++++++++++-- test/test_bloom_shell_settings.cpp | 54 ++++++++++++++- 8 files changed, 232 insertions(+), 37 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 1ca815a2d..3503ed5f4 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314630696 +byte_count = 314631721 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 88710a6b1..942c89d7b 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 6282, - "tree_sha256": "6486eee568949804c5812c1a895a16d8ab04966a8f5311a9613e38d703aca467", + "byte_count": 7307, + "tree_sha256": "d52061ae44bbb85f117a372a3ffd8a9996e3d18e77bdc260a0843b7ae5576c79", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/src/bloomShell/bloom_shell_settings.c b/src/bloomShell/bloom_shell_settings.c index 2c1cbe078..ca1b6d93a 100644 --- a/src/bloomShell/bloom_shell_settings.c +++ b/src/bloomShell/bloom_shell_settings.c @@ -168,7 +168,14 @@ int bloom_shell_settings_row_format(const BloomShellCapabilities *capabilities, : snprintf(label, label_size, "Mute unavailable"); break; case BLOOM_SHELL_SETTINGS_WIFI: - length = values->ready ? snprintf(label, label_size, "Wi-Fi %s", values->wifi_enabled ? "On" : "Off") + length = values->ready ? snprintf(label, label_size, "Wi-Fi %s", + !values->wifi_enabled + ? "Off" + : !values->wifi_status_ready + ? "On" + : values->wifi_associated + ? "On / Connected" + : "On / No connection") : snprintf(label, label_size, "Wi-Fi unavailable"); break; case BLOOM_SHELL_SETTINGS_SSH: @@ -622,7 +629,14 @@ int bloom_shell_quick_settings_format(const BloomShellCapabilities *capabilities else if (row == 1) length = snprintf(label, label_size, "Volume: %s%d", values->mute ? "Muted / " : "", values->volume); else if (capabilities->wifi && row == 2) - length = snprintf(label, label_size, "Wi-Fi: %s", values->wifi_enabled ? "On" : "Off"); + length = snprintf(label, label_size, "Wi-Fi: %s", + !values->wifi_enabled + ? "Off" + : !values->wifi_status_ready + ? "On" + : values->wifi_associated + ? "On / Connected" + : "On / No connection"); else return -1; return length >= 0 && (size_t)length < label_size ? 0 : -1; @@ -767,26 +781,61 @@ static int run_network_request(const char *path, const char *operation) return -1; } -static int load_network_preference(const char *path, int *enabled) +int bloom_shell_network_status_parse(const char *json, BloomShellQuickValues *values) { - if (enabled == NULL) - return -1; - char json[QUICK_OUTPUT_MAX + 1]; - if (load_output(path, "status", NULL, json, sizeof(json)) != 0) + if (json == NULL || values == NULL) return -1; cJSON *root = cJSON_Parse(json); const cJSON *schema = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "schema"); const cJSON *service = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "service"); + const cJSON *available = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "available"); + const cJSON *capable = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "wifi_capable"); const cJSON *wifi = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "wifi_enabled"); - int valid = cJSON_IsNumber(schema) && schema->valueint == 1 && cJSON_IsString(service) && + const cJSON *associated = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "associated"); + const cJSON *state = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "state"); + int valid = cJSON_IsNumber(schema) && schema->valuedouble == 1.0 && cJSON_IsString(service) && service->valuestring != NULL && strcmp(service->valuestring, "bloom-network") == 0 && - cJSON_IsBool(wifi); - if (valid) - *enabled = cJSON_IsTrue(wifi); + cJSON_IsBool(available) && cJSON_IsBool(capable) && cJSON_IsBool(wifi) && + cJSON_IsBool(associated) && cJSON_IsString(state) && state->valuestring != NULL; + int enabled = valid && cJSON_IsTrue(wifi); + int connected = valid && cJSON_IsTrue(associated); + if (valid) { + if (strcmp(state->valuestring, "associated") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && enabled && connected; + else if (strcmp(state->valuestring, "acquiring_address") == 0 || + strcmp(state->valuestring, "not_associated") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && enabled && !connected; + else if (strcmp(state->valuestring, "wifi_disabled") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && !enabled && !connected; + else if (strcmp(state->valuestring, "no_network_hardware") == 0) + valid = cJSON_IsTrue(available) && !cJSON_IsTrue(capable) && !enabled && !connected; + else + valid = 0; + } + if (valid) { + values->wifi_enabled = enabled; + values->wifi_associated = connected; + values->wifi_status_ready = 1; + } cJSON_Delete(root); return valid ? 0 : -1; } +int bloom_shell_network_status_load(const char *network_path, BloomShellQuickValues *values) +{ + if (values == NULL) + return -1; + char json[QUICK_OUTPUT_MAX + 1]; + int result = load_output(network_path, "status", NULL, json, sizeof(json)) == 0 + ? bloom_shell_network_status_parse(json, values) + : -1; + if (result != 0) { + values->wifi_status_ready = 0; + values->wifi_associated = 0; + } + return result; +} + void bloom_shell_network_credential_clear(BloomShellNetworkCredential *credential) { if (credential == NULL) @@ -985,12 +1034,13 @@ int bloom_shell_quick_settings_adjust(const BloomShellCapabilities *capabilities if (next == values->wifi_enabled) return 0; if (run_network_request(network_path, next ? "enable" : "disable") != 0) { - int persisted = values->wifi_enabled; - if (load_network_preference(network_path, &persisted) == 0) - values->wifi_enabled = persisted; + bloom_shell_network_status_load(network_path, values); return -1; } values->wifi_enabled = next; + values->wifi_status_ready = 0; + values->wifi_associated = 0; + bloom_shell_network_status_load(network_path, values); return 0; } return -1; diff --git a/src/bloomShell/bloom_shell_settings.h b/src/bloomShell/bloom_shell_settings.h index 934da1bc3..dba3978aa 100644 --- a/src/bloomShell/bloom_shell_settings.h +++ b/src/bloomShell/bloom_shell_settings.h @@ -17,6 +17,8 @@ typedef struct { int volume; int mute; int wifi_enabled; + int wifi_status_ready; + int wifi_associated; int network_services_ready; int ssh_available; int ssh_enabled; @@ -117,6 +119,8 @@ size_t bloom_shell_quick_settings_count(const BloomShellCapabilities *capabiliti const char *bloom_shell_quick_settings_label(const BloomShellCapabilities *capabilities, size_t row); int bloom_shell_quick_values_parse(const char *json, BloomShellQuickValues *values); int bloom_shell_quick_values_load(const char *settings_path, BloomShellQuickValues *values); +int bloom_shell_network_status_parse(const char *json, BloomShellQuickValues *values); +int bloom_shell_network_status_load(const char *network_path, BloomShellQuickValues *values); int bloom_shell_network_services_parse(const char *json, BloomShellQuickValues *values); int bloom_shell_network_services_load(const char *services_path, BloomShellQuickValues *values); int bloom_shell_network_service_change(BloomShellQuickValues *values, diff --git a/src/bloomShell/main.c b/src/bloomShell/main.c index 2852df337..7f071ea59 100644 --- a/src/bloomShell/main.c +++ b/src/bloomShell/main.c @@ -2714,6 +2714,7 @@ int main(int argc, char **argv) bloom_shell_first_run_load(BLOOM_SETTINGS_BINARY, &first_run); BloomShellQuickValues quick_values = {0}; bloom_shell_quick_values_load(BLOOM_SETTINGS_BINARY, &quick_values); + bloom_shell_network_status_load(BLOOM_NETWORK_BINARY, &quick_values); bloom_shell_quick_battery_load(BLOOM_PLATFORM_BINARY, &quick_values); bloom_shell_network_services_load(BLOOM_NETWORK_SERVICES_BINARY, &quick_values); capabilities.samba = diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index 6b435ed05..416159a70 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -33,6 +33,33 @@ wifi_capability() { reconcile_services() { [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1; } +restore_preference() { + restore_desired=$1 + "$SETTINGS" set device.wifi_enabled "$restore_desired" >/dev/null 2>&1 || return 1 + if "$BACKEND" reconcile >/dev/null 2>&1; then + if [ "$restore_desired" = true ] && [ -x "$COMPAT" ]; then + "$COMPAT" services >/dev/null 2>&1 || return 1 + fi + else + restore_status=$? + [ "$restore_status" -eq 3 ] && [ -x "$COMPAT" ] && + "$COMPAT" check >/dev/null 2>&1 || return 1 + fi + reconcile_services +} + +rollback_preference_failure() { + rollback_operation=$1 + rollback_state=$2 + rollback_desired=$3 + if restore_preference "$rollback_desired"; then + preference_result "$rollback_operation" false false "${rollback_state}_rolled_back" + else + preference_result "$rollback_operation" true false "${rollback_state}_rollback_failed" + fi + return 1 +} + ipv4_ready() { [ -x "$IP" ] || return 1 "$IP" -4 -o addr show dev wlan0 scope global 2>/dev/null | @@ -108,37 +135,38 @@ set_preference() { wifi=$(wifi_capability) || { preference_result "$operation" false false platform_unavailable; return 1; } [ "$wifi" = true ] || { preference_result "$operation" false false no_network_hardware; return 1; } [ -x "$SETTINGS" ] || { preference_result "$operation" false false settings_unavailable; return 1; } + [ -x "$BACKEND" ] || { preference_result "$operation" false false backend_unavailable; return 1; } + previous=$(wifi_preference) || { + preference_result "$operation" false false wifi_state_unavailable + return 1 + } + case "$previous" in + 0) previous_desired=false ;; + 1) previous_desired=true ;; + *) preference_result "$operation" false false wifi_state_unavailable; return 1 ;; + esac "$SETTINGS" set device.wifi_enabled "$desired" >/dev/null 2>&1 || { preference_result "$operation" false false settings_rejected return 1 } - if [ "$desired" = false ]; then - reconcile_services || { - preference_result "$operation" true false remote_services_failed - return 1 - } - fi - [ -x "$BACKEND" ] || { preference_result "$operation" true false backend_unavailable; return 1; } if "$BACKEND" reconcile >/dev/null 2>&1; then if [ "$desired" = true ] && [ -x "$COMPAT" ]; then "$COMPAT" services >/dev/null 2>&1 || { - preference_result "$operation" true false compatibility_failed + rollback_preference_failure "$operation" compatibility_failed "$previous_desired" return 1 } fi else status=$? if [ "$status" -ne 3 ] || [ ! -x "$COMPAT" ] || ! "$COMPAT" check >/dev/null 2>&1; then - preference_result "$operation" true false backend_failed + rollback_preference_failure "$operation" backend_failed "$previous_desired" return 1 fi fi - if [ "$desired" = true ]; then - reconcile_services || { - preference_result "$operation" true false remote_services_failed - return 1 - } - fi + reconcile_services || { + rollback_preference_failure "$operation" remote_services_failed "$previous_desired" + return 1 + } preference_result "$operation" true true applied } diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index b3a0cb621..c5e3bb23b 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -171,22 +171,82 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] [ ! -e "$COMPAT_LOG" ] [ "$(cat "$SERVICES_LOG")" = reconcile ] - [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings services backend ' ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend services ' ] } -@test "preference mutation reports settings and apply failures separately" { +@test "failed preference apply restores the prior preference and services" { printf '#!/bin/sh\nexit 1\n' >"$BLOOM_SETTINGS_BIN" chmod +x "$BLOOM_SETTINGS_BIN" run "$SERVICE" request enable [ "$status" -eq 1 ] [[ "$output" == *'"preference_saved":false,"applied":false,"state":"settings_rejected"'* ]] - printf '#!/bin/sh\nexit 0\n' >"$BLOOM_SETTINGS_BIN" + cat >"$BLOOM_SETTINGS_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$SETTINGS_LOG" +printf 'settings:%s\n' "$3" >>"$APPLY_LOG" +SH + cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' +#!/bin/sh +printf 'backend\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/backend-failed" ]; then + : >"$BATS_TEST_TMPDIR/backend-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_BACKEND_BIN" + rm -f "$APPLY_LOG" + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"backend_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$SETTINGS_LOG")" = 'set device.wifi_enabled false set device.wifi_enabled true ' ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings:false backend settings:true backend compat services ' ] +} + +@test "failed preference rollback preserves explicit unsafe state" { + cat >"$BLOOM_SETTINGS_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$SETTINGS_LOG" +[ "$3" != true ] +SH printf '#!/bin/sh\nexit 1\n' >"$BLOOM_NETWORK_BACKEND_BIN" chmod +x "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_BACKEND_BIN" run "$SERVICE" request disable [ "$status" -eq 1 ] - [[ "$output" == *'"preference_saved":true,"applied":false,"state":"backend_failed"'* ]] + [[ "$output" == *'"preference_saved":true,"applied":false,"state":"backend_failed_rollback_failed"'* ]] + [ ! -e "$SERVICES_LOG" ] +} + +@test "compatibility failure restores the prior preference and services" { + cat >"$BLOOM_NETWORK_COMPAT_BIN" <<'SH' +#!/bin/sh +printf 'compat\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/compat-failed" ]; then + : >"$BATS_TEST_TMPDIR/compat-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_NETWORK_COMPAT_BIN" + run "$SERVICE" request enable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"compatibility_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend compat settings backend compat services ' ] +} + +@test "service failure restores the prior preference and backend" { + cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' +#!/bin/sh +printf 'services\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/services-failed" ]; then + : >"$BATS_TEST_TMPDIR/services-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_NETWORK_SERVICES_BIN" + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"remote_services_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend services settings backend compat services ' ] } @test "preference mutation refuses hardware without networking" { diff --git a/test/test_bloom_shell_settings.cpp b/test/test_bloom_shell_settings.cpp index 0ca1215df..3ee3e07b7 100644 --- a/test/test_bloom_shell_settings.cpp +++ b/test/test_bloom_shell_settings.cpp @@ -294,6 +294,44 @@ TEST(BloomShellSettings, RejectsMalformedOrOutOfRangeValues) EXPECT_FALSE(values.ready); } +TEST(BloomShellSettings, NetworkStatusDistinguishesDesiredWifiFromConnectivity) +{ + BloomShellCapabilities capabilities{}; + BloomShellQuickValues values{}; + ASSERT_EQ(0, bloom_shell_capabilities_from_model(354, 0, &capabilities)); + values.ready = 1; + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":false,"state":"acquiring_address"})", + &values)); + EXPECT_TRUE(values.wifi_enabled); + EXPECT_TRUE(values.wifi_status_ready); + EXPECT_FALSE(values.wifi_associated); + char label[64]; + ASSERT_EQ(0, bloom_shell_settings_row_format(&capabilities, &values, 10, label, + sizeof(label))); + EXPECT_NE(std::string::npos, std::string(label).find("On / No connection")); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: On / No connection", label); + + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":true,"state":"associated"})", + &values)); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: On / Connected", label); + + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":false,"associated":false,"state":"wifi_disabled"})", + &values)); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: Off", label); + EXPECT_NE(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":false,"state":"associated"})", + &values)); +} + TEST(BloomShellSettings, UnavailableValuesAreExplicit) { BloomShellCapabilities capabilities{}; @@ -373,7 +411,8 @@ TEST(BloomShellSettings, FailedWifiApplyReloadsPersistedPreference) "case \"$*\" in\n" " 'request disable') exit 1 ;;\n" " status) printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-network\"," - "\"wifi_enabled\":false}' ;;\n" + "\"wifi_enabled\":false,\"associated\":false," + "\"state\":\"wifi_disabled\"}' ;;\n" " *) exit 2 ;;\n" "esac\n"; } @@ -386,9 +425,22 @@ TEST(BloomShellSettings, FailedWifiApplyReloadsPersistedPreference) EXPECT_NE(0, bloom_shell_quick_settings_adjust(&capabilities, &values, 2, -1, script.c_str(), script.c_str())); EXPECT_FALSE(values.wifi_enabled); + EXPECT_TRUE(values.wifi_status_ready); std::filesystem::remove_all(directory); } +TEST(BloomShellSettings, FailedNetworkStatusLoadClearsStaleConnectivity) +{ + BloomShellQuickValues values{}; + values.wifi_enabled = 1; + values.wifi_status_ready = 1; + values.wifi_associated = 1; + EXPECT_NE(0, bloom_shell_network_status_load("/missing/bloom-network", &values)); + EXPECT_TRUE(values.wifi_enabled); + EXPECT_FALSE(values.wifi_status_ready); + EXPECT_FALSE(values.wifi_associated); +} + TEST(BloomShellSettings, ThemeVariantCyclesThroughCanonicalFixedRequests) { const auto directory = std::filesystem::temp_directory_path() / From d74fea5eb0e6e2378ed16a153305ca8620929c63 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 15:04:09 -0400 Subject: [PATCH 19/29] test(network): provide complete runtime status fixture --- test/test_bloom_shell_settings.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_bloom_shell_settings.cpp b/test/test_bloom_shell_settings.cpp index 3ee3e07b7..41ec1f445 100644 --- a/test/test_bloom_shell_settings.cpp +++ b/test/test_bloom_shell_settings.cpp @@ -411,6 +411,7 @@ TEST(BloomShellSettings, FailedWifiApplyReloadsPersistedPreference) "case \"$*\" in\n" " 'request disable') exit 1 ;;\n" " status) printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-network\"," + "\"available\":true,\"wifi_capable\":true," "\"wifi_enabled\":false,\"associated\":false," "\"state\":\"wifi_disabled\"}' ;;\n" " *) exit 2 ;;\n" From ac9c0cdf63799ea277849be1c9d7371d58ca31d5 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 15:31:55 -0400 Subject: [PATCH 20/29] fix(deploy): require atomic network helper bundle --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- docs/DEVELOPMENT.md | 9 +- static/build/.tmp_update/bin/bloom-dev-deploy | 22 ++++- test/shell/dev_deploy.bats | 99 +++++++++++++------ test/shell/transfer_policy.bats | 41 +++++--- tools/dev-deploy-manifest.py | 17 +--- 7 files changed, 128 insertions(+), 66 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 3503ed5f4..96b35d7e8 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314631721 +byte_count = 314632109 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 942c89d7b..de9345803 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 30096, - "tree_sha256": "e474bc2bc7816eb1f976affd5c5952c9b3616ded762630bb3f0333220b62d13b", + "byte_count": 30484, + "tree_sha256": "8a8602e84b6ab331716c41697ae0170d4c0b6715eb7fa77ce561b3e5efc08955", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2dbac31a4..05a45e52b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -171,10 +171,11 @@ data roots are rejected. Ordinary activation refuses `network-helpers`; remote network mutations use a separate rollback-timed workflow so the only working SSH path cannot be replaced by this command. -The SFTP chroot consumer, `bloom-transfer-policy`, and -`config/transfer-policy.json` form one guarded `network-helpers` deployment -group. If a candidate contains any member, host and device manifest validation -require all three. Activate that bundle only through the rollback-timed network +The complete settings, platform, Wi-Fi, listener/service, transaction, remote +key, SFTP, and transfer-policy runtime set forms one guarded 16-file +`network-helpers` deployment group. Host and device manifest validation reject +every partial group so incompatible schema generations cannot be mixed on a +live device. Activate that bundle only through the rollback-timed network overlay path, then validate policy status, exact mount modes, key-only access, listener state, and rollback through an independent connection. diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index 48d934e59..e6a9700f3 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -173,10 +173,24 @@ validate_manifest() { [ "$actual_paths" = "$expected_paths" ] || fail "launch/session/RA bundle is incomplete" elif [ "$manifest_component" = network-helpers ]; then actual_paths="$($JQ -r '.files[].path' "$manifest" | sort)" - transfer_count="$(printf '%s\n' "$actual_paths" | grep -Ec \ - '^(bin/bloom-dev-ssh|bin/bloom-sftp-gate|bin/bloom-sftp-server|bin/bloom-transfer-policy|config/transfer-policy.json)$' || true)" - [ "$transfer_count" -eq 0 ] || [ "$transfer_count" -eq 5 ] || - fail "network transfer policy bundle is incomplete" + expected_paths="$(printf '%s\n' \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + script/network/update_networking.sh \ + script/network/wpsclient.sh | sort)" + [ "$actual_paths" = "$expected_paths" ] || fail "network helper bundle is incomplete" fi } diff --git a/test/shell/dev_deploy.bats b/test/shell/dev_deploy.bats index a4f5c2f90..16badbaad 100644 --- a/test/shell/dev_deploy.bats +++ b/test/shell/dev_deploy.bats @@ -149,6 +149,59 @@ stage_audit() { "$SERVICE" seal "$id" >/dev/null } +stage_network_bundle() { + id=$1 + payload="$BATS_TEST_TMPDIR/$id-payload" + manifest="$BATS_TEST_TMPDIR/$id.json" + for path in \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + script/network/update_networking.sh \ + script/network/wpsclient.sh; do + mkdir -p "$payload/$(dirname "$path")" + if [ "$path" = config/transfer-policy.json ]; then + printf '%s\n' '{}' >"$payload/$path" + else + printf '#!/bin/sh\nexit 0\n' >"$payload/$path" + chmod 0755 "$payload/$path" + fi + done + cat >"$payload/bin/bloom-network-services" <<'EOF' +#!/bin/sh +[ "$1" = status ] || exit 1 +printf '%s\n' '{"schema":2,"service":"bloom-network-services"}' +EOF + cat >"$payload/bin/bloom-transfer-policy" <<'EOF' +#!/bin/sh +[ "$1" = validate ] +EOF + chmod 0755 "$payload/bin/bloom-network-services" "$payload/bin/bloom-transfer-policy" + python3 /workspace/tools/dev-deploy-manifest.py \ + network-helpers "$payload" "$id" \ + 0123456789abcdef0123456789abcdef01234567 "$manifest" + manifest_size=$(wc -c <"$manifest" | tr -d ' ') + manifest_sha=$(sha256sum "$manifest" | awk '{print $1}') + "$SERVICE" prepare "$id" >/dev/null + "$SERVICE" manifest "$id" "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null + jq -r '.files[] | [.path,.size,.sha256,.mode] | @tsv' "$manifest" | + while IFS=$'\t' read -r path size sha mode; do + "$SERVICE" receive "$id" "$path" "$size" "$sha" "$mode" <"$payload/$path" >/dev/null + done + "$SERVICE" seal "$id" >/dev/null +} + @test "preflight requires developer mode local enablement and an idle update and session" { run "$SERVICE" preflight [ "$status" -eq 0 ] @@ -339,21 +392,23 @@ EOF [ "$(printf '%s\n' "$output" | grep -Ec 'Roms|Saves|BIOS|authorized_keys|appconfigs|\.bloom/update')" -eq 0 ] } -@test "ordinary activation cannot mutate network helpers" { - payload="$BATS_TEST_TMPDIR/network-audit" - manifest="$BATS_TEST_TMPDIR/network-candidate.json" - write_audit "$payload" network - size=$(wc -c <"$payload" | tr -d ' ') - sha=$(sha256sum "$payload" | awk '{print $1}') - cat >"$manifest" <"$manifest" <<'EOF' +{"schema":1,"deployment_id":"network-incomplete","component":"network-helpers","base_commit":"0123456789abcdef0123456789abcdef01234567","files":[{"path":"bin/bloom-network","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","mode":"0755"}]} EOF manifest_size=$(wc -c <"$manifest" | tr -d ' ') manifest_sha=$(sha256sum "$manifest" | awk '{print $1}') - "$SERVICE" prepare network-candidate >/dev/null - "$SERVICE" manifest network-candidate "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null - "$SERVICE" receive network-candidate bin/bloom-listener-audit "$size" "$sha" 0755 <"$payload" >/dev/null - "$SERVICE" seal network-candidate >/dev/null + "$SERVICE" prepare network-incomplete >/dev/null + + run "$SERVICE" manifest network-incomplete "$manifest_size" "$manifest_sha" <"$manifest" + + [ "$status" -ne 0 ] + [[ "$output" == *'network helper bundle is incomplete'* ]] +} + +@test "ordinary activation cannot mutate network helpers" { + stage_network_bundle network-candidate run "$SERVICE" activate network-candidate @@ -363,25 +418,7 @@ EOF } @test "network helper activation requires an exact active transaction authorization" { - payload="$BATS_TEST_TMPDIR/network-services" - manifest="$BATS_TEST_TMPDIR/network-authorized.json" - cat >"$payload" <<'EOF' -#!/bin/sh -[ "$1" = status ] || exit 1 -printf '%s\n' '{"schema":1,"service":"bloom-network-services"}' -EOF - chmod +x "$payload" - size=$(wc -c <"$payload" | tr -d ' ') - sha=$(sha256sum "$payload" | awk '{print $1}') - cat >"$manifest" </dev/null - "$SERVICE" manifest network-authorized "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null - "$SERVICE" receive network-authorized bin/bloom-network-services "$size" "$sha" 0755 <"$payload" >/dev/null - "$SERVICE" seal network-authorized >/dev/null + stage_network_bundle network-authorized run "$SERVICE" activate-network network-authorized tx-wrong [ "$status" -ne 0 ] diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats index ff2f64ae6..723d64b08 100755 --- a/test/shell/transfer_policy.bats +++ b/test/shell/transfer_policy.bats @@ -96,26 +96,43 @@ teardown() { [ "$status" -ne 0 ] } -@test "Warm deployment requires the policy, consumer, and authority together" { +@test "Warm deployment requires the complete network authority bundle" { payload="$BATS_TEST_TMPDIR/payload" manifest="$BATS_TEST_TMPDIR/manifest.json" - mkdir -p "$payload/bin" "$payload/config" - cp /workspace/static/build/.tmp_update/bin/bloom-dev-ssh "$payload/bin/" - cp /workspace/static/build/.tmp_update/bin/bloom-sftp-gate "$payload/bin/" - cp /workspace/static/build/.tmp_update/bin/bloom-transfer-policy "$payload/bin/" - printf '#!/bin/sh\nexit 0\n' >"$payload/bin/bloom-sftp-server" - cp /workspace/build/transfer-policy.json "$payload/config/" - chmod 0755 "$payload/bin/bloom-dev-ssh" "$payload/bin/bloom-sftp-gate" \ - "$payload/bin/bloom-sftp-server" "$payload/bin/bloom-transfer-policy" + for path in \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + script/network/update_networking.sh \ + script/network/wpsclient.sh; do + mkdir -p "$payload/$(dirname "$path")" + if [ "$path" = config/transfer-policy.json ]; then + cp /workspace/build/transfer-policy.json "$payload/$path" + else + printf '#!/bin/sh\nexit 0\n' >"$payload/$path" + chmod 0755 "$payload/$path" + fi + done run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ net02-fixture 0123456789abcdef0123456789abcdef01234567 "$manifest" [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 - jq -e '[.files[].path] == ["bin/bloom-dev-ssh", "bin/bloom-sftp-gate", "bin/bloom-sftp-server", "bin/bloom-transfer-policy", "config/transfer-policy.json"]' "$manifest" + [ "$(jq '.files | length' "$manifest")" -eq 16 ] - rm "$payload/config/transfer-policy.json" + rm "$payload/bin/bloom-platform" run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ net02-incomplete 0123456789abcdef0123456789abcdef01234567 "$manifest" [ "$status" -ne 0 ] - [[ "$output" == *"network transfer policy bundle is incomplete"* ]] + [[ "$output" == *"component bundle is incomplete"* ]] } diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index 598b57d83..29fd5d46d 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -69,17 +69,13 @@ # Shell, input, GameSwitcher, RA, and session state machines. "launch-session": EXACT_PATHS["launch-session"], "save-migration": EXACT_PATHS["save-migration"], + # Network ownership spans settings, platform capability discovery, Wi-Fi, + # listener/service reconciliation, transfer policy, and the rollback + # transaction. A partial overlay can pair incompatible schema generations + # and falsely report a healthy radio as unavailable. + "network-helpers": EXACT_PATHS["network-helpers"], } -NETWORK_TRANSFER_PATHS = { - "bin/bloom-dev-ssh", - "bin/bloom-sftp-gate", - "bin/bloom-sftp-server", - "bin/bloom-transfer-policy", - "config/transfer-policy.json", -} - - def allowed(component, path): if component == "ui-assets": return path.startswith("res/bloom/") and len(path) > len("res/bloom/") @@ -129,9 +125,6 @@ def build_manifest(component, root, deployment_id, base_commit): raise ValueError("payload does not contain an allowlisted file") required = REQUIRED_PATHS.get(component) present = {item["path"] for item in files} - if component == "network-helpers" and present.intersection(NETWORK_TRANSFER_PATHS): - if not NETWORK_TRANSFER_PATHS.issubset(present): - raise ValueError("network transfer policy bundle is incomplete") if required is not None and present != required: missing = sorted(required - present) unexpected = sorted(present - required) From aec6bd351c726fa31172477787b5279dca45e712 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 16:18:19 -0400 Subject: [PATCH 21/29] fix(network): retry timed rollback recovery --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- docs/DEVELOPMENT.md | 7 ++ .../.tmp_update/bin/bloom-network-transaction | 18 +++- test/shell/network_transaction.bats | 87 +++++++++++++++++++ 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 96b35d7e8..0b5a893d2 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314632109 +byte_count = 314632369 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index de9345803..40b2ed8ca 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 25714, - "tree_sha256": "223ba42fb5c3e5d8c41e51b13e4042db1d6bc083c4b58368b5ae000f300f3d50", + "byte_count": 25974, + "tree_sha256": "8d5d51c37f769cbdaf1d15476976f02f3c716c895a3d915d0e46de8f5357752e", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 05a45e52b..feb710c59 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -240,6 +240,13 @@ and the raw one-time nonce. The lease contains only its transaction identity, monotonic bounds, state, and the fixed `known_good_station`/`key_only_ssh` allowlist. It cannot add a service, key, profile, or listener. +Wi-Fi restoration is not a single radio-start attempt. The watchdog reruns the +complete prior-state restore and waits for association, IPv4, key-only SSH, and +listener health in four bounded 45-second windows. A transient SSD202D radio or +DHCP start failure therefore triggers a fresh recovery attempt; exhaustion is +reported as `rollback_failed` with the recovery lease still armed instead of +silently claiming recovery. + Confirmation never reuses the OpenSSH control socket. It rechecks the configured model, station association, key-only SSH runtime, prohibited listeners, aggregate `bloomctl health --json`, and active overlay identity over independent diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 4f327b884..2439c837c 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -318,6 +318,19 @@ wait_for_prior_unchanged() { return 1 } +restore_prior_bounded() { + restore_attempt=0 + while [ "$restore_attempt" -lt 4 ]; do + restore_attempt=$((restore_attempt + 1)) + if restore_prior && wait_for_prior_unchanged 45; then + return 0 + fi + [ "$restore_attempt" -lt 4 ] || break + "$SLEEP" 1 || return 1 + done + return 1 +} + rollback_locked() { reason=$1 state="$(read_state)" || fail "transaction state is unavailable" @@ -342,8 +355,7 @@ rollback_locked() { elif [ "$operation" = network-overlay ] && wait_for_prior_unchanged 15; then : else - restore_prior || restored=false - [ "$restored" != true ] || wait_for_prior_unchanged 60 || restored=false + restore_prior_bounded || restored=false fi if [ "$restored" = true ]; then final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rolled_back"|.recovery_lease="inactive"')" @@ -354,7 +366,7 @@ rollback_locked() { rm -f "$WATCHDOG_READY" return 0 fi - final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rollback_failed"|.failure_reason="restore_failed"|.recovery_lease="inactive"')" + final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rollback_failed"|.failure_reason="restore_failed"|.recovery_lease="armed"')" publish_state "$final" rm -f "$WATCHDOG_READY" return 1 diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index 68f0ada31..c130d6ac1 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -236,6 +236,93 @@ EOF grep -F 'listener-audit' "$TRANSACTION_LOG" } +@test "Wi-Fi rollback retries transient radio restore failures" { + begin_transaction + "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + enable_count="$BATS_TEST_TMPDIR/enable-count" + printf '%s\n' 0 >"$enable_count" + export ENABLE_COUNT="$enable_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +if [ "$1:$2" = request:enable ]; then + count=$(cat "$ENABLE_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$ENABLE_COUNT" + [ "$count" -ge 3 ] +fi +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(cat "$enable_count")" -eq 3 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] +} + +@test "Wi-Fi rollback retries after an association convergence timeout" { + begin_transaction + "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + status_count="$BATS_TEST_TMPDIR/restore-status-count" + printf '%s\n' 0 >"$status_count" + export RESTORE_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$RESTORE_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$RESTORE_STATUS_COUNT" + if [ "$count" -le 45 ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' + else + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + fi + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(grep -Fc 'network request enable' "$TRANSACTION_LOG")" -eq 2 ] + [ "$(cat "$status_count")" -eq 46 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] +} + +@test "exhausted Wi-Fi restore attempts fail honestly with the lease armed" { + begin_transaction + "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":false,"associated":false}' + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +[ "$1:$2" != request:enable ] +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -ne 0 ] + [ "$(grep -Fc 'network request enable' "$TRANSACTION_LOG")" -eq 4 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rollback_failed ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = armed ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] +} + @test "mutation failure automatically rolls back before returning failure" { begin_transaction run env NETWORK_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable From 7758e36245126d511a3f94803e39da1f21df2ef8 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 16:18:26 -0400 Subject: [PATCH 22/29] ci: bound exhaustive cppcheck configuration expansion --- .github/workflows/test.yml | 6 +++++- docs/CI_SCOPING.md | 18 +++++++++--------- test/test_ci_scope.sh | 4 ++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29db45446..0a60702d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,11 @@ jobs: started=$SECONDS targets='${{ steps.scope.outputs.targets }}' if [ '${{ github.event_name }}' != 'pull_request' ]; then - make static-analysis CPPCHECK_JOBS="$(nproc)" + # Full-source integration remains exhaustive by ownership boundary, + # while native, ASan, and ARM compile the real configurations. Avoid + # cppcheck's speculative exponential preprocessor expansion here too. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS=--max-configs=1 targets=full elif [ "$targets" = full ]; then # PR feedback keeps the complete source closure but bounds speculative diff --git a/docs/CI_SCOPING.md b/docs/CI_SCOPING.md index f95432c2a..b451f3627 100644 --- a/docs/CI_SCOPING.md +++ b/docs/CI_SCOPING.md @@ -39,15 +39,15 @@ When the repository does not provide a protected merge queue, dispatch the The dispatch follows the same full-source path as `merge_group`; a green bounded pull-request run is not a substitute for that exact-head integration gate. -Cppcheck keeps the full-source default configuration exploration for -merge-group gates. Pull-request feedback uses one bounded preprocessor -configuration, over either the complete source closure for a full scope or the -affected components for a narrower scope. All modes use cppcheck's build -directory while analysis runs in parallel. Whole-program checks remain when the -full source closure is present; the affected-component gate suppresses -unused-function reports that cannot be proven without omitted sources. Native -and ASan compilation also use the hosted runner's available cores; the test -selection and assertions themselves are unchanged. +Cppcheck uses one bounded preprocessor configuration in every mode. Full-source +workflow-dispatch and merge-group gates still analyze the complete source +closure and retain whole-program checks; pull-request feedback analyzes either +that same closure or the affected components. The affected-component gate +suppresses unused-function reports that cannot be proven without omitted +sources. Native, ASan, and ARM builds compile the real configurations, so +speculative cppcheck configuration expansion adds latency without replacing a +compiler gate. All modes use cppcheck's build directory and the hosted runner's +available cores; test selection and assertions remain unchanged. Each required workflow writes gate duration, selected risk, and selected scope to the GitHub step summary. Collect those records before setting p50/p95 diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index ea8a6b340..8b66fc959 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -134,6 +134,10 @@ grep -F 'GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test' "$root/.github/w grep -F 'make static-analysis CPPCHECK_JOBS="$(nproc)"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'workflow_dispatch:' "$root/.github/workflows/test.yml" >/dev/null grep -F "if [ '\${{ github.event_name }}' != 'pull_request' ]; then" "$root/.github/workflows/test.yml" >/dev/null +if [ "$(grep -Fc 'CPPCHECK_CONFIG_FLAGS=--max-configs=1' "$root/.github/workflows/test.yml")" -ne 2 ]; then + echo "full-source cppcheck must bound configuration expansion for PR and integration runs" >&2 + exit 1 +fi grep -F 'CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS)' "$root/Makefile" >/dev/null if grep -E '^static-analysis:.*external-libs' "$root/Makefile" >/dev/null; then From 72ec706926a1276e1650ecacf1eee633ade96b0b Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 17:16:40 -0400 Subject: [PATCH 23/29] fix(network): recover interrupted mutations during boot --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 12 +++--- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 8 +++- docs/DEVELOPMENT.md | 8 +++- static/build/.tmp_update/bin/bloom-dev-deploy | 2 + .../.tmp_update/bin/bloom-network-transaction | 33 ++++++++++++++- static/build/.tmp_update/runtime.sh | 7 ++++ test/shell/dev_deploy.bats | 1 + test/shell/network_transaction.bats | 41 +++++++++++++++++++ test/shell/transfer_policy.bats | 3 +- tools/dev-deploy-manifest.py | 1 + 12 files changed, 108 insertions(+), 12 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 0b5a893d2..19a7d2cb6 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314632369 +byte_count = 314634522 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 40b2ed8ca..1359c7ca5 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -85,8 +85,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/runtime.sh", "file_count": 1, - "byte_count": 43892, - "tree_sha256": "1bb79b1f7830bed84571c6279f96ae8516a5f85cbdbbbceeb022382127c8357a", + "byte_count": 44353, + "tree_sha256": "69f06fd6088ab20e80ef10c013b70f79c6b3c754ffdb94738e84b51523b3a023", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 30484, - "tree_sha256": "8a8602e84b6ab331716c41697ae0170d4c0b6715eb7fa77ce561b3e5efc08955", + "byte_count": 30546, + "tree_sha256": "2ebc4df09a587d005dbc16815376341d1df83d7075908e0bbf79956a24616dc2", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 25974, - "tree_sha256": "8d5d51c37f769cbdaf1d15476976f02f3c716c895a3d915d0e46de8f5357752e", + "byte_count": 27604, + "tree_sha256": "688c7dcc6eacaf5b58b7e2f805759b4dfd1bb8eb1b556678d8257b373ee095fd", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index d6f3b77b6..0c084f233 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,7 +23,7 @@ observable properties and never substitutes for physical UX review. | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | -| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, and timeout restoration; transaction-bound network overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, timeout, and boot-interruption restoration; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | ## Task-based UX certification diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ec14496c..9d9134950 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -475,6 +475,11 @@ publishes `applying` before invoking its canonical owner. `confirm` consumes the capability only while the deadline and `applied` phase remain valid. `rollback` and the watchdog restore canonical preferences, reconcile service state, undo an activated network overlay, audit listeners, and publish a terminal phase. +If power interrupts that path, early runtime startup validates the matching +transaction and recovery lease, removes only its own empty stale lock, and runs +the same bounded rollback after canonical settings load but before ordinary +network startup. Thus a persisted Wi-Fi-off mutation cannot become the next +boot's unreviewed baseline. If an overlay activator has already restored its prior bytes, rollback first verifies the exact saved network and service preferences. An exact match closes the lease without replaying service requests, avoiding both unnecessary @@ -490,7 +495,8 @@ service preferences. Outside that bounded transaction the listener audit marks port 2222 as prohibited, so it cannot become stable background state. `bloom-dev-deploy activate-network` is an internal cross-owner handshake. It -activates a sealed `network-helpers` slot only when the transaction service +activates a sealed `network-helpers` slot, including the cooperating boot +runtime, only when the transaction service confirms the same transaction, phase, operation, and deployment identifier. The normal activation path remains denied. `bloomctl health` reports a bounded transaction phase and recovery-lease diagnostic without making developer state diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index feb710c59..0146e5b5b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -172,7 +172,7 @@ network mutations use a separate rollback-timed workflow so the only working SSH path cannot be replaced by this command. The complete settings, platform, Wi-Fi, listener/service, transaction, remote -key, SFTP, and transfer-policy runtime set forms one guarded 16-file +key, SFTP, transfer-policy, and boot-recovery runtime set forms one guarded 17-file `network-helpers` deployment group. Host and device manifest validation reject every partial group so incompatible schema generations cannot be mixed on a live device. Activate that bundle only through the rollback-timed network @@ -247,6 +247,12 @@ DHCP start failure therefore triggers a fresh recovery attempt; exhaustion is reported as `rollback_failed` with the recovery lease still armed instead of silently claiming recovery. +If power is lost while the lease remains armed, early runtime startup invokes +the same bounded rollback before ordinary boot networking consumes the saved +Wi-Fi preference. Boot recovery accepts only a validated matching +transaction/lease pair and removes only its own empty stale lock directory; an +incomplete record, identity mismatch, symlink, or non-empty lock fails closed. + Confirmation never reuses the OpenSSH control socket. It rechecks the configured model, station association, key-only SSH runtime, prohibited listeners, aggregate `bloomctl health --json`, and active overlay identity over independent diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index e6a9700f3..61c994050 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -79,6 +79,7 @@ allowed_path() { network-helpers:bin/bloomctl|\ network-helpers:bin/bloom-listener-audit|network-helpers:bin/bloom-wifi|\ network-helpers:config/transfer-policy.json|\ + network-helpers:runtime.sh|\ network-helpers:script/network/update_networking.sh|\ network-helpers:script/network/wpsclient.sh) return 0 ;; *) return 1 ;; @@ -188,6 +189,7 @@ validate_manifest() { bin/bloom-listener-audit \ bin/bloom-wifi \ config/transfer-policy.json \ + runtime.sh \ script/network/update_networking.sh \ script/network/wpsclient.sh | sort)" [ "$actual_paths" = "$expected_paths" ] || fail "network helper bundle is incomplete" diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 2439c837c..1fb04dcb9 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -444,6 +444,36 @@ command_rollback() { printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"rollback","restored":true}' } +command_boot_recover() { + state="$(read_state 2>/dev/null || true)" + lease="$(read_lease 2>/dev/null || true)" + if [ -z "$state" ] && [ -z "$lease" ]; then + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"not_required"}' + return 0 + fi + [ -n "$state" ] && [ -n "$lease" ] || fail "boot recovery state is incomplete" + transaction_id="$(printf '%s\n' "$state" | "$JQ" -er '.transaction_id')" + [ "$(printf '%s\n' "$lease" | "$JQ" -er '.transaction_id')" = "$transaction_id" ] || + fail "boot recovery transaction identity is invalid" + phase="$(printf '%s\n' "$state" | "$JQ" -er '.phase')" + lease_state="$(printf '%s\n' "$lease" | "$JQ" -er '.state')" + case "$phase:$lease_state" in + confirmed:inactive|rolled_back:inactive) + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"not_required"}' + return 0 + ;; + armed:armed|applying:armed|applied:armed|rolling_back:armed|rollback_failed:armed) ;; + *) fail "boot recovery state is not recoverable" ;; + esac + if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then + [ -d "$LOCK" ] && [ ! -L "$LOCK" ] || fail "boot recovery lock is unsafe" + rmdir "$LOCK" 2>/dev/null || fail "boot recovery lock is not empty" + fi + lock + rollback_locked boot_recovery || fail "boot recovery failed" + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"restored"}' +} + command_status() { if ! state="$(read_state 2>/dev/null)"; then { [ ! -e "$STATE" ] && [ ! -L "$STATE" ]; } || fail "transaction state is invalid" @@ -525,8 +555,9 @@ case "$#:${1:-}" in 4:apply) command_apply "$2" "$3" "$4" ;; 2:confirm) command_confirm "$2" ;; 1:rollback) command_rollback ;; + 1:boot-recover) command_boot_recover ;; 3:authorize-overlay) command_authorize_overlay "$2" "$3" ;; 3:authorize-shadow) command_authorize_shadow "$2" "$3" ;; 2:watchdog) command_watchdog "$2" ;; - *) fail "expected {status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback|authorize-overlay TRANSACTION ID|authorize-shadow TRANSACTION PORT}" ;; + *) fail "expected {status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback|boot-recover|authorize-overlay TRANSACTION ID|authorize-shadow TRANSACTION PORT}" ;; esac diff --git a/static/build/.tmp_update/runtime.sh b/static/build/.tmp_update/runtime.sh index 604337f04..7011a40c4 100644 --- a/static/build/.tmp_update/runtime.sh +++ b/static/build/.tmp_update/runtime.sh @@ -45,6 +45,13 @@ main() { fi init_system + # A remote mutation may have lost power after persisting its bounded prior + # state. Recover that exact known-good station and service baseline before + # ordinary boot networking consumes the saved Wi-Fi preference. + if [ -x "$sysdir/bin/bloom-network-transaction" ]; then + bloom-network-transaction boot-recover >> "$sysdir/logs/bloom-network-transaction.log" 2>&1 || + log "Bloom network transaction boot recovery failed" + fi update_time # Remount passwd/group to add our own users diff --git a/test/shell/dev_deploy.bats b/test/shell/dev_deploy.bats index 16badbaad..f14d137d2 100644 --- a/test/shell/dev_deploy.bats +++ b/test/shell/dev_deploy.bats @@ -168,6 +168,7 @@ stage_network_bundle() { bin/bloom-listener-audit \ bin/bloom-wifi \ config/transfer-policy.json \ + runtime.sh \ script/network/update_networking.sh \ script/network/wpsclient.sh; do mkdir -p "$payload/$(dirname "$path")" diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index c130d6ac1..c05582bdd 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -323,6 +323,47 @@ EOF [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] } +@test "boot recovery restores an interrupted Wi-Fi mutation and removes only its empty stale lock" { + begin_transaction + "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + mkdir "$TX_ROOT/.lock" + + run "$SERVICE" boot-recover + [ "$status" -eq 0 ] + [[ "$output" == *'"recovery":"restored"'* ]] + [ ! -e "$TX_ROOT/.lock" ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .failure_reason "$TX_ROOT/state.json")" = boot_recovery ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + grep -F 'network request enable' "$TRANSACTION_LOG" +} + +@test "boot recovery is a no-op without an armed transaction and rejects unsafe stale locks" { + run "$SERVICE" boot-recover + [ "$status" -eq 0 ] + [[ "$output" == *'"recovery":"not_required"'* ]] + + begin_transaction + "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + mkdir "$TX_ROOT/.lock" + : >"$TX_ROOT/.lock/owner" + run "$SERVICE" boot-recover + [ "$status" -ne 0 ] + [[ "$output" == *'boot recovery lock is not empty'* ]] + [ -f "$TX_ROOT/.lock/owner" ] +} + +@test "runtime performs transaction recovery after settings load and before ordinary networking" { + runtime=/workspace/static/build/.tmp_update/runtime.sh + init_line="$(grep -n '^ init_system$' "$runtime" | cut -d: -f1)" + recovery_line="$(grep -n ' bloom-network-transaction boot-recover' "$runtime" | cut -d: -f1)" + networking_line="$(grep -n '^ start_networking$' "$runtime" | cut -d: -f1)" + + [ "$recovery_line" -gt "$init_line" ] + [ "$recovery_line" -lt "$networking_line" ] +} + @test "mutation failure automatically rolls back before returning failure" { begin_transaction run env NETWORK_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats index 723d64b08..42f7528eb 100755 --- a/test/shell/transfer_policy.bats +++ b/test/shell/transfer_policy.bats @@ -114,6 +114,7 @@ teardown() { bin/bloom-listener-audit \ bin/bloom-wifi \ config/transfer-policy.json \ + runtime.sh \ script/network/update_networking.sh \ script/network/wpsclient.sh; do mkdir -p "$payload/$(dirname "$path")" @@ -128,7 +129,7 @@ teardown() { run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ net02-fixture 0123456789abcdef0123456789abcdef01234567 "$manifest" [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 - [ "$(jq '.files | length' "$manifest")" -eq 16 ] + [ "$(jq '.files | length' "$manifest")" -eq 17 ] rm "$payload/bin/bloom-platform" run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index 29fd5d46d..f87843ae6 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -54,6 +54,7 @@ "bin/bloom-listener-audit", "bin/bloom-wifi", "config/transfer-policy.json", + "runtime.sh", "script/network/update_networking.sh", "script/network/wpsclient.sh", }, From 5017d2ef9e863df9f3593e2aebf39fc04f6efda5 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 20:13:41 -0400 Subject: [PATCH 24/29] fix(network): persist boot recovery to device profile --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +-- docs/program/BloomOS_Work_Program_v3.json | 6 +++- static/build/.tmp_update/runtime.sh | 30 ++++++++++++++--- test/shell/network_transaction.bats | 40 ++++++++++++++++++++++- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 19a7d2cb6..28be8e74a 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314634522 +byte_count = 314635220 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 1359c7ca5..b0fe533c8 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -85,8 +85,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/runtime.sh", "file_count": 1, - "byte_count": 44353, - "tree_sha256": "69f06fd6088ab20e80ef10c013b70f79c6b3c754ffdb94738e84b51523b3a023", + "byte_count": 45051, + "tree_sha256": "c6a9227bdbe235e730a9610bff62444889e975b7de8ba135f9c3c639948d657a", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 2aeb78d90..4bedaecc0 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -787,7 +787,11 @@ "signed Full candidates aff110ba from workflow 32969798436 and ef9dbec5 from workflow 33025501252 both failed to restore reachable key-only SSH; ef9dbec5 exposed carrier-only readiness with no global IPv4 address, an unresponsive request path, and missing DHCP recovery before automatic rollback to retained known-good b073b423", "the corrected host candidate requires carrier plus global IPv4, reports carrier-only as acquiring_address, restarts only inherited udhcpc when address acquisition is missing, and performs delayed service reconciliation from a detached bounded watcher whose startup must be acknowledged and whose launch failure is rejected, so UI and boot request paths remain bounded without silently losing listener restoration", "an isolated exact-hash BusyBox ash fixture on the Mini Plus exercised the corrected DHCP recovery without changing its live radio, route, listeners, or processes; a guarded Warm overlay was refused outside the legacy allowlist and an allowlisted one-file attempt self-restored, exposing a legacy rollback replay failure that now has a focused transaction regression", - "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the device is retained on known-good b073b423 with IPv4 and route restored, SSH on and key-only, SFTP and Samba off, an IDLE session, and a clean listener audit; the corrected signed durable Mini Plus gate remains pending and no additional Full or Warm deployment is authorized from this checkpoint" + "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the device is retained on known-good b073b423 with IPv4 and route restored, SSH on and key-only, SFTP and Samba off, an IDLE session, and a clean listener audit; the corrected signed durable Mini Plus gate remains pending and no additional Full or Warm deployment is authorized from this checkpoint", + "exact head 72ec706926a1276e1650ecacf1eee633ade96b0b passed all ordinary checks, exhaustive workflow 33118211692, and protected hardware workflow 33118213384; its signed archive is retained but was never staged or activated because the Mini Plus remained in recovery", + "the failed real Wi-Fi-disable gate preserved transaction tx-5415-933 as rollback_failed with its recovery lease armed and exposed that a persisted per-device wifi=0 profile could override a settings-only offline repair on the next boot; the complete active development overlay was subsequently restored from 14 signed known-good backups plus one reviewed policy backup, with zero hash mismatches and the sealed slot retained", + "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", + "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; the focused transaction/runtime gate passed 23/23 and the optimized complete shell gate passed 658/658 after manifest, provenance, transfer-policy, and exact 314635220-byte dependency preflight ran once" ] }, { diff --git a/static/build/.tmp_update/runtime.sh b/static/build/.tmp_update/runtime.sh index 7011a40c4..0c8105fe6 100644 --- a/static/build/.tmp_update/runtime.sh +++ b/static/build/.tmp_update/runtime.sh @@ -47,11 +47,11 @@ main() { init_system # A remote mutation may have lost power after persisting its bounded prior # state. Recover that exact known-good station and service baseline before - # ordinary boot networking consumes the saved Wi-Fi preference. - if [ -x "$sysdir/bin/bloom-network-transaction" ]; then - bloom-network-transaction boot-recover >> "$sysdir/logs/bloom-network-transaction.log" 2>&1 || - log "Bloom network transaction boot recovery failed" - fi + # ordinary boot networking consumes the saved Wi-Fi preference. A restored + # root setting must also replace the per-device boot profile immediately; + # otherwise a later hard power loss can copy the failed preference back + # after the recovery lease has already closed. + recover_network_transaction || : update_time # Remount passwd/group to add our own users @@ -1222,6 +1222,26 @@ save_settings() { fi } +recover_network_transaction() { + [ -x "$sysdir/bin/bloom-network-transaction" ] || return 0 + recovery_result="$("$sysdir/bin/bloom-network-transaction" boot-recover 2>&1)" + recovery_status=$? + printf '%s\n' "$recovery_result" >> "$sysdir/logs/bloom-network-transaction.log" + if [ "$recovery_status" -ne 0 ]; then + log "Bloom network transaction boot recovery failed" + return 1 + fi + case "$recovery_result" in + *'"recovery":"restored"'*) + if ! save_settings; then + log "Bloom network transaction recovery profile persistence failed" + return 1 + fi + ;; + esac + return 0 +} + update_time() { if time_result=$($sysdir/bin/bloom-time reconcile); then log "$time_result" diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index c05582bdd..a73a53ec0 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -357,13 +357,51 @@ EOF @test "runtime performs transaction recovery after settings load and before ordinary networking" { runtime=/workspace/static/build/.tmp_update/runtime.sh init_line="$(grep -n '^ init_system$' "$runtime" | cut -d: -f1)" - recovery_line="$(grep -n ' bloom-network-transaction boot-recover' "$runtime" | cut -d: -f1)" + recovery_line="$(grep -n '^ recover_network_transaction || :$' "$runtime" | cut -d: -f1)" networking_line="$(grep -n '^ start_networking$' "$runtime" | cut -d: -f1)" [ "$recovery_line" -gt "$init_line" ] [ "$recovery_line" -lt "$networking_line" ] } +@test "runtime persists only a restored boot transaction to the device profile" { + runtime=/workspace/static/build/.tmp_update/runtime.sh + sysdir="$BATS_TEST_TMPDIR/system" + calls="$BATS_TEST_TMPDIR/calls" + mkdir -p "$sysdir/bin" "$sysdir/logs" + cat >"$sysdir/bin/bloom-network-transaction" <<'EOF' +#!/bin/sh +case "${RECOVERY_RESULT:-not_required}" in + restored) printf '%s\n' '{"schema":1,"recovery":"restored"}' ;; + not_required) printf '%s\n' '{"schema":1,"recovery":"not_required"}' ;; + failed) printf '%s\n' 'recovery failed'; exit 1 ;; +esac +EOF + chmod +x "$sysdir/bin/bloom-network-transaction" + save_settings() { + printf '%s\n' save >> "$calls" + [ "${SAVE_SETTINGS_FAIL:-0}" != 1 ] + } + log() { printf 'log:%s\n' "$*" >> "$calls"; } + eval "$(sed -n '/^recover_network_transaction() {/,/^}/p' "$runtime")" + + export RECOVERY_RESULT=restored + recover_network_transaction + [ "$(grep -Fc save "$calls")" -eq 1 ] + + export RECOVERY_RESULT=not_required + recover_network_transaction + [ "$(grep -Fc save "$calls")" -eq 1 ] + + export RECOVERY_RESULT=restored SAVE_SETTINGS_FAIL=1 + ! recover_network_transaction + grep -F 'recovery profile persistence failed' "$calls" + + export RECOVERY_RESULT=failed SAVE_SETTINGS_FAIL=0 + ! recover_network_transaction + grep -F 'boot recovery failed' "$calls" +} + @test "mutation failure automatically rolls back before returning failure" { begin_transaction run env NETWORK_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable From 6f83b9b1788e9f8685b62704de4bfa70c33d54d5 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 20:31:34 -0400 Subject: [PATCH 25/29] fix(network): persist Wi-Fi preference to device profile --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +-- docs/program/BloomOS_Work_Program_v3.json | 2 +- static/build/.tmp_update/bin/bloom-network | 34 ++++++++++++++++++++-- test/shell/network_status.bats | 29 ++++++++++++++++-- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 28be8e74a..9be775d06 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314635220 +byte_count = 314636496 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index b0fe533c8..c05a76eae 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 7307, - "tree_sha256": "d52061ae44bbb85f117a372a3ffd8a9996e3d18e77bdc260a0843b7ae5576c79", + "byte_count": 8583, + "tree_sha256": "ee0dd293f4c6e56efd7c853203db60209d116b8f8858a51aaa0734019f11662b", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 4bedaecc0..090387833 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -791,7 +791,7 @@ "exact head 72ec706926a1276e1650ecacf1eee633ade96b0b passed all ordinary checks, exhaustive workflow 33118211692, and protected hardware workflow 33118213384; its signed archive is retained but was never staged or activated because the Mini Plus remained in recovery", "the failed real Wi-Fi-disable gate preserved transaction tx-5415-933 as rollback_failed with its recovery lease armed and exposed that a persisted per-device wifi=0 profile could override a settings-only offline repair on the next boot; the complete active development overlay was subsequently restored from 14 signed known-good backups plus one reviewed policy backup, with zero hash mismatches and the sealed slot retained", "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", - "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; the focused transaction/runtime gate passed 23/23 and the optimized complete shell gate passed 658/658 after manifest, provenance, transfer-policy, and exact 314635220-byte dependency preflight ran once" + "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index 416159a70..30ac2c740 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -2,14 +2,18 @@ set -eu ROOT=${BLOOM_NETWORK_ROOT:-} +SD_ROOT="$ROOT/mnt/SDCARD" +SYSTEM_ROOT="$SD_ROOT/.tmp_update" PLATFORM=${BLOOM_PLATFORM_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-platform} JSONVAL=${BLOOM_NETWORK_JSONVAL_BIN:-/customer/app/jsonval} -LIB_DIR=${BLOOM_NETWORK_LIB_DIR:-$ROOT/mnt/SDCARD/.tmp_update/lib} +LIB_DIR=${BLOOM_NETWORK_LIB_DIR:-$SYSTEM_ROOT/lib} BACKEND=${BLOOM_NETWORK_BACKEND_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} COMPAT=${BLOOM_NETWORK_COMPAT_BIN:-/mnt/SDCARD/.tmp_update/script/network/update_networking.sh} SETTINGS=${BLOOM_SETTINGS_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-settings} SERVICES=${BLOOM_NETWORK_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} IP=${BLOOM_NETWORK_IP_BIN:-/bin/ip} +READ_UUID=${BLOOM_NETWORK_READ_UUID_BIN:-/mnt/SDCARD/.tmp_update/bin/read_uuid} +DEVICE_SETTINGS_ROOT=${BLOOM_NETWORK_DEVICE_SETTINGS_ROOT:-$SYSTEM_ROOT/config/system} result() { printf '{"schema":1,"service":"bloom-network","available":%s,"wifi_capable":%s,"wifi_enabled":%s,"associated":%s,"state":"%s"}\n' \ @@ -33,6 +37,27 @@ wifi_capability() { reconcile_services() { [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1; } +persist_device_profile() { + [ -f "$SD_ROOT/system.json" ] && [ ! -L "$SD_ROOT/system.json" ] || return 1 + [ -d "$DEVICE_SETTINGS_ROOT" ] && [ ! -L "$DEVICE_SETTINGS_ROOT" ] || return 1 + [ -x "$READ_UUID" ] || return 1 + device_uuid="$($READ_UUID 2>/dev/null)" || return 1 + [ "${#device_uuid}" -eq 12 ] || return 1 + case "$device_uuid" in *[!0-9A-Fa-f]*) return 1 ;; esac + device_profile="$DEVICE_SETTINGS_ROOT/$device_uuid.json" + [ ! -L "$device_profile" ] || return 1 + device_profile_tmp="$device_profile.tmp.$$" + { [ ! -e "$device_profile_tmp" ] && [ ! -L "$device_profile_tmp" ]; } || return 1 + if ! cp "$SD_ROOT/system.json" "$device_profile_tmp"; then + rm -f "$device_profile_tmp" + return 1 + fi + if ! mv -f "$device_profile_tmp" "$device_profile"; then + rm -f "$device_profile_tmp" + return 1 + fi +} + restore_preference() { restore_desired=$1 "$SETTINGS" set device.wifi_enabled "$restore_desired" >/dev/null 2>&1 || return 1 @@ -45,7 +70,8 @@ restore_preference() { [ "$restore_status" -eq 3 ] && [ -x "$COMPAT" ] && "$COMPAT" check >/dev/null 2>&1 || return 1 fi - reconcile_services + reconcile_services || return 1 + persist_device_profile } rollback_preference_failure() { @@ -167,6 +193,10 @@ set_preference() { rollback_preference_failure "$operation" remote_services_failed "$previous_desired" return 1 } + persist_device_profile || { + rollback_preference_failure "$operation" profile_persistence_failed "$previous_desired" + return 1 + } preference_result "$operation" true true applied } diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index c5e3bb23b..8870af086 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -11,15 +11,19 @@ setup() { export BLOOM_SETTINGS_BIN="$BATS_TEST_TMPDIR/bloom-settings" export BLOOM_NETWORK_SERVICES_BIN="$BATS_TEST_TMPDIR/bloom-network-services" export BLOOM_NETWORK_IP_BIN="$BATS_TEST_TMPDIR/ip" + export BLOOM_NETWORK_READ_UUID_BIN="$BATS_TEST_TMPDIR/read-uuid" + export BLOOM_NETWORK_DEVICE_SETTINGS_ROOT="$BLOOM_NETWORK_ROOT/mnt/SDCARD/.tmp_update/config/system" export BACKEND_LOG="$BATS_TEST_TMPDIR/backend.log" export COMPAT_LOG="$BATS_TEST_TMPDIR/compat.log" export SETTINGS_LOG="$BATS_TEST_TMPDIR/settings.log" export SERVICES_LOG="$BATS_TEST_TMPDIR/services.log" export APPLY_LOG="$BATS_TEST_TMPDIR/apply.log" - mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" + mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT" printf up >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/carrier" : >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" + printf '%s\n' '{"wifi":1}' >"$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json" + printf '#!/bin/sh\nprintf 5E649300ABEA\n' >"$BLOOM_NETWORK_READ_UUID_BIN" printf '#!/bin/sh\nprintf available\n' >"$BLOOM_PLATFORM_BIN" printf '#!/bin/sh\n[ "$1" = wifi ] || exit 2\nprintf 1\n' >"$BLOOM_NETWORK_JSONVAL_BIN" cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' @@ -36,6 +40,12 @@ SH #!/bin/sh printf '%s\n' "$*" >"$SETTINGS_LOG" printf 'settings\n' >>"$APPLY_LOG" +case "$3" in + true) wifi=1 ;; + false) wifi=0 ;; + *) exit 2 ;; +esac +printf '{"wifi":%s}\n' "$wifi" >"$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json" SH cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' #!/bin/sh @@ -47,7 +57,7 @@ SH [ -e "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' SH - chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" "$BLOOM_NETWORK_IP_BIN" + chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" "$BLOOM_NETWORK_IP_BIN" "$BLOOM_NETWORK_READ_UUID_BIN" } @test "status does not claim a usable association before DHCP publishes IPv4" { @@ -162,6 +172,7 @@ SH [ "$(cat "$COMPAT_LOG")" = services ] [ "$(cat "$SERVICES_LOG")" = reconcile ] [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend compat services ' ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json")" = 1 ] rm -f "$COMPAT_LOG" "$SERVICES_LOG" "$APPLY_LOG" run "$SERVICE" request disable @@ -172,6 +183,20 @@ SH [ ! -e "$COMPAT_LOG" ] [ "$(cat "$SERVICES_LOG")" = reconcile ] [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend services ' ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json")" = 0 ] +} + +@test "preference persistence rejects a symlinked device profile and restores the prior state" { + outside="$BATS_TEST_TMPDIR/outside-profile" + printf '%s\n' 'do-not-overwrite' >"$outside" + ln -s "$outside" "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json" + + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"profile_persistence_failed_rollback_failed"'* ]] + [ "$(cat "$outside")" = do-not-overwrite ] + [ -L "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json" ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json")" = 1 ] } @test "failed preference apply restores the prior preference and services" { From fced08216e680fedda39b2787045820c213ff6fb Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 21:41:14 -0400 Subject: [PATCH 26/29] fix(network): stop inherited BusyBox telnet listener --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 8 ++-- docs/program/BloomOS_Work_Program_v3.json | 3 +- .../.tmp_update/bin/bloom-listener-audit | 2 +- .../script/network/update_networking.sh | 24 ++++++++++++ test/shell/listener_lockdown.bats | 39 ++++++++++++++++++- 6 files changed, 69 insertions(+), 9 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 9be775d06..d33f6e127 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314636496 +byte_count = 314637578 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index c05a76eae..53ca133d9 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -592,8 +592,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-listener-audit", "file_count": 1, - "byte_count": 2713, - "tree_sha256": "76dd00d7010451a0e519e451c388a7473a90c83a421b071d3ae3b8b208c0ff1f", + "byte_count": 2723, + "tree_sha256": "d0d0cc448e9122be7275d73c474619bb0515a9785b7beecf11af0574f73756ed", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -2308,8 +2308,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/script/network", "file_count": 7, - "byte_count": 32703, - "tree_sha256": "2b826c557f9d7c948a61e9e5a45e98f111f973e80d55b70b089b1ca2d73a9289", + "byte_count": 33775, + "tree_sha256": "b8a1ea55882c2d64a63debcaf88f8bb2f45ea3038a149eab8b4877e0c97eb6df", "source": "https://github.com/OnionUI/Onion", "source_revision": "07505ea58c7bba698d6b9220ff43946a43cac76b", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 090387833..cd5650c09 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -791,7 +791,8 @@ "exact head 72ec706926a1276e1650ecacf1eee633ade96b0b passed all ordinary checks, exhaustive workflow 33118211692, and protected hardware workflow 33118213384; its signed archive is retained but was never staged or activated because the Mini Plus remained in recovery", "the failed real Wi-Fi-disable gate preserved transaction tx-5415-933 as rollback_failed with its recovery lease armed and exposed that a persisted per-device wifi=0 profile could override a settings-only offline repair on the next boot; the complete active development overlay was subsequently restored from 14 signed known-good backups plus one reviewed policy backup, with zero hash mismatches and the sealed slot retained", "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", - "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once" + "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once", + "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-listener-audit b/static/build/.tmp_update/bin/bloom-listener-audit index 5567ff118..2e52b3408 100755 --- a/static/build/.tmp_update/bin/bloom-listener-audit +++ b/static/build/.tmp_update/bin/bloom-listener-audit @@ -41,7 +41,7 @@ process_present() { } listener_present '(:|[.])21[[:space:]].*(bftpd|ftpd|ftp)' && append_unique prohibited ftp -listener_present '(:|[.])23[[:space:]].*telnetd' && append_unique prohibited telnet +listener_present '(:|[.])23[[:space:]].*(telnetd|busybox)' && append_unique prohibited telnet listener_present '(:|[.])(80|8080)[[:space:]].*(filebrowser|uhttpd)' && append_unique prohibited filebrowser_http listener_present '(:|[.])(137|138|139|445)[[:space:]].*smbd' && append_unique prohibited samba listener_present '(:|[.])(53|67)[[:space:]].*(dnsmasq|hostapd)' && append_unique prohibited hotspot diff --git a/static/build/.tmp_update/script/network/update_networking.sh b/static/build/.tmp_update/script/network/update_networking.sh index 4eca53369..8b1371684 100644 --- a/static/build/.tmp_update/script/network/update_networking.sh +++ b/static/build/.tmp_update/script/network/update_networking.sh @@ -1,5 +1,7 @@ #!/bin/sh root=${BLOOM_NETWORK_ROOT:-} +procroot=${BLOOM_NETWORK_PROC_ROOT:-/proc} +killbin=${BLOOM_NETWORK_KILL_BIN:-kill} sysdir=$root/mnt/SDCARD/.tmp_update miyoodir=$root/mnt/SDCARD/miyoo customerdir=$root/customer @@ -103,11 +105,33 @@ check_services() { # Bloom retains this compatibility script only for station bring-up, time, OTA, # and delegation to the reviewed key-only SSH/SFTP owner. Inherited listener # flags are never authoritative and cannot activate a service. +stop_busybox_telnet() { + for process_dir in "$procroot"/[0-9]*; do + [ -d "$process_dir" ] && [ ! -L "$process_dir" ] && + [ -r "$process_dir/cmdline" ] && [ ! -L "$process_dir/cmdline" ] || continue + process_id=${process_dir##*/} + case "$process_id" in ''|*[!0-9]*) continue ;; esac + command_line=$(tr '\000' ' ' <"$process_dir/cmdline" 2>/dev/null || true) + case "$command_line" in + 'busybox telnetd'|'busybox telnetd '|*/busybox\ telnetd|*/busybox\ telnetd\ ) + if "$killbin" -9 "$process_id" 2>/dev/null; then + log "Bloom security: stopped inherited busybox telnetd process" + fi + ;; + esac + done +} + lockdown_legacy_listeners() { for service in $legacy_services; do disable_flag "$service" done + # The firmware launches Telnet as the BusyBox applet `busybox telnetd`. + # Its process name is therefore `busybox`, so pgrep/killall by `telnetd` + # cannot see it. Match only the exact applet argv and kill only that PID. + stop_busybox_telnet + for process in bftpd filebrowser telnetd hostapd dnsmasq smbd; do if is_running "$process"; then killall -9 "$process" diff --git a/test/shell/listener_lockdown.bats b/test/shell/listener_lockdown.bats index 0a1e3d1b4..a9898c592 100644 --- a/test/shell/listener_lockdown.bats +++ b/test/shell/listener_lockdown.bats @@ -38,12 +38,12 @@ EOF @test "listener audit reports inherited listeners and processes without exposing addresses" { cat >"$INPUT/listeners" <<'EOF' tcp 0 0 0.0.0.0:21 0.0.0.0:* LISTEN 10/bftpd -tcp 0 0 0.0.0.0:23 0.0.0.0:* LISTEN 11/telnetd +tcp 0 0 0.0.0.0:23 0.0.0.0:* LISTEN 11/busybox tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 12/filebrowser tcp 0 0 0.0.0.0:445 0.0.0.0:* LISTEN 13/smbd udp 0 0 0.0.0.0:53 0.0.0.0:* 14/dnsmasq EOF - printf '%s\n' '10 bftpd' '11 telnetd' '12 filebrowser' '13 smbd' '14 dnsmasq' \ + printf '%s\n' '10 bftpd' '11 busybox telnetd' '12 filebrowser' '13 smbd' '14 dnsmasq' \ >"$INPUT/processes" run env BLOOM_LISTENER_AUDIT_INPUT_DIR="$INPUT" sh "$AUDIT" @@ -55,6 +55,41 @@ EOF [[ "$output" != *'0.0.0.0'* ]] } +@test "stable compatibility reconciliation stops the exact BusyBox telnet applet" { + root="$BATS_TEST_TMPDIR/root" + procroot="$BATS_TEST_TMPDIR/proc" + sysdir="$root/mnt/SDCARD/.tmp_update" + mock="$BATS_TEST_TMPDIR/mock" + calls="$BATS_TEST_TMPDIR/calls" + mkdir -p "$sysdir/config" "$sysdir/script" "$sysdir/bin" "$root/customer/app" \ + "$mock" "$procroot/3605" "$procroot/4000" "$BATS_TEST_TMPDIR/outside-process" + printf 'password\n' >"$sysdir/config/.password.txt" + printf '%s\n' 'log() { :; }' 'bootScreen() { :; }' >"$sysdir/script/log.sh" + printf '#!/bin/sh\nprintf 0\n' >"$root/customer/app/jsonval" + printf '#!/bin/sh\nexit 0\n' >"$sysdir/bin/bloom-dev-ssh" + printf 'busybox\000telnetd\000' >"$procroot/3605/cmdline" + printf 'sh\000-c\000echo busybox telnetd\000' >"$procroot/4000/cmdline" + printf 'busybox\000telnetd\000' >"$BATS_TEST_TMPDIR/outside-process/cmdline" + ln -s "$BATS_TEST_TMPDIR/outside-process" "$procroot/5000" + cat >"$mock/pgrep" <<'EOF' +#!/bin/sh +exit 1 +EOF + cat >"$mock/kill" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$CALLS" +EOF + chmod +x "$root/customer/app/jsonval" "$sysdir/bin/bloom-dev-ssh" "$mock/pgrep" "$mock/kill" + + run env BLOOM_NETWORK_ROOT="$root" BLOOM_NETWORK_PROC_ROOT="$procroot" \ + BLOOM_NETWORK_KILL_BIN="$mock/kill" CALLS="$calls" PATH="$mock:$PATH" sh "$COMPAT" services + + [ "$status" -eq 0 ] + grep -Fx -- '-9 3605' "$calls" + ! grep -Fq -- '4000' "$calls" + ! grep -Fq -- '5000' "$calls" +} + @test "stable compatibility reconciliation ignores stale flags and stops inherited processes" { root="$BATS_TEST_TMPDIR/root" sysdir="$root/mnt/SDCARD/.tmp_update" From e04539d7533c09f16a3b4d9cfe5f1aa21c467274 Mon Sep 17 00:00:00 2001 From: boburning Date: Thu, 27 Aug 2026 22:21:21 -0400 Subject: [PATCH 27/29] fix(network): avoid replaying untouched watchdog baseline --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- docs/program/BloomOS_Work_Program_v3.json | 3 +- .../.tmp_update/bin/bloom-network-transaction | 2 + test/shell/network_transaction.bats | 45 +++++++++++++++++++ 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index d33f6e127..46713fcf7 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314637578 +byte_count = 314637653 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 53ca133d9..40f590c9d 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 27604, - "tree_sha256": "688c7dcc6eacaf5b58b7e2f805759b4dfd1bb8eb1b556678d8257b373ee095fd", + "byte_count": 27679, + "tree_sha256": "a983ce90ac345983d939c63898a06b13c1c61b07fcbaf8d15714bccbb6f701b9", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index cd5650c09..11d271bc7 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -792,7 +792,8 @@ "the failed real Wi-Fi-disable gate preserved transaction tx-5415-933 as rollback_failed with its recovery lease armed and exposed that a persisted per-device wifi=0 profile could override a settings-only offline repair on the next boot; the complete active development overlay was subsequently restored from 14 signed known-good backups plus one reviewed policy backup, with zero hash mismatches and the sealed slot retained", "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once", - "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet" + "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet", + "exact head fced08216e680fedda39b2787045820c213ff6fb passed ordinary, exhaustive, protected signing, release metadata, signature, CRC, payload reconstruction, sealed-bundle, and isolated BusyBox fixture gates; its confirmed 17-file Warm overlay kept Wi-Fi associated and listeners clean, but the no-mutation watchdog unnecessarily replayed an already-matching prior network state, briefly restarted SSH, and ended rollback_failed after the endpoint had automatically recovered; no real Wi-Fi disable was attempted, the exact baseline was verified twice before closing the lease, and the overlay was rolled back to signed b073b423; the next host correction closes an untouched timed transaction directly when its saved baseline already matches and retains bounded restoration when it does not" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 1fb04dcb9..9dbc35dec 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -352,6 +352,8 @@ rollback_locked() { fi if [ "$operation" = ssh-shadow ]; then verify_prior_unchanged || restored=false + elif [ "$operation" = none ] && verify_prior_unchanged; then + : elif [ "$operation" = network-overlay ] && wait_for_prior_unchanged 15; then : else diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index a73a53ec0..4a0fe0b89 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -429,6 +429,51 @@ EOF grep -F 'services request ssh enable' "$TRANSACTION_LOG" } +@test "watchdog closes an untouched transaction without replaying the network baseline" { + begin_transaction + printf '%s\n' 116 >"$TIME_FILE" + transaction_id="$(jq -r .transaction_id "$TX_ROOT/state.json")" + + run "$SERVICE" watchdog "$transaction_id" + + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .failure_reason "$TX_ROOT/state.json")" = confirmation_timeout ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + ! grep -Fq 'network request' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" +} + +@test "untouched watchdog retains bounded restoration when the saved baseline drifted" { + begin_transaction + status_count="$BATS_TEST_TMPDIR/untouched-status-count" + printf '%s\n' 0 >"$status_count" + export UNTOUCHED_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$UNTOUCHED_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$UNTOUCHED_STATUS_COUNT" + if [ "$count" -eq 1 ]; then associated=false; else associated=true; fi + printf '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":%s}\n' "$associated" + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + chmod +x "$BLOOM_NETWORK_BIN" + printf '%s\n' 116 >"$TIME_FILE" + transaction_id="$(jq -r .transaction_id "$TX_ROOT/state.json")" + + run "$SERVICE" watchdog "$transaction_id" + + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + grep -F 'network request enable' "$TRANSACTION_LOG" + grep -F 'services reconcile' "$TRANSACTION_LOG" +} + @test "active update session conflicts and unsafe state fail closed" { run env UPDATE_PHASE=testing "$SERVICE" begin 15 [ "$status" -ne 0 ] From 6565d34874600d42e7b60cdb75f08a9e4c9b3791 Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 07:10:09 -0400 Subject: [PATCH 28/29] fix(network): reject unqualified remote Wi-Fi disable --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 +- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 5 +- docs/DEVELOPMENT.md | 10 ++-- docs/SECURITY.md | 11 +++-- docs/program/BloomOS_Work_Program_v3.json | 3 +- .../.tmp_update/bin/bloom-network-transaction | 4 +- test/shell/bloom_device.bats | 3 ++ test/shell/network_transaction.bats | 46 +++++++++++++------ tools/bloom-device | 7 ++- 11 files changed, 65 insertions(+), 32 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 46713fcf7..aaafa1598 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 330 file_count = 2129 -byte_count = 314637653 +byte_count = 314637632 source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 40f590c9d..f7d72a69c 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 27679, - "tree_sha256": "a983ce90ac345983d939c63898a06b13c1c61b07fcbaf8d15714bccbb6f701b9", + "byte_count": 27658, + "tree_sha256": "96398c18ceb12113691600ebdf18de86ff8a1dc96022ecaa9d0111f851495efe", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 0c084f233..751869207 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,7 +23,7 @@ observable properties and never substitutes for physical UX review. | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | -| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, timeout, and boot-interruption restoration; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; unqualified remote Wi-Fi disable rejected before state or radio mutation; legacy Wi-Fi-disable recovery; apply-failure, explicit, timeout, and boot-interruption restoration; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | ## Task-based UX certification diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d9134950..387d6c87e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -471,7 +471,10 @@ service preferences in private schema-1 state, stores only a SHA-256 nonce digest, publishes a separate schema-1 recovery lease containing only monotonic bounds and the fixed known-good-station/key-only-SSH allowlist, and detaches a watchdog before returning the one-time capability. `apply` permits one fixed mutation and -publishes `applying` before invoking its canonical owner. `confirm` consumes the +publishes `applying` before invoking its canonical owner. Remote Wi-Fi disable +fails before that publication because a real Mini Plus radio-cycle did not +restore the sole automation route; historical persisted Wi-Fi-disable state +remains recoverable for backward compatibility. `confirm` consumes the capability only while the deadline and `applied` phase remain valid. `rollback` and the watchdog restore canonical preferences, reconcile service state, undo an activated network overlay, audit listeners, and publish a terminal phase. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0146e5b5b..664390061 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -256,10 +256,12 @@ incomplete record, identity mismatch, symlink, or non-empty lock fails closed. Confirmation never reuses the OpenSSH control socket. It rechecks the configured model, station association, key-only SSH runtime, prohibited listeners, aggregate `bloomctl health --json`, and active overlay identity over independent -connections. The host deliberately refuses to confirm `wifi-disable` or -`ssh-disable`; those sole-route operations must be explicitly rolled back or -allowed to expire under the watchdog. Transaction state cannot create either -developer marker or alter signed update identity. +connections. The host and device reject a new `wifi-disable` apply before +persistent state or radio mutation because the Mini Plus radio-cycle restore +path is not qualified. Historical Wi-Fi-disable state remains recoverable. The +host also refuses to confirm `ssh-disable`; that sole-route operation must be +explicitly rolled back or allowed to expire under the watchdog. Transaction +state cannot create either developer marker or alter signed update identity. An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e2fb516b8..79c9fc42f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -50,10 +50,13 @@ Remote network mutation adds a distinct local enablement marker and a known-good associated station, running key-only SSH, retained station profile, valid authorized key, and idle update and session owners. It atomically records only the exact prior preferences and a hash of the one-time nonce, then starts a -detached watchdog before any change. Apply is limited to one fixed Wi-Fi/SSH -preference operation, one sealed network-helper overlay, or the fixed-port -key-only SSH shadow preflight. The shadow never replaces or stops port 22 and -is removed on confirmation, rollback, failure, or expiry. Timeout, apply +detached watchdog before any change. Apply is limited to the non-destructive +fixed Wi-Fi-enable operation, one fixed SSH preference operation, one sealed +network-helper overlay, or the fixed-port key-only SSH shadow preflight. Remote +Wi-Fi disable is rejected because the Mini Plus radio-cycle restore path is not +device-qualified and has no independent recovery network. The shadow never +replaces or stops port 22 and is removed on confirmation, rollback, failure, or +expiry. Timeout, apply failure, and explicit rollback restore prior preferences and listener policy. For a network overlay that has already self-restored, rollback verifies the exact saved preference matrix and listener audit before replaying anything. An diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 11d271bc7..bc3634b06 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -793,7 +793,8 @@ "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once", "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet", - "exact head fced08216e680fedda39b2787045820c213ff6fb passed ordinary, exhaustive, protected signing, release metadata, signature, CRC, payload reconstruction, sealed-bundle, and isolated BusyBox fixture gates; its confirmed 17-file Warm overlay kept Wi-Fi associated and listeners clean, but the no-mutation watchdog unnecessarily replayed an already-matching prior network state, briefly restarted SSH, and ended rollback_failed after the endpoint had automatically recovered; no real Wi-Fi disable was attempted, the exact baseline was verified twice before closing the lease, and the overlay was rolled back to signed b073b423; the next host correction closes an untouched timed transaction directly when its saved baseline already matches and retains bounded restoration when it does not" + "exact head fced08216e680fedda39b2787045820c213ff6fb passed ordinary, exhaustive, protected signing, release metadata, signature, CRC, payload reconstruction, sealed-bundle, and isolated BusyBox fixture gates; its confirmed 17-file Warm overlay kept Wi-Fi associated and listeners clean, but the no-mutation watchdog unnecessarily replayed an already-matching prior network state, briefly restarted SSH, and ended rollback_failed after the endpoint had automatically recovered; no real Wi-Fi disable was attempted, the exact baseline was verified twice before closing the lease, and the overlay was rolled back to signed b073b423; the next host correction closes an untouched timed transaction directly when its saved baseline already matches and retains bounded restoration when it does not", + "a prohibited real sole-route Wi-Fi-disable attempt at e04539d7533c09f16a3b4d9cfe5f1aa21c467274 created transaction tx-6625-4060 and failed all bounded in-session restoration windows; the next boot retried the persisted rollback and failed again while the shell inaccurately reported Wi-Fi connected and the known LAN address and key-only SSH remained unreachable; offline card recovery preserved exact transaction evidence, confirmed signed known-good b073b423 plus the still-active e04539d7 overlay, restored 16 sealed signed-baseline backups, removed the one originally absent overlay file, removed the active pointer, quarantined the failed transaction directory, disabled the developer network-mutation marker, passed a read-only FAT32 chkdsk with no errors, and safely ejected; the host harness and device transaction helper now reject new remote Wi-Fi-disable apply requests before persistent state or radio mutation while retaining recovery support for historical Wi-Fi-disable state" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 9dbc35dec..a197e7c1c 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -380,7 +380,8 @@ command_apply() { overlay=${3:-} [ "$SHADOW_PORT" = 2222 ] || fail "SSH shadow port configuration is invalid" case "$operation" in - wifi-enable|wifi-disable|ssh-enable|ssh-disable|ssh-shadow) [ -z "$overlay" ] || fail "operation has unexpected arguments" ;; + wifi-disable) fail "remote Wi-Fi disable is not device-qualified" ;; + wifi-enable|ssh-enable|ssh-disable|ssh-shadow) [ -z "$overlay" ] || fail "operation has unexpected arguments" ;; network-overlay) safe_id "$overlay" || fail "network overlay id is unsafe" ;; *) fail "network mutation operation is unsupported" ;; esac @@ -395,7 +396,6 @@ command_apply() { applied=true case "$operation" in wifi-enable) "$NETWORK" request enable >/dev/null 2>&1 || applied=false ;; - wifi-disable) "$NETWORK" request disable >/dev/null 2>&1 || applied=false ;; ssh-enable) "$SERVICES" request ssh enable >/dev/null 2>&1 || applied=false ;; ssh-disable) "$SERVICES" request ssh disable >/dev/null 2>&1 || applied=false ;; ssh-shadow) "$SSH_HELPER" shadow-start "$(printf '%s\n' "$state" | "$JQ" -r '.transaction_id')" "$SHADOW_PORT" >/dev/null 2>&1 || applied=false ;; diff --git a/test/shell/bloom_device.bats b/test/shell/bloom_device.bats index f7ae235ae..a1934d5ef 100644 --- a/test/shell/bloom_device.bats +++ b/test/shell/bloom_device.bats @@ -416,6 +416,9 @@ PY [ "$status" -eq 1 ] run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef network-overlay '../escape' [ "$status" -eq 1 ] + run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef wifi-disable + [ "$status" -eq 1 ] + [[ "$output" == *'remote Wi-Fi disable is not device-qualified'* ]] ! grep -Fq 'bloom-network-transaction begin' "$MOCK_LOG" ! grep -Fq 'bloom-network-transaction apply' "$MOCK_LOG" } diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index 4a0fe0b89..f4d41933c 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -44,7 +44,7 @@ if [ "$1" = status ]; then exit fi printf 'services %s %s %s\n' "$1" "${2:-}" "${3:-}" >>"$TRANSACTION_LOG" -[ "${SERVICES_FAIL_REQUEST:-0}" != 1 ] +[ "${SERVICES_FAIL_REQUEST:-0}" != 1 ] || [ "$1:${2:-}:${3:-}" != request:ssh:disable ] EOF cat >"$BLOOM_UPDATE_STATE_BIN" <<'EOF' #!/bin/sh @@ -103,6 +103,14 @@ begin_transaction() { [[ "$output" == *'"recovery_lease":"armed"'* ]] } +seed_legacy_wifi_disable_transaction() { + begin_transaction + temporary="$TX_ROOT/state.json.legacy-wifi-disable" + jq -c '.phase="applied" | .operation="wifi-disable"' "$TX_ROOT/state.json" >"$temporary" + mv "$temporary" "$TX_ROOT/state.json" + printf '%s\n' 'network request disable' >>"$TRANSACTION_LOG" +} + @test "begin requires both local developer markers and a known-good recovery path" { begin_transaction run "$SERVICE" status @@ -192,6 +200,22 @@ EOF [ "$status" -ne 0 ] } +@test "remote Wi-Fi disable is rejected before any persistent mutation" { + begin_transaction + state_before="$(sha256sum "$TX_ROOT/state.json" | awk '{print $1}')" + lease_before="$(sha256sum "$TX_ROOT/recovery-lease.json" | awk '{print $1}')" + + run "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable + + [ "$status" -ne 0 ] + [[ "$output" == *'remote Wi-Fi disable is not device-qualified'* ]] + [ "$(sha256sum "$TX_ROOT/state.json" | awk '{print $1}')" = "$state_before" ] + [ "$(sha256sum "$TX_ROOT/recovery-lease.json" | awk '{print $1}')" = "$lease_before" ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = armed ] + [ "$(jq -r .operation "$TX_ROOT/state.json")" = none ] + ! grep -Fq 'network request disable' "$TRANSACTION_LOG" +} + @test "bounded SSH shadow listener is transaction-authorized and always cleaned up" { begin_transaction run "$SERVICE" apply 0123456789abcdef0123456789abcdef ssh-shadow @@ -223,8 +247,7 @@ EOF } @test "explicit rollback restores exact Wi-Fi and service preferences" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction rm "$SDCARD/.bloom/dev/network-mutation.enabled" run "$SERVICE" rollback [ "$status" -eq 0 ] @@ -237,8 +260,7 @@ EOF } @test "Wi-Fi rollback retries transient radio restore failures" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction enable_count="$BATS_TEST_TMPDIR/enable-count" printf '%s\n' 0 >"$enable_count" export ENABLE_COUNT="$enable_count" @@ -268,8 +290,7 @@ EOF } @test "Wi-Fi rollback retries after an association convergence timeout" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction status_count="$BATS_TEST_TMPDIR/restore-status-count" printf '%s\n' 0 >"$status_count" export RESTORE_STATUS_COUNT="$status_count" @@ -300,8 +321,7 @@ EOF } @test "exhausted Wi-Fi restore attempts fail honestly with the lease armed" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction cat >"$BLOOM_NETWORK_BIN" <<'EOF' #!/bin/sh if [ "$1" = status ]; then @@ -324,8 +344,7 @@ EOF } @test "boot recovery restores an interrupted Wi-Fi mutation and removes only its empty stale lock" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction mkdir "$TX_ROOT/.lock" run "$SERVICE" boot-recover @@ -344,8 +363,7 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *'"recovery":"not_required"'* ]] - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction mkdir "$TX_ROOT/.lock" : >"$TX_ROOT/.lock/owner" run "$SERVICE" boot-recover @@ -404,7 +422,7 @@ EOF @test "mutation failure automatically rolls back before returning failure" { begin_transaction - run env NETWORK_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable + run env SERVICES_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef ssh-disable [ "$status" -ne 0 ] run "$SERVICE" status [[ "$output" == *'"phase":"rolled_back"'* ]] diff --git a/tools/bloom-device b/tools/bloom-device index 19225e66a..26e42dcff 100755 --- a/tools/bloom-device +++ b/tools/bloom-device @@ -15,7 +15,7 @@ Usage: bloom-device [output-directory] bloom-device dev-evidence [output-directory] bloom-device network- bloom-device network-begin [rollback-seconds] - bloom-device network-apply [deployment-id] + bloom-device network-apply [deployment-id] bloom-device network-confirm Targets are read from tools/targets.toml or BLOOM_TARGETS_FILE. Copy @@ -332,7 +332,10 @@ command_network_apply() { local nonce="$1" operation="$2" deployment_id="${3:-}" validate_network_nonce "$nonce" case "$operation" in - wifi-enable|wifi-disable|ssh-enable|ssh-disable|ssh-shadow) + wifi-disable) + die "remote Wi-Fi disable is not device-qualified" + ;; + wifi-enable|ssh-enable|ssh-disable|ssh-shadow) [[ -z "$deployment_id" ]] || die "$operation does not accept a deployment id" ;; network-overlay) From bc6abaf813978682bde3f4d61d7c32411b1f0989 Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 07:37:41 -0400 Subject: [PATCH 29/29] docs(program): mark NET-02 merge ready --- docs/program/BloomOS_Work_Program_v3.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index bc3634b06..732525bff 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -764,7 +764,7 @@ "id": "NET-02", "track": "Network", "title": "Canonical transfer/share policy", - "status": "in_progress", + "status": "merge_ready", "dependencies": [ "NET-01" ], @@ -794,7 +794,10 @@ "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once", "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet", "exact head fced08216e680fedda39b2787045820c213ff6fb passed ordinary, exhaustive, protected signing, release metadata, signature, CRC, payload reconstruction, sealed-bundle, and isolated BusyBox fixture gates; its confirmed 17-file Warm overlay kept Wi-Fi associated and listeners clean, but the no-mutation watchdog unnecessarily replayed an already-matching prior network state, briefly restarted SSH, and ended rollback_failed after the endpoint had automatically recovered; no real Wi-Fi disable was attempted, the exact baseline was verified twice before closing the lease, and the overlay was rolled back to signed b073b423; the next host correction closes an untouched timed transaction directly when its saved baseline already matches and retains bounded restoration when it does not", - "a prohibited real sole-route Wi-Fi-disable attempt at e04539d7533c09f16a3b4d9cfe5f1aa21c467274 created transaction tx-6625-4060 and failed all bounded in-session restoration windows; the next boot retried the persisted rollback and failed again while the shell inaccurately reported Wi-Fi connected and the known LAN address and key-only SSH remained unreachable; offline card recovery preserved exact transaction evidence, confirmed signed known-good b073b423 plus the still-active e04539d7 overlay, restored 16 sealed signed-baseline backups, removed the one originally absent overlay file, removed the active pointer, quarantined the failed transaction directory, disabled the developer network-mutation marker, passed a read-only FAT32 chkdsk with no errors, and safely ejected; the host harness and device transaction helper now reject new remote Wi-Fi-disable apply requests before persistent state or radio mutation while retaining recovery support for historical Wi-Fi-disable state" + "a prohibited real sole-route Wi-Fi-disable attempt at e04539d7533c09f16a3b4d9cfe5f1aa21c467274 created transaction tx-6625-4060 and failed all bounded in-session restoration windows; the next boot retried the persisted rollback and failed again while the shell inaccurately reported Wi-Fi connected and the known LAN address and key-only SSH remained unreachable; offline card recovery preserved exact transaction evidence, confirmed signed known-good b073b423 plus the still-active e04539d7 overlay, restored 16 sealed signed-baseline backups, removed the one originally absent overlay file, removed the active pointer, quarantined the failed transaction directory, disabled the developer network-mutation marker, passed a read-only FAT32 chkdsk with no errors, and safely ejected; the host harness and device transaction helper now reject new remote Wi-Fi-disable apply requests before persistent state or radio mutation while retaining recovery support for historical Wi-Fi-disable state", + "a clean post-recovery boot returned strict key-only SSH on the first probe; signed known-good b073b423 remained installed and healthy with associated Wi-Fi, a default route, clean listener audit, IDLE transaction, inactive lease, no active overlay or pointer, the network-mutation enable marker absent, and the failed four-file transaction evidence quarantined with exact hashes", + "exact code head 6565d34874600d42e7b60cdb75f08a9e4c9b3791 passed every ordinary check plus exhaustive workflow 33166683195 in 3m51s and protected hardware workflow 33166684506 in 9m25s; its 203248605-byte signed archive has SHA-256 dee6cc27cf02ad0604c76acae331d30121765b4a749fe3150ec51375a927f535, complete payload reconstruction, repository Ed25519 verification, exact dependency identity, and changed embedded script bytes matching the reviewed tree", + "the exact signed 27658-byte transaction helper with SHA-256 37f08d8e5502ca1d56ac79448156a88c321b456a61f06cfe2b5a2daad01e9497 was transferred to a bounded Mini Plus temporary path and executed only against an isolated nonexistent root; remote Wi-Fi disable was rejected before the root or any transaction state was created, the temporary helper was removed, and fresh inspection retained associated Wi-Fi, IDLE transaction, inactive lease, clean listener audit, signed known-good b073b423, and aggregate health" ] }, {