From 213f689ee970d64a7895757c27be7574d916571b Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:04:24 -0400 Subject: [PATCH 1/4] feat(network): add bounded asynchronous Wi-Fi scan --- build/dependencies.lock | 8 +- build/legacy-manifest.json | 17 +- build/wifi-scan-result.schema.json | 53 +++ docs/1.0_TEST_MATRIX.md | 1 + docs/ARCHITECTURE.md | 11 + docs/DEVELOPMENT.md | 17 + docs/SECURITY.md | 8 + docs/program/BloomOS_Work_Program_v3.json | 13 +- static/build/.tmp_update/bin/bloom-wifi-scan | 425 +++++++++++++++++++ static/build/.tmp_update/bin/bloomctl | 15 +- test/shell/bloomctl_info.bats | 25 ++ test/shell/legacy_manifest.bats | 4 +- test/shell/wifi_scan.bats | 279 ++++++++++++ tools/legacy_manifest.py | 1 + 14 files changed, 862 insertions(+), 15 deletions(-) create mode 100644 build/wifi-scan-result.schema.json create mode 100755 static/build/.tmp_update/bin/bloom-wifi-scan create mode 100644 test/shell/wifi_scan.bats diff --git a/build/dependencies.lock b/build/dependencies.lock index 6f8144ba9..119fa3c53 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 = 331 -file_count = 2130 -byte_count = 314651736 -source_build_components = 227 +component_count = 332 +file_count = 2131 +byte_count = 314669698 +source_build_components = 228 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index 8b1e21a4d..cd83d494a 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -1003,13 +1003,26 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-wifi-scan", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-wifi-scan", + "file_count": 1, + "byte_count": 17225, + "tree_sha256": "d4ce21a80d8321606d94d6ecc5698f6adecf7c84d8774fa6780210c19957b707", + "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-bloomctl", "kind": "runtime", "path": "static/build/.tmp_update/bin/bloomctl", "file_count": 1, - "byte_count": 34614, - "tree_sha256": "19b5d3dedc13eda3fb584adf271daebf89a38dfeaaf838f3e38e672663980650", + "byte_count": 35351, + "tree_sha256": "db5fa1c268ed71f098c626f80d29edb2f21896469262a7c2acfaeb49e48dad02", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/build/wifi-scan-result.schema.json b/build/wifi-scan-result.schema.json new file mode 100644 index 000000000..d44c807a6 --- /dev/null +++ b/build/wifi-scan-result.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.local/schema/wifi-scan-result-v1.json", + "title": "BloomOS bounded local Wi-Fi scan result", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "service", + "networks" + ], + "properties": { + "schema": { + "const": 1 + }, + "service": { + "const": "bloom-wifi-scan" + }, + "networks": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "ssid", + "security", + "signal_dbm" + ], + "properties": { + "ssid": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "^[ -~]+$" + }, + "security": { + "enum": [ + "open", + "wpa2-psk", + "unsupported" + ] + }, + "signal_dbm": { + "type": "integer", + "minimum": -200, + "maximum": 0 + } + } + } + } + } +} diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 60fed660f..2685682ea 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -48,6 +48,7 @@ shoulder button. | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | | 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 | | Wi-Fi profile store | JFFS2 private root; stdin-only bounded replacement and single-profile upsert; requested/document ID binding; inactive-ID deletion with active-profile refusal; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; mode-0600 atomic single-profile supplicant projection; symlink, occupied-slot, live-lock, invalid-profile, and prior-byte preservation | +| Wi-Fi scan API | Asynchronous start; redacted status; explicit ID-scoped local results; cancel and terminal discard; one active worker; five-second rate limit; 15-second runtime; 64-KiB raw ceiling; 32 unique printable SSIDs; eight-operation history; open/WPA2-PSK/unsupported classification; duplicate signal reconciliation; backend failure, timeout, oversize, unsafe-path, stale/live-lock, and unsupported-hardware handling | | 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 63f1fb24e..6456e8f6e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -299,6 +299,17 @@ The service never scans, associates, changes the Wi-Fi preference, or restarts the radio; those behaviors remain with later NET-04 API work and the existing `bloom-network`/`bloom-wifi` owners. +`bloom-wifi-scan` begins the NET-04 station API as a separate ephemeral local +authority. A start returns immediately with a high-entropy operation ID; only +one worker may run, starts are rate-limited, runtime is capped at 15 seconds, +raw backend output is capped at 64 KiB, results are limited to 32 unique +printable SSIDs, and operation history is capped at eight entries. Status is +redacted and never contains scan results. Results are available only through +an explicit local ID-scoped request, live only below a mode-0700 `/tmp` root, +and classify only explicit open, WPA2-PSK, or unsupported security. A caller +can cancel active work and explicitly discard any terminal result. The scanner +does not change a profile, preference, association, DHCP client, or listener. + Listener reconciliation is event-driven at existing fixed boundaries: boot, Wi-Fi reconciliation, Wi-Fi preference changes, remote-key publication, and each listener preference request. A request publishes its desired boolean and diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f4a675f7d..4b68ba2c8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -297,6 +297,23 @@ redacted health. The remaining NET-04 scan/connect orchestration and active profile removal must retain stdin or an equivalent fixed private input boundary and the existing rollback rules. +The NET-04 scan slice is asynchronous and keeps result data only under `/tmp`: + +```sh +operation_id=$(bloomctl network scan start | jq -r .operation_id) +bloomctl network scan status "$operation_id" +bloomctl network scan results "$operation_id" +bloomctl network scan cancel "$operation_id" +bloomctl network scan discard "$operation_id" +``` + +Only the explicit results call returns SSIDs, for local UI consumption. Never +copy that output into logs, screenshots, issues, PR bodies, or validation +evidence. Host fixtures use synthetic `iw` output and must cover cancellation, +timeout, backend failure, oversized output, duplicate SSIDs, security +classification, unsafe paths, stale/live locks, rate limiting, and bounded +history. This slice performs no connect or forget transition. + An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable `.bloom/update` state. After device checks pass, developers can promote the diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 7485c8c6f..3f85cf0e8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -102,4 +102,12 @@ handle, revalidates the resulting complete store, and publishes atomically. the managed supplicant projection. Neither operation scans, associates, restarts Wi-Fi, or exposes an SSID or credential. +Wi-Fi scan status and logs never contain SSIDs. An explicit local results call +may return at most 32 printable SSIDs for the future on-device UI, but that +ephemeral mode-0600 result is excluded from diagnostics, support exports, +screenshots, and durable evidence. The worker accepts no interface, command, +destination, or backend override from its public arguments; it has one fixed +interface, a 15-second deadline, a 64-KiB raw-output ceiling, a single active +operation, a five-second start limit, cancellation, and bounded history. + Security-sensitive migrations and repair operations must be bounded, logged, reversible where possible, and validated before activation. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index d8fa40d83..f4ce9a087 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -198,9 +198,7 @@ "title": "Establish clean recovery workspace and current ledger", "status": "merged", "dependencies": [], - "github_prs": [ - 487 - ], + "github_prs": [], "risk_tier": 0, "deployment_tier": "host", "device_gate_required": false, @@ -833,15 +831,18 @@ "dependencies": [ "NET-03" ], - "github_prs": [], + "github_prs": [ + 487 + ], "risk_tier": 2, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "fca10bb0168d8bc38737881745b68cd3bab9da59", + "last_audited_main": "105f744d2fc88d0cacf023ecb62e800572dd0933", "evidence": [ "issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration", "draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04", - "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green" + "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green; PR #487 merged as 105f744d2fc88d0cacf023ecb62e800572dd0933", + "the unpublished NET-04B candidate adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; it does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04" ] }, { diff --git a/static/build/.tmp_update/bin/bloom-wifi-scan b/static/build/.tmp_update/bin/bloom-wifi-scan new file mode 100755 index 000000000..34417b58b --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-wifi-scan @@ -0,0 +1,425 @@ +#!/bin/sh +set -eu + +ROOT=${BLOOM_WIFI_SCAN_ROOT:-/tmp/bloom-wifi-scan} +OPERATIONS="$ROOT/operations" +LOCK="$ROOT/.lock" +ACTIVE="$ROOT/active" +LAST_START="$ROOT/last-start" +PLATFORM=${BLOOM_WIFI_SCAN_PLATFORM_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-platform} +IW=${BLOOM_WIFI_SCAN_IW_BIN:-/mnt/SDCARD/.tmp_update/bin/iw} +JQ=${BLOOM_WIFI_SCAN_JQ_BIN:-/mnt/SDCARD/.tmp_update/bin/jq} +NOHUP=${BLOOM_WIFI_SCAN_NOHUP_BIN:-/usr/bin/nohup} +SELF=${BLOOM_WIFI_SCAN_SELF:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi-scan} +SLEEP=${BLOOM_WIFI_SCAN_SLEEP_BIN:-sleep} +DATE=${BLOOM_WIFI_SCAN_DATE_BIN:-date} +OD=${BLOOM_WIFI_SCAN_OD_BIN:-od} +PROC_ROOT=${BLOOM_WIFI_SCAN_PROC_ROOT:-/proc} +INTERFACE=${BLOOM_WIFI_SCAN_INTERFACE:-wlan0} +MAX_SECONDS=${BLOOM_WIFI_SCAN_MAX_SECONDS:-15} +RATE_SECONDS=${BLOOM_WIFI_SCAN_RATE_SECONDS:-5} +MAX_OPERATIONS=${BLOOM_WIFI_SCAN_MAX_OPERATIONS:-8} +MAX_RAW_BYTES=65536 +MAX_RESULTS=32 +LOCKED=0 +SCAN_PID= +WORKER_TOKEN= +WORKER_TERMINAL=0 + +fail() { printf 'bloom-wifi-scan: %s\n' "$*" >&2; exit 1; } + +safe_token() { + case "$1" in ''|*[!0-9a-f-]*|-*|*-) return 1 ;; esac + [ "${#1}" -ge 18 ] && [ "${#1}" -le 32 ] +} + +safe_number() { + case "$1" in ''|*[!0-9]*) return 1 ;; esac +} + +ensure_root() { + parent=${ROOT%/*} + [ -n "$parent" ] || parent=/ + [ -d "$parent" ] && [ ! -L "$parent" ] || fail "scan parent is unsafe" + if [ -e "$ROOT" ] || [ -L "$ROOT" ]; then + [ -d "$ROOT" ] && [ ! -L "$ROOT" ] || fail "scan root is unsafe" + else + mkdir "$ROOT" || fail "cannot create scan root" + fi + chmod 700 "$ROOT" || fail "cannot protect scan root" + if [ -e "$OPERATIONS" ] || [ -L "$OPERATIONS" ]; then + [ -d "$OPERATIONS" ] && [ ! -L "$OPERATIONS" ] || fail "operation root is unsafe" + else + mkdir "$OPERATIONS" || fail "cannot create operation root" + fi + chmod 700 "$OPERATIONS" || fail "cannot protect operation root" +} + +acquire_lock() { + if ! mkdir "$LOCK" 2>/dev/null; then + [ -d "$LOCK" ] && [ ! -L "$LOCK" ] || fail "scan service is busy" + owner=$(cat "$LOCK/owner" 2>/dev/null || true) + case "$owner" in + ''|*[!0-9]*) owner_live=false ;; + *) if kill -0 "$owner" 2>/dev/null; then owner_live=true; else owner_live=false; fi ;; + esac + [ "$owner_live" = false ] || fail "scan service is busy" + rm -f "$LOCK/owner" 2>/dev/null || fail "cannot recover scan lock" + rmdir "$LOCK" 2>/dev/null || fail "cannot recover scan lock" + mkdir "$LOCK" 2>/dev/null || fail "scan service is busy" + fi + LOCKED=1 + (umask 077; printf '%s\n' "$$" >"$LOCK/owner") || fail "cannot record scan lock" +} + +release_lock() { + [ "$LOCKED" -eq 1 ] || return 0 + rm -f "$LOCK/owner" 2>/dev/null || true + rmdir "$LOCK" 2>/dev/null || true + LOCKED=0 +} + +cleanup() { + if [ -n "$WORKER_TOKEN" ] && [ "$WORKER_TERMINAL" -eq 0 ]; then + [ -z "$SCAN_PID" ] || kill -TERM "$SCAN_PID" 2>/dev/null || true + [ -z "$SCAN_PID" ] || wait "$SCAN_PID" 2>/dev/null || true + started=$(read_state_field "$WORKER_TOKEN" started_at 2>/dev/null || printf 0) + completed=$(now 2>/dev/null || printf 0) + write_state "$WORKER_TOKEN" failed backend_failed 0 "$started" "$completed" 2>/dev/null || true + directory=$(operation_dir "$WORKER_TOKEN" 2>/dev/null || true) + [ -z "$directory" ] || rm -f "$directory/pid" "$directory/raw.scan" "$directory/results.json.incoming" 2>/dev/null || true + clear_active "$WORKER_TOKEN" 2>/dev/null || true + fi + release_lock +} +trap cleanup EXIT + +now() { + value=$("$DATE" +%s 2>/dev/null) || fail "clock is unavailable" + safe_number "$value" || fail "clock is invalid" + printf '%s' "$value" +} + +operation_dir() { + safe_token "$1" || fail "operation id is invalid" + printf '%s/%s' "$OPERATIONS" "$1" +} + +write_state() { + token=$1 + phase=$2 + error=$3 + result_count=$4 + started_at=$5 + completed_at=$6 + directory=$(operation_dir "$token") + [ -d "$directory" ] && [ ! -L "$directory" ] || fail "operation state is unsafe" + temporary="$directory/state.json.incoming" + [ ! -e "$temporary" ] && [ ! -L "$temporary" ] || fail "state slot is occupied" + (umask 077; printf '{"schema":1,"service":"bloom-wifi-scan","operation_id":"%s","phase":"%s","error":"%s","result_count":%s,"started_at":%s,"completed_at":%s}\n' \ + "$token" "$phase" "$error" "$result_count" "$started_at" "$completed_at" >"$temporary") || fail "cannot write scan state" + chmod 600 "$temporary" || fail "cannot protect scan state" + mv -f "$temporary" "$directory/state.json" || fail "cannot publish scan state" +} + +read_state_field() { + token=$1 + field=$2 + directory=$(operation_dir "$token") + state="$directory/state.json" + [ -f "$state" ] && [ ! -L "$state" ] || fail "operation is unavailable" + "$JQ" -er ".$field" "$state" 2>/dev/null || fail "operation state is invalid" +} + +worker_is_live() { + token=$1 + directory=$(operation_dir "$token") + [ -f "$directory/pid" ] && [ ! -L "$directory/pid" ] || return 1 + pid=$(cat "$directory/pid" 2>/dev/null || true) + safe_number "$pid" || return 1 + kill -0 "$pid" 2>/dev/null || return 1 + command=$(tr '\000' ' ' <"$PROC_ROOT/$pid/cmdline" 2>/dev/null || true) + case "$command" in *"$SELF worker $token"*) return 0 ;; *) return 1 ;; esac +} + +active_token() { + [ -e "$ACTIVE" ] || [ -L "$ACTIVE" ] || return 1 + [ -f "$ACTIVE" ] && [ ! -L "$ACTIVE" ] || fail "active scan state is unsafe" + token=$(cat "$ACTIVE" 2>/dev/null || true) + safe_token "$token" || fail "active scan state is invalid" + printf '%s' "$token" +} + +clear_active() { + token=$1 + [ -e "$ACTIVE" ] || [ -L "$ACTIVE" ] || return 0 + current=$(active_token) + [ "$current" != "$token" ] || rm -f "$ACTIVE" || fail "cannot clear active scan" +} + +platform_ready() { + [ -x "$PLATFORM" ] || return 1 + [ "$($PLATFORM model 2>/dev/null)" = mini_plus ] || return 1 + [ "$($PLATFORM capability-status network.wifi_station 2>/dev/null)" = available ] +} + +validate_bounds() { + safe_number "$MAX_SECONDS" && [ "$MAX_SECONDS" -ge 1 ] && [ "$MAX_SECONDS" -le 30 ] || fail "scan timeout is invalid" + safe_number "$RATE_SECONDS" && [ "$RATE_SECONDS" -le 60 ] || fail "scan rate limit is invalid" + safe_number "$MAX_OPERATIONS" && [ "$MAX_OPERATIONS" -ge 1 ] && [ "$MAX_OPERATIONS" -le 16 ] || fail "scan history bound is invalid" +} + +operation_count() { + count=0 + for directory in "$OPERATIONS"/*; do + [ -e "$directory" ] || continue + [ -d "$directory" ] && [ ! -L "$directory" ] || fail "operation history is unsafe" + count=$((count + 1)) + done + printf '%s' "$count" +} + +new_token() { + timestamp=$1 + random=$("$OD" -An -N8 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n') || fail "random source is unavailable" + case "$random" in ????????????????) ;; *) fail "random source is invalid" ;; esac + token="$timestamp-$random" + safe_token "$token" || fail "generated operation id is invalid" + printf '%s' "$token" +} + +start_scan() { + ensure_root + validate_bounds + platform_ready || fail "station scanning is unavailable" + [ -x "$IW" ] && [ -x "$JQ" ] && [ -x "$NOHUP" ] && [ -x "$SELF" ] && [ -x "$DATE" ] && [ -x "$OD" ] || fail "scan dependency is unavailable" + acquire_lock + current= + if [ -e "$ACTIVE" ] || [ -L "$ACTIVE" ]; then + current=$(active_token) + fi + if [ -n "$current" ]; then + if worker_is_live "$current"; then + fail "scan already active" + fi + phase=$(read_state_field "$current" phase) + case "$phase" in + queued|running) + started=$(read_state_field "$current" started_at) + write_state "$current" failed backend_failed 0 "$started" "$(now)" + ;; + esac + clear_active "$current" + fi + [ "$(operation_count)" -lt "$MAX_OPERATIONS" ] || fail "scan history is full" + timestamp=$(now) + if [ -e "$LAST_START" ] || [ -L "$LAST_START" ]; then + [ -f "$LAST_START" ] && [ ! -L "$LAST_START" ] || fail "scan rate state is unsafe" + previous=$(cat "$LAST_START" 2>/dev/null || true) + safe_number "$previous" || fail "scan rate state is invalid" + elapsed=$((timestamp - previous)) + [ "$elapsed" -ge "$RATE_SECONDS" ] || fail "scan rate limit is active" + fi + token=$(new_token "$timestamp") + directory=$(operation_dir "$token") + [ ! -e "$directory" ] && [ ! -L "$directory" ] || fail "operation id already exists" + mkdir "$directory" || fail "cannot create scan operation" + chmod 700 "$directory" || fail "cannot protect scan operation" + write_state "$token" queued none 0 "$timestamp" 0 + [ ! -e "$LAST_START.incoming" ] && [ ! -L "$LAST_START.incoming" ] || fail "scan rate slot is occupied" + [ ! -e "$ACTIVE.incoming" ] && [ ! -L "$ACTIVE.incoming" ] || fail "active scan slot is occupied" + (umask 077; printf '%s\n' "$timestamp" >"$LAST_START.incoming" && mv -f "$LAST_START.incoming" "$LAST_START") || fail "cannot publish scan rate state" + (umask 077; printf '%s\n' "$token" >"$ACTIVE.incoming" && mv -f "$ACTIVE.incoming" "$ACTIVE") || fail "cannot publish active scan" + "$NOHUP" "$SELF" worker "$token" >/dev/null 2>&1 "$directory/pid") || fail "cannot record scan worker" + release_lock + printf '{"schema":1,"service":"bloom-wifi-scan","operation":"start","accepted":true,"operation_id":"%s"}\n' "$token" +} + +parse_scan() { + raw=$1 + result=$2 + awk -v max="$MAX_RESULTS" ' + function reset() { ssid=""; signal=-999; privacy=0; secure=0; psk=0 } + function save( security,idx) { + if (ssid == "" || length(ssid) > 32 || ssid !~ /^[ -~]+$/ || signal < -200 || signal > 0) return + security = psk ? "wpa2-psk" : ((privacy || secure) ? "unsupported" : "open") + if (ssid in positions) { + idx=positions[ssid] + if (signal > strengths[idx]) { strengths[idx]=signal; securities[idx]=security } + return + } + if (count >= max) return + count++; positions[ssid]=count; names[count]=ssid; strengths[count]=signal; securities[count]=security + } + function escaped(value) { gsub(/\\/, "\\\\", value); gsub(/\"/, "\\\"", value); return value } + /^BSS / { save(); reset(); next } + /^[ \t]+SSID: / { value=$0; sub(/^[ \t]+SSID: /,"",value); ssid=value; next } + /^[ \t]+signal: / { value=$2; sub(/[.].*/,"",value); signal=value+0; next } + /^[ \t]+capability: .*Privacy/ { privacy=1; next } + /^[ \t]+(RSN|WPA):/ { secure=1; next } + /^[ \t]+Authentication suites: .*PSK/ { psk=1; next } + END { + save(); printf "{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"networks\":[" + for (i=1; i<=count; i++) { + if (i>1) printf "," + printf "{\"ssid\":\"%s\",\"security\":\"%s\",\"signal_dbm\":%d}", escaped(names[i]), securities[i], strengths[i] + } + print "]}" + } + ' "$raw" >"$result" +} + +worker_abort() { + [ -z "$SCAN_PID" ] || kill -TERM "$SCAN_PID" 2>/dev/null || true + [ -z "$SCAN_PID" ] || wait "$SCAN_PID" 2>/dev/null || true + if [ -n "$WORKER_TOKEN" ] && [ "$WORKER_TERMINAL" -eq 0 ]; then + started=$(read_state_field "$WORKER_TOKEN" started_at 2>/dev/null || printf 0) + completed=$(now 2>/dev/null || printf 0) + write_state "$WORKER_TOKEN" cancelled cancelled 0 "$started" "$completed" 2>/dev/null || true + clear_active "$WORKER_TOKEN" + WORKER_TERMINAL=1 + fi + exit 0 +} + +run_worker() { + token=$1 + ensure_root + validate_bounds + directory=$(operation_dir "$token") + [ -d "$directory" ] && [ ! -L "$directory" ] || fail "operation is unavailable" + WORKER_TOKEN=$token + trap worker_abort HUP INT TERM + started=$(read_state_field "$token" started_at) + write_state "$token" running none 0 "$started" 0 + raw="$directory/raw.scan" + result="$directory/results.json.incoming" + [ ! -e "$raw" ] && [ ! -L "$raw" ] && [ ! -e "$result" ] && [ ! -L "$result" ] || fail "scan output slot is occupied" + (umask 077; ulimit -f $(((MAX_RAW_BYTES + 511) / 512)); "$IW" dev "$INTERFACE" scan >"$raw" 2>/dev/null) & + SCAN_PID=$! + attempt=0 + timed_out=false + while kill -0 "$SCAN_PID" 2>/dev/null; do + if [ "$attempt" -ge $((MAX_SECONDS * 10)) ]; then + timed_out=true + kill -TERM "$SCAN_PID" 2>/dev/null || true + "$SLEEP" 0.2 || true + kill -KILL "$SCAN_PID" 2>/dev/null || true + break + fi + attempt=$((attempt + 1)) + "$SLEEP" 0.1 || true + done + if wait "$SCAN_PID"; then scan_ok=true; else scan_ok=false; fi + SCAN_PID= + bytes=$(wc -c <"$raw" | tr -d ' ') + safe_number "$bytes" || bytes=$((MAX_RAW_BYTES + 1)) + completed=$(now) + if [ "$timed_out" = true ]; then + rm -f "$raw" + write_state "$token" failed timeout 0 "$started" "$completed" + elif [ "$scan_ok" != true ] && [ "$bytes" -ge "$MAX_RAW_BYTES" ]; then + rm -f "$raw" + write_state "$token" failed output_too_large 0 "$started" "$completed" + elif [ "$scan_ok" != true ]; then + rm -f "$raw" + write_state "$token" failed backend_failed 0 "$started" "$completed" + elif [ "$bytes" -gt "$MAX_RAW_BYTES" ]; then + rm -f "$raw" + write_state "$token" failed output_too_large 0 "$started" "$completed" + else + (umask 077; parse_scan "$raw" "$result") || fail "cannot parse scan result" + chmod 600 "$result" || fail "cannot protect scan result" + count=$("$JQ" -er '.networks | length' "$result") || fail "scan result is invalid" + mv -f "$result" "$directory/results.json" || fail "cannot publish scan result" + rm -f "$raw" + write_state "$token" succeeded none "$count" "$started" "$completed" + fi + rm -f "$directory/pid" 2>/dev/null || true + clear_active "$token" + WORKER_TERMINAL=1 +} + +status_scan() { + ensure_root + token=$1 + directory=$(operation_dir "$token") + state="$directory/state.json" + [ -f "$state" ] && [ ! -L "$state" ] || fail "operation is unavailable" + "$JQ" -e ' + type=="object" and + (keys|sort)==["completed_at","error","operation_id","phase","result_count","schema","service","started_at"] and + .schema==1 and .service=="bloom-wifi-scan" and + (.operation_id|type)=="string" and + (.phase=="queued" or .phase=="running" or .phase=="succeeded" or .phase=="failed" or .phase=="cancelled") and + (.error=="none" or .error=="timeout" or .error=="backend_failed" or .error=="output_too_large" or .error=="cancelled") and + (.result_count|type)=="number" and (.started_at|type)=="number" and (.completed_at|type)=="number" + ' "$state" >/dev/null || fail "operation state is invalid" + cat "$state" +} + +results_scan() { + ensure_root + token=$1 + [ "$(read_state_field "$token" phase)" = succeeded ] || fail "scan result is unavailable" + directory=$(operation_dir "$token") + result="$directory/results.json" + [ -f "$result" ] && [ ! -L "$result" ] || fail "scan result is unavailable" + cat "$result" +} + +cancel_scan() { + ensure_root + token=$1 + acquire_lock + current= + if [ -e "$ACTIVE" ] || [ -L "$ACTIVE" ]; then + current=$(active_token) + fi + [ "$current" = "$token" ] || fail "operation is not active" + worker_is_live "$token" || fail "scan worker is unavailable" + directory=$(operation_dir "$token") + pid=$(cat "$directory/pid") + kill -TERM "$pid" 2>/dev/null || fail "cannot cancel scan" + attempt=0 + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do + "$SLEEP" 0.1 || true + attempt=$((attempt + 1)) + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || fail "cannot cancel scan" + started=$(read_state_field "$token" started_at) + write_state "$token" cancelled cancelled 0 "$started" "$(now)" + clear_active "$token" + fi + release_lock + printf '{"schema":1,"service":"bloom-wifi-scan","operation":"cancel","cancelled":true,"operation_id":"%s"}\n' "$token" +} + +discard_scan() { + ensure_root + token=$1 + acquire_lock + phase=$(read_state_field "$token" phase) + case "$phase" in succeeded|failed|cancelled) ;; *) fail "operation is not terminal" ;; esac + directory=$(operation_dir "$token") + for file in state.json results.json pid raw.scan results.json.incoming; do + [ ! -L "$directory/$file" ] || fail "operation state is unsafe" + [ ! -e "$directory/$file" ] || rm -f "$directory/$file" || fail "cannot discard operation" + done + rmdir "$directory" || fail "operation directory is not empty" + release_lock + printf '{"schema":1,"service":"bloom-wifi-scan","operation":"discard","discarded":true,"operation_id":"%s"}\n' "$token" +} + +case "$#:${1:-}:${2:-}:${3:-}" in + 1:start::) start_scan ;; + 2:worker:*:) run_worker "$2" ;; + 3:status:*:--json) status_scan "$2" ;; + 3:results:*:--json) results_scan "$2" ;; + 2:cancel:*:) cancel_scan "$2" ;; + 2:discard:*:) discard_scan "$2" ;; + *) fail "invalid arguments" ;; +esac diff --git a/static/build/.tmp_update/bin/bloomctl b/static/build/.tmp_update/bin/bloomctl index 9bdd6f596..86a91dd06 100755 --- a/static/build/.tmp_update/bin/bloomctl +++ b/static/build/.tmp_update/bin/bloomctl @@ -26,6 +26,7 @@ BLOOM_POWER="${BLOOM_POWER_BIN:-$SCRIPT_DIR/bloom-power}" BLOOM_NETWORK="${BLOOM_NETWORK_BIN:-$SCRIPT_DIR/bloom-network}" BLOOM_NETWORK_SERVICES="${BLOOM_NETWORK_SERVICES_BIN:-$SCRIPT_DIR/bloom-network-services}" BLOOM_NETWORK_TRANSACTION="${BLOOM_NETWORK_TRANSACTION_BIN:-$SCRIPT_DIR/bloom-network-transaction}" +BLOOM_WIFI_SCAN="${BLOOM_WIFI_SCAN_BIN:-$SCRIPT_DIR/bloom-wifi-scan}" BLOOM_JQ="${BLOOM_JQ_BIN:-$SCRIPT_DIR/jq}" BLOOM_CONTROLS="${BLOOM_CONTROLS_BIN:-$SCRIPT_DIR/bloom-controls}" BLOOM_TIME="${BLOOM_TIME_BIN:-$SCRIPT_DIR/bloom-time}" @@ -378,7 +379,7 @@ usage() { printf ' bloomctl platform capabilities\n' >&2 printf ' bloomctl settings {status|values|import-onion|sync-onion|reconcile-onion|materialize-onion|activate-bloom|rollback-authority|reset-defaults|first-run-status|complete-first-run|set FIELD VALUE}\n' >&2 printf ' bloomctl power {status|reboot|poweroff}\n' >&2 - printf ' bloomctl network {status|reconcile|enable|disable|transaction status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback}\n' >&2 + printf ' bloomctl network {status|reconcile|enable|disable|scan start|status ID|results ID|cancel ID|discard ID|transaction status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback}\n' >&2 printf ' bloomctl controls {status|brightness VALUE|volume VALUE|mute true}\n' >&2 printf ' bloomctl time status\n' >&2 printf ' bloomctl lid status\n' >&2 @@ -625,6 +626,18 @@ network_command() { 1:status:) "$BLOOM_NETWORK" status ;; 1:reconcile:) "$BLOOM_NETWORK" request reconcile ;; 1:enable:|1:disable:) "$BLOOM_NETWORK" request "$1" ;; + 2:scan:start) + [ -x "$BLOOM_WIFI_SCAN" ] || { printf 'bloomctl: Wi-Fi scan service is unavailable\n' >&2; return 1; } + "$BLOOM_WIFI_SCAN" start + ;; + 3:scan:status|3:scan:results) + [ -x "$BLOOM_WIFI_SCAN" ] || { printf 'bloomctl: Wi-Fi scan service is unavailable\n' >&2; return 1; } + "$BLOOM_WIFI_SCAN" "$2" "$3" --json + ;; + 3:scan:cancel|3:scan:discard) + [ -x "$BLOOM_WIFI_SCAN" ] || { printf 'bloomctl: Wi-Fi scan service is unavailable\n' >&2; return 1; } + "$BLOOM_WIFI_SCAN" "$2" "$3" + ;; 2:transaction:status|2:transaction:rollback) [ -x "$BLOOM_NETWORK_TRANSACTION" ] || { printf 'bloomctl: network transaction service is unavailable\n' >&2; return 1; } "$BLOOM_NETWORK_TRANSACTION" "$2" diff --git a/test/shell/bloomctl_info.bats b/test/shell/bloomctl_info.bats index 154ec31fa..ed3b8b79f 100644 --- a/test/shell/bloomctl_info.bats +++ b/test/shell/bloomctl_info.bats @@ -711,6 +711,12 @@ SH printf '{"schema":1,"transaction_arguments":"%s"}\n' "$*" SH chmod +x "$transaction" + scanner="$BATS_TEST_TMPDIR/bloom-wifi-scan" + cat >"$scanner" <<'SH' +#!/bin/sh +printf '{"schema":1,"scan_arguments":"%s"}\n' "$*" +SH + chmod +x "$scanner" run env BLOOM_NETWORK_BIN="$service" \ sh /workspace/static/build/.tmp_update/bin/bloomctl network status @@ -732,6 +738,25 @@ SH [ "$status" -eq 0 ] [ "$output" = '{"schema":1,"arguments":"request disable"}' ] + run env BLOOM_NETWORK_BIN="$service" BLOOM_WIFI_SCAN_BIN="$scanner" \ + sh /workspace/static/build/.tmp_update/bin/bloomctl network scan start + [ "$status" -eq 0 ] + [ "$output" = '{"schema":1,"scan_arguments":"start"}' ] + + for operation in status results; do + run env BLOOM_NETWORK_BIN="$service" BLOOM_WIFI_SCAN_BIN="$scanner" \ + sh /workspace/static/build/.tmp_update/bin/bloomctl network scan "$operation" 1234567890-abcdef0123456789 + [ "$status" -eq 0 ] + [ "$output" = "{\"schema\":1,\"scan_arguments\":\"$operation 1234567890-abcdef0123456789 --json\"}" ] + done + + for operation in cancel discard; do + run env BLOOM_NETWORK_BIN="$service" BLOOM_WIFI_SCAN_BIN="$scanner" \ + sh /workspace/static/build/.tmp_update/bin/bloomctl network scan "$operation" 1234567890-abcdef0123456789 + [ "$status" -eq 0 ] + [ "$output" = "{\"schema\":1,\"scan_arguments\":\"$operation 1234567890-abcdef0123456789\"}" ] + done + run env BLOOM_NETWORK_BIN="$service" BLOOM_NETWORK_TRANSACTION_BIN="$transaction" \ sh /workspace/static/build/.tmp_update/bin/bloomctl network transaction begin 60 [ "$status" -eq 0 ] diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index 2b3cab045..948c7692e 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: 331 components" ]] + [[ "$output" == "legacy manifest validate: 332 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)" ] @@ -104,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) == 54 +assert len(bloom) == 55 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/wifi_scan.bats b/test/shell/wifi_scan.bats new file mode 100644 index 000000000..8af58e9aa --- /dev/null +++ b/test/shell/wifi_scan.bats @@ -0,0 +1,279 @@ +#!/usr/bin/env bats + +setup() { + export SERVICE=/workspace/static/build/.tmp_update/bin/bloom-wifi-scan + export BLOOM_WIFI_SCAN_ROOT="$BATS_TEST_TMPDIR/scan" + export BLOOM_WIFI_SCAN_PLATFORM_BIN="$BATS_TEST_TMPDIR/platform" + export BLOOM_WIFI_SCAN_IW_BIN="$BATS_TEST_TMPDIR/iw" + export BLOOM_WIFI_SCAN_JQ_BIN=/usr/bin/jq + export BLOOM_WIFI_SCAN_NOHUP_BIN="$BATS_TEST_TMPDIR/nohup" + export BLOOM_WIFI_SCAN_SELF="$SERVICE" + export BLOOM_WIFI_SCAN_SLEEP_BIN=/bin/sleep + export BLOOM_WIFI_SCAN_DATE_BIN=/bin/date + export BLOOM_WIFI_SCAN_OD_BIN=/usr/bin/od + export BLOOM_WIFI_SCAN_PROC_ROOT=/proc + export BLOOM_WIFI_SCAN_MAX_SECONDS=2 + export BLOOM_WIFI_SCAN_RATE_SECONDS=0 + export BLOOM_WIFI_SCAN_MAX_OPERATIONS=8 + export SCAN_MODE_FILE="$BATS_TEST_TMPDIR/scan-mode" + export SCAN_TRACE_FILE="$BATS_TEST_TMPDIR/scan-trace" + printf '%s\n' success >"$SCAN_MODE_FILE" + cat >"$BLOOM_WIFI_SCAN_PLATFORM_BIN" <<'SH' +#!/bin/sh +case "$*" in + model) printf '%s\n' mini_plus ;; + 'capability-status network.wifi_station') printf '%s\n' available ;; + *) exit 2 ;; +esac +SH + cat >"$BLOOM_WIFI_SCAN_IW_BIN" <<'SH' +#!/bin/sh +[ "$*" = 'dev wlan0 scan' ] || exit 2 +mode=$(cat "$SCAN_MODE_FILE") +case "$mode" in + success) + cat <<'OUT' +BSS 00:00:00:00:00:01(on wlan0) + signal: -64.00 dBm + capability: ESS (0x0001) + SSID: Fixture Open +BSS 00:00:00:00:00:02(on wlan0) + signal: -52.00 dBm + capability: ESS Privacy (0x0011) + SSID: Fixture WPA2 + RSN: + Authentication suites: PSK +BSS 00:00:00:00:00:03(on wlan0) + signal: -42.00 dBm + capability: ESS Privacy (0x0011) + SSID: Fixture WPA2 + RSN: + Authentication suites: PSK +BSS 00:00:00:00:00:04(on wlan0) + signal: -70.00 dBm + capability: ESS Privacy (0x0011) + SSID: Fixture Enterprise + RSN: + Authentication suites: IEEE 802.1X +BSS 00:00:00:00:00:05(on wlan0) + signal: -80.00 dBm + capability: ESS Privacy (0x0011) + SSID: Fixture Legacy +OUT + ;; + slow) sleep 10 ;; + fail) exit 1 ;; + oversized) dd if=/dev/zero bs=70000 count=1 2>/dev/null | tr '\000' A ;; + *) exit 2 ;; +esac +SH + cat >"$BLOOM_WIFI_SCAN_NOHUP_BIN" <<'SH' +#!/bin/sh +exec /usr/bin/nohup "$@" 2>"$SCAN_TRACE_FILE" +SH + chmod +x "$BLOOM_WIFI_SCAN_PLATFORM_BIN" "$BLOOM_WIFI_SCAN_IW_BIN" "$BLOOM_WIFI_SCAN_NOHUP_BIN" +} + +start_scan() { + run "$SERVICE" start + if [ "$status" -ne 0 ]; then + printf '%s\n' "$output" >&3 + return 1 + fi + token=$(printf '%s\n' "$output" | jq -er '.operation_id') +} + +wait_terminal() { + token=$1 + attempt=0 + while [ "$attempt" -lt 60 ]; do + phase=$("$SERVICE" status "$token" --json | jq -r .phase) + case "$phase" in succeeded|failed|cancelled) return 0 ;; esac + sleep 0.1 + attempt=$((attempt + 1)) + done + return 1 +} + +@test "scan runs asynchronously and publishes one bounded local result per SSID" { + run "$BLOOM_WIFI_SCAN_IW_BIN" dev wlan0 scan + [ "$status" -eq 0 ] + start_scan + [[ "$output" != *'Fixture'* ]] + wait_terminal "$token" + + run "$SERVICE" status "$token" --json + [ "$status" -eq 0 ] + if [ "$(printf '%s\n' "$output" | jq -r .phase)" != succeeded ]; then + printf '%s\n' "$output" >&3 + cat "$SCAN_TRACE_FILE" >&3 || true + return 1 + fi + [ "$(printf '%s\n' "$output" | jq -r .result_count)" -eq 4 ] + [[ "$output" != *'Fixture'* ]] + + run "$SERVICE" results "$token" --json + [ "$status" -eq 0 ] + [ "$(printf '%s\n' "$output" | jq -r '.networks | length')" -eq 4 ] + [ "$(printf '%s\n' "$output" | jq -r '.networks[] | select(.ssid == "Fixture Open") | .security')" = open ] + [ "$(printf '%s\n' "$output" | jq -r '.networks[] | select(.ssid == "Fixture WPA2") | .security')" = wpa2-psk ] + [ "$(printf '%s\n' "$output" | jq -r '.networks[] | select(.ssid == "Fixture WPA2") | .signal_dbm')" -eq -42 ] + [ "$(printf '%s\n' "$output" | jq -r '.networks[] | select(.ssid == "Fixture Enterprise") | .security')" = unsupported ] + [ "$(printf '%s\n' "$output" | jq -r '.networks[] | select(.ssid == "Fixture Legacy") | .security')" = unsupported ] + [ "$(stat -c %a "$BLOOM_WIFI_SCAN_ROOT")" = 700 ] + [ "$(stat -c %a "$BLOOM_WIFI_SCAN_ROOT/operations/$token")" = 700 ] + [ "$(stat -c %a "$BLOOM_WIFI_SCAN_ROOT/operations/$token/results.json")" = 600 ] +} + +@test "terminal results can be explicitly discarded and invalid ids fail closed" { + start_scan + wait_terminal "$token" + run "$SERVICE" discard "$token" + [ "$status" -eq 0 ] + [[ "$output" == *'"discarded":true'* ]] + [ ! -e "$BLOOM_WIFI_SCAN_ROOT/operations/$token" ] + + run "$SERVICE" status '../escape' --json + [ "$status" -ne 0 ] + [[ "$output" == *'operation id is invalid'* ]] + run "$SERVICE" export + [ "$status" -ne 0 ] + [[ "$output" == *'invalid arguments'* ]] +} + +@test "a live scan is cancelable and cancellation publishes no result" { + printf '%s\n' slow >"$SCAN_MODE_FILE" + start_scan + attempt=0 + while [ "$attempt" -lt 30 ]; do + [ "$("$SERVICE" status "$token" --json | jq -r .phase)" = running ] && break + sleep 0.1 + attempt=$((attempt + 1)) + done + + run "$SERVICE" cancel "$token" + if [ "$status" -ne 0 ]; then + printf '%s\n' "$output" >&3 + return 1 + fi + [[ "$output" == *'"cancelled":true'* ]] + wait_terminal "$token" + [ "$("$SERVICE" status "$token" --json | jq -r .phase)" = cancelled ] + [ ! -e "$BLOOM_WIFI_SCAN_ROOT/operations/$token/results.json" ] +} + +@test "backend failure timeout and oversized output expose only bounded errors" { + printf '%s\n' fail >"$SCAN_MODE_FILE" + start_scan + wait_terminal "$token" + run "$SERVICE" status "$token" --json + [ "$(printf '%s\n' "$output" | jq -r .error)" = backend_failed ] + "$SERVICE" discard "$token" >/dev/null + + printf '%s\n' oversized >"$SCAN_MODE_FILE" + start_scan + wait_terminal "$token" + run "$SERVICE" status "$token" --json + [ "$(printf '%s\n' "$output" | jq -r .error)" = output_too_large ] + "$SERVICE" discard "$token" >/dev/null + + printf '%s\n' slow >"$SCAN_MODE_FILE" + export BLOOM_WIFI_SCAN_MAX_SECONDS=1 + start_scan + wait_terminal "$token" + run "$SERVICE" status "$token" --json + [ "$(printf '%s\n' "$output" | jq -r .error)" = timeout ] + [[ "$output" != *'sleep'* ]] +} + +@test "rate and history bounds reject new work without disturbing prior state" { + export BLOOM_WIFI_SCAN_RATE_SECONDS=60 + start_scan + wait_terminal "$token" + first=$token + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'scan rate limit is active'* ]] + first_phase=$("$SERVICE" status "$first" --json | jq -r .phase) + if [ "$first_phase" != succeeded ]; then + "$SERVICE" status "$first" --json >&3 + return 1 + fi + + export BLOOM_WIFI_SCAN_RATE_SECONDS=0 + export BLOOM_WIFI_SCAN_MAX_OPERATIONS=1 + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'scan history is full'* ]] + "$SERVICE" discard "$first" >/dev/null + start_scan + wait_terminal "$token" +} + +@test "unsafe roots and live locks are refused while stale locks recover" { + mkdir "$BATS_TEST_TMPDIR/elsewhere" + ln -s "$BATS_TEST_TMPDIR/elsewhere" "$BLOOM_WIFI_SCAN_ROOT" + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'scan root is unsafe'* ]] + rm "$BLOOM_WIFI_SCAN_ROOT" + + mkdir -p "$BLOOM_WIFI_SCAN_ROOT/operations" + ln -s "$BATS_TEST_TMPDIR/elsewhere" "$BLOOM_WIFI_SCAN_ROOT/active" + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'active scan state is unsafe'* ]] + rm "$BLOOM_WIFI_SCAN_ROOT/active" + + stale=1234567890-abcdef0123456789 + mkdir "$BLOOM_WIFI_SCAN_ROOT/operations/$stale" + chmod 700 "$BLOOM_WIFI_SCAN_ROOT/operations/$stale" + printf '%s\n' '{"schema":1,"service":"bloom-wifi-scan","operation_id":"1234567890-abcdef0123456789","phase":"running","error":"none","result_count":0,"started_at":1234567890,"completed_at":0}' >"$BLOOM_WIFI_SCAN_ROOT/operations/$stale/state.json" + printf '%s\n' "$stale" >"$BLOOM_WIFI_SCAN_ROOT/active" + start_scan + wait_terminal "$token" + [ "$("$SERVICE" status "$stale" --json | jq -r .phase)" = failed ] + "$SERVICE" discard "$stale" >/dev/null + "$SERVICE" discard "$token" >/dev/null + + mkdir "$BLOOM_WIFI_SCAN_ROOT/.lock" + printf '%s\n' 999999 >"$BLOOM_WIFI_SCAN_ROOT/.lock/owner" + start_scan + wait_terminal "$token" + "$SERVICE" discard "$token" >/dev/null + + mkdir "$BLOOM_WIFI_SCAN_ROOT/.lock" + printf '%s\n' "$$" >"$BLOOM_WIFI_SCAN_ROOT/.lock/owner" + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'scan service is busy'* ]] +} + +@test "unsupported hardware and invalid public bounds fail before launching work" { + sed -i 's/mini_plus/mini_v4/' "$BLOOM_WIFI_SCAN_PLATFORM_BIN" + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'station scanning is unavailable'* ]] + [ ! -e "$BLOOM_WIFI_SCAN_ROOT/active" ] + + sed -i 's/mini_v4/mini_plus/' "$BLOOM_WIFI_SCAN_PLATFORM_BIN" + export BLOOM_WIFI_SCAN_MAX_SECONDS=0 + run "$SERVICE" start + [ "$status" -ne 0 ] + [[ "$output" == *'scan timeout is invalid'* ]] +} + +@test "versioned scan-result schema records the same public privacy bounds" { + schema=/workspace/build/wifi-scan-result.schema.json + run jq -e ' + .properties.schema.const == 1 and + .properties.service.const == "bloom-wifi-scan" and + .properties.networks.maxItems == 32 and + .properties.networks.items.additionalProperties == false and + .properties.networks.items.properties.ssid.maxLength == 32 and + (.properties.networks.items.properties.security.enum | sort) == ["open", "unsupported", "wpa2-psk"] and + .properties.networks.items.properties.signal_dbm.minimum == -200 and + .properties.networks.items.properties.signal_dbm.maximum == 0 + ' "$schema" + [ "$status" -eq 0 ] +} diff --git a/tools/legacy_manifest.py b/tools/legacy_manifest.py index 0e4149180..53d9e05ce 100644 --- a/tools/legacy_manifest.py +++ b/tools/legacy_manifest.py @@ -66,6 +66,7 @@ "runtime-tmp-update-bin-bloom-samba-service", "runtime-tmp-update-bin-bloom-wifi", "runtime-tmp-update-bin-bloom-wifi-profiles", + "runtime-tmp-update-bin-bloom-wifi-scan", "runtime-tmp-update-bin-bloom-platform", "runtime-tmp-update-bin-bloom-power", "runtime-tmp-update-bin-bloom-ra-login", From 656c67d0776495941e155e5f703af5741728c7c4 Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:05:52 -0400 Subject: [PATCH 2/4] fix(network): pin Mini Plus scan dependencies --- build/dependencies.lock | 2 +- build/legacy-manifest.json | 4 ++-- static/build/.tmp_update/bin/bloom-wifi-scan | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build/dependencies.lock b/build/dependencies.lock index 119fa3c53..8c58a55f6 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -186,7 +186,7 @@ manifest = "build/legacy-manifest.json" schema = 1 component_count = 332 file_count = 2131 -byte_count = 314669698 +byte_count = 314669717 source_build_components = 228 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index cd83d494a..7cbf86f05 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -1008,8 +1008,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi-scan", "file_count": 1, - "byte_count": 17225, - "tree_sha256": "d4ce21a80d8321606d94d6ecc5698f6adecf7c84d8774fa6780210c19957b707", + "byte_count": 17244, + "tree_sha256": "7701d79ce3b896f5d4438d0aef5dd4f0c1f4b69ee6945268c47ebd489ac787ef", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", diff --git a/static/build/.tmp_update/bin/bloom-wifi-scan b/static/build/.tmp_update/bin/bloom-wifi-scan index 34417b58b..b92120675 100755 --- a/static/build/.tmp_update/bin/bloom-wifi-scan +++ b/static/build/.tmp_update/bin/bloom-wifi-scan @@ -11,9 +11,9 @@ IW=${BLOOM_WIFI_SCAN_IW_BIN:-/mnt/SDCARD/.tmp_update/bin/iw} JQ=${BLOOM_WIFI_SCAN_JQ_BIN:-/mnt/SDCARD/.tmp_update/bin/jq} NOHUP=${BLOOM_WIFI_SCAN_NOHUP_BIN:-/usr/bin/nohup} SELF=${BLOOM_WIFI_SCAN_SELF:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi-scan} -SLEEP=${BLOOM_WIFI_SCAN_SLEEP_BIN:-sleep} -DATE=${BLOOM_WIFI_SCAN_DATE_BIN:-date} -OD=${BLOOM_WIFI_SCAN_OD_BIN:-od} +SLEEP=${BLOOM_WIFI_SCAN_SLEEP_BIN:-/bin/sleep} +DATE=${BLOOM_WIFI_SCAN_DATE_BIN:-/bin/date} +OD=${BLOOM_WIFI_SCAN_OD_BIN:-/usr/bin/od} PROC_ROOT=${BLOOM_WIFI_SCAN_PROC_ROOT:-/proc} INTERFACE=${BLOOM_WIFI_SCAN_INTERFACE:-wlan0} MAX_SECONDS=${BLOOM_WIFI_SCAN_MAX_SECONDS:-15} From b5de4b1ae7f7d558886a8c55047b3a7ada141621 Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:06:40 -0400 Subject: [PATCH 3/4] docs(program): link NET-04B draft --- docs/program/BloomOS_Work_Program_v3.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index f4ce9a087..da24e2890 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -832,7 +832,8 @@ "NET-03" ], "github_prs": [ - 487 + 487, + 488 ], "risk_tier": 2, "deployment_tier": "warm", @@ -842,7 +843,7 @@ "issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration", "draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04", "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green; PR #487 merged as 105f744d2fc88d0cacf023ecb62e800572dd0933", - "the unpublished NET-04B candidate adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; it does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04" + "draft PR #488 adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; 9/9 affected tests passed in 13.8 seconds and 13/13 focused manifest/provenance tests passed in 271 seconds with exact 314669717-byte dependency preflight, and the slice does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04" ] }, { From 03507a6426f2f7fc61107dbae0579f46ebcece5b Mon Sep 17 00:00:00 2001 From: boburning Date: Fri, 28 Aug 2026 09:12:22 -0400 Subject: [PATCH 4/4] docs(program): record NET-04B device boundary --- docs/program/BloomOS_Work_Program_v3.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index da24e2890..816b6eeb5 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -843,7 +843,8 @@ "issue #486 tracks NET-04 after NET-03 merged as fca10bb0168d8bc38737881745b68cd3bab9da59; no prior NET-04 issue or implementation PR exists and current main has no bounded scan/connect/forget orchestration", "draft PR #487 is the first host-safe NET-04 slice and extends the private NET-03 authority with stdin-only atomic single-profile upsert, requested/document ID binding, inactive-profile deletion, a non-secret projected-profile marker, and fail-closed refusal to replace or forget the active profile; 16/16 focused tests passed in 4.8 seconds with exact 314651736-byte dependency preflight, and the slice does not scan, associate, restart Wi-Fi, change preferences, or complete NET-04", "exact head b6e96eebd08df0eb8b75867b8e341f3e7a76a830 passed risk-scoped shell workflow 33171784731 with 1m29s test execution; its exact 14104-byte helper with SHA-256 5fd88c120a0a4a979a890865a5ac06e7dbeb7e50ffcd99ce08cfa389e637ecc7 was transferred through strict key-only SSH and exercised only under /tmp/bloom-net04a-fixture-b6e96eeb with synthetic profiles, where active deletion failed closed, inactive deletion succeeded, root/store/config modes were 0700/0600/0600, live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef remained unchanged, temporary state was removed, and aggregate device health remained green; PR #487 merged as 105f744d2fc88d0cacf023ecb62e800572dd0933", - "draft PR #488 adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; 9/9 affected tests passed in 13.8 seconds and 13/13 focused manifest/provenance tests passed in 271 seconds with exact 314669717-byte dependency preflight, and the slice does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04" + "draft PR #488 adds a fixed-interface asynchronous scan worker with immediate high-entropy operation IDs, redacted status, explicit local-only results, cancellation, terminal discard, one active worker, bounded rate/runtime/raw output/results/history, open/WPA2-PSK/unsupported classification, and bloomctl delegation; 9/9 affected tests passed in 13.8 seconds and 13/13 focused manifest/provenance tests passed in 271 seconds with exact 314669717-byte dependency preflight, and the slice does not connect, forget, change a preference, restart Wi-Fi, modify DHCP, or complete NET-04", + "exact head b5de4b1ae7f7d558886a8c55047b3a7ada141621 passed risk-scoped shell workflow 33173965383 with 1m39s test execution; its exact 17244-byte helper with SHA-256 0d3b3e1ac893fb787fcd7c3d28b2c526175475d360bab83836d9072efab695b3 and a 156-byte synthetic platform fixture were transferred through strict key-only SSH and exercised only under /tmp with /bin/echo replacing iw, transitioned RUNNING to SUCCEEDED with zero results, enforced 0700/0700/0600/0600 root-operation-state-result modes, discarded the terminal operation, removed every task file, retained live supplicant SHA-256 d7fbadd510e76c423638513337620e17944d51b4505ff314354971d97114bdef, and left aggregate device health green without a real scan or network mutation" ] }, {