diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 1a3aff4ef..cb9f53671 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -50,7 +50,7 @@ shoulder button. | 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 `bloomctl network profile upsert ID`; requested/document ID binding; explicit `network forget ID`; inactive-ID deletion with active-profile refusal; active forget only after a separately confirmed fallback connection; schema/unknown-field/count/size enforcement; WPA2-PSK, explicit open, and manual hidden WPA2; WEP and hidden open denial; unique IDs/SSIDs; redacted status; explicit local credential-free UI list with one active marker; 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 | -| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | +| Wi-Fi UI | Original Mini hidden; Settings, Shell Quick Settings, and GameSwitcher Quick Settings converge on `--settings-wifi`; direct radio toggle removed; bounded fixed-argv profile-list load outside render; connection state and saved-network detail; single-flight nonblocking fixed-argv scan client; 16-KiB output and five-second deadline; child process-group cleanup; controller-owned start/status/results/discard and cancel-during-start sequencing; 250-ms status opportunities; exact profile/scan/ack schemas; 16 profiles and 32 networks; unique bounded IDs/SSIDs; one active profile; fixed scan phases/errors; terminal history discarded; credential and unknown-field rejection; passwords absent from arguments, logs, screenshots, durable Shell state, and evidence | | 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 b98534c89..f44512853 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -559,6 +559,13 @@ versioned scan model's operation IDs, caps output at 16 KiB, and terminates and reaps a command after five seconds. This client has no runtime consumer until the follow-on event-loop state machine is integrated and validated. +`BloomShellWifiController` owns that event-loop-independent lifecycle. It +sequences one start, 250-ms status opportunities, one results fetch, and one +terminal discard. A cancel received before start returns an operation ID is +remembered and issued immediately afterward. Successful networks and terminal +errors remain in bounded memory for presentation while the service-side +operation ID is cleared, preventing scan-history accumulation. + Profile association reuses the one guarded network-transaction authority rather than adding a second recovery mechanism. `begin` copies the exact current supplicant file into a mode-0700 JFFS2-private transaction root, records only diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f77f02a8a..d46021e9f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -381,6 +381,13 @@ an unvalidated operation ID. Host tests must exercise timeout, oversized output, malformed acknowledgement, symlink, and process cleanup paths before the request object gains a runtime consumer. +Use `BloomShellWifiController`, not an ad hoc chain of request calls, for the +scan lifecycle. Its poll function is nonblocking and returns after at most one +state transition; the SDL layer supplies monotonic milliseconds and schedules +the next wake. Keep the controller alive until cancel/status/discard completes, +including when cancel is requested while start is still returning its operation +ID. + The NET-04 scan slice is asynchronous and keeps result data only under `/tmp`: ```sh diff --git a/docs/SECURITY.md b/docs/SECURITY.md index a29798a96..3f05433b6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -143,4 +143,10 @@ deadline, and kills and reaps the child process group on every failed or abandoned request. Cancel and discard acknowledgements must match the exact requested operation ID. +The lifecycle controller automatically discards succeeded, failed, and +cancelled operations after fetching any successful result. It never guesses an +operation ID: cancellation requested during start remains pending until the +validated start response supplies the ID. This bounds scan history and prevents +a UI close or rapid cancel from racing an unrelated service operation. + 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 ac8342e63..9f46fb423 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -882,12 +882,13 @@ ], "github_prs": [ 545, - 546 + 546, + 547 ], "risk_tier": 1, "deployment_tier": "hot", "device_gate_required": true, - "last_audited_main": "7dee3fc27b5b0cd5b743af99f8e66b631c514c12", + "last_audited_main": "765f2038b7d63e61d6db8bab1a70ce750a07d088", "evidence": [ "issue #544 tracks NET-05 on current main after merged NET-04A-D and UI-05; the audit found that Bloom Shell still treats Wi-Fi as an inline toggle whose A and left/right handlers synchronously call bloom-network enable or disable instead of opening the bounded profile and scan UI", "the first host-safe slice adds an explicit local credential-free projection of at most 16 validated profile IDs, printable SSIDs, security classes, hidden flags, and one active marker; the private service never returns a PSK and the Shell model rejects unknown or credential-bearing fields", @@ -895,7 +896,9 @@ "GitHub PR #545 passed format in 16 seconds, native in 1 minute 24 seconds, the affected ARM build in 1 minute 26 seconds, AddressSanitizer in 1 minute 55 seconds, the 736-test shell gate in 2 minutes 3 seconds, and static analysis in 2 minutes 4 seconds; it merged by squash as 3b0a13a30d34733f03831b0d041785c4fc6dc3ae", "the dependent rendered-surface slice changes Wi-Fi from a toggle to a detail row, converges Settings and both Quick Settings implementations on the exact --settings-wifi route, removes the direct radio mutation helper, makes Wi-Fi left/right inert, and loads only the 4-KiB-bounded credential-free saved-profile projection outside the render path; scan, association, credential entry, forget, and recovery-qualified radio power remain pending", "GitHub PR #546 passed format in 14 seconds, the affected ARM build in 1 minute 17 seconds, native tests and the deterministic UI gallery in 1 minute 29 seconds, AddressSanitizer in 1 minute 56 seconds, static analysis in 2 minutes 17 seconds, and the 737-test shell gate in 2 minutes 19 seconds; it merged by squash as 7dee3fc27b5b0cd5b743af99f8e66b631c514c12", - "the next host-only slice adds no runtime consumer or device action; it provides a nonblocking fixed-argv client for scan start, status, results, cancel, and discard with strict operation IDs, a 16-KiB output ceiling, a five-second command deadline, process-group kill and reap, no-follow executable checks, exact response parsing, and deterministic teardown so the later event-loop UI cannot improvise subprocess behavior" + "the next host-only slice adds no runtime consumer or device action; it provides a nonblocking fixed-argv client for scan start, status, results, cancel, and discard with strict operation IDs, a 16-KiB output ceiling, a five-second command deadline, process-group kill and reap, no-follow executable checks, exact response parsing, and deterministic teardown so the later event-loop UI cannot improvise subprocess behavior", + "GitHub PR #547 used risk-scoped CI: format passed in 23 seconds, 58 focused shell tests in 43 seconds, native tests in 1 minute 8 seconds, AddressSanitizer in 1 minute 12 seconds, the affected Bloom Shell ARM build in 1 minute 21 seconds, and scoped static analysis in 2 minutes 8 seconds; it merged by squash as 765f2038b7d63e61d6db8bab1a70ce750a07d088", + "the following host-only lifecycle controller sequences start, bounded status polling, results, and terminal discard through the single-flight request client; it preserves terminal results in memory while clearing the service operation, and a cancel received during start is deferred until the exact operation ID arrives before status confirmation and discard" ] }, { diff --git a/src/bloomShell/bloom_shell_wifi_ui.c b/src/bloomShell/bloom_shell_wifi_ui.c index 570611f5a..d8a339c1a 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.c +++ b/src/bloomShell/bloom_shell_wifi_ui.c @@ -583,3 +583,202 @@ void bloom_shell_wifi_request_destroy(BloomShellWifiRequest *request) close(request->output_fd); bloom_shell_wifi_request_init(request); } + +void bloom_shell_wifi_controller_init(BloomShellWifiController *controller) +{ + if (controller == NULL) + return; + memset(controller, 0, sizeof(*controller)); + bloom_shell_wifi_request_init(&controller->request); +} + +static void scan_model_reset(BloomShellWifiUi *wifi) +{ + wifi->scan_id[0] = '\0'; + wifi->scan_phase = BLOOM_SHELL_WIFI_SCAN_IDLE; + wifi->scan_error[0] = '\0'; + wifi->scan_result_count = 0; + wifi->scan_started_at = 0; + wifi->scan_completed_at = 0; + wifi->network_count = 0; + memset(wifi->networks, 0, sizeof(wifi->networks)); +} + +int bloom_shell_wifi_controller_begin(BloomShellWifiController *controller, + const char *bloomctl_path, + BloomShellWifiUi *wifi, uint64_t now_ms) +{ + if (controller == NULL || wifi == NULL || + bloom_shell_wifi_controller_active(controller) || wifi->scan_id[0] != '\0') + return -1; + scan_model_reset(wifi); + controller->terminal_phase = BLOOM_SHELL_WIFI_SCAN_IDLE; + controller->next_poll_ms = 0; + controller->cancel_pending = 0; + if (bloom_shell_wifi_request_start( + &controller->request, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_START, NULL, now_ms) != 0) { + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; + return -1; + } + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_STARTING; + return 0; +} + +static int controller_start(BloomShellWifiController *controller, + const char *bloomctl_path, + BloomShellWifiRequestKind kind, + const char *operation_id_value, uint64_t now_ms, + BloomShellWifiControllerState state) +{ + if (bloom_shell_wifi_request_start(&controller->request, bloomctl_path, kind, + operation_id_value, now_ms) != 0) { + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; + return -1; + } + controller->state = state; + return 0; +} + +static int controller_discard(BloomShellWifiController *controller, + const char *bloomctl_path, + const BloomShellWifiUi *wifi, uint64_t now_ms) +{ + controller->terminal_phase = wifi->scan_phase; + return controller_start(controller, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD, + wifi->scan_id, now_ms, + BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING); +} + +static void controller_discarded(BloomShellWifiController *controller, + BloomShellWifiUi *wifi) +{ + wifi->scan_id[0] = '\0'; + controller->cancel_pending = 0; + if (controller->terminal_phase == BLOOM_SHELL_WIFI_SCAN_SUCCEEDED) + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE; + else if (controller->terminal_phase == BLOOM_SHELL_WIFI_SCAN_CANCELLED) + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_CANCELLED; + else + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; +} + +int bloom_shell_wifi_controller_poll(BloomShellWifiController *controller, + const char *bloomctl_path, + BloomShellWifiUi *wifi, uint64_t now_ms) +{ + if (controller == NULL || wifi == NULL) + return -1; + if (!bloom_shell_wifi_request_active(&controller->request)) { + if (controller->state != BLOOM_SHELL_WIFI_CONTROLLER_POLLING || + now_ms < controller->next_poll_ms) + return 0; + return controller_start(controller, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS, + wifi->scan_id, now_ms, + BLOOM_SHELL_WIFI_CONTROLLER_POLLING) == 0 + ? 1 + : -1; + } + BloomShellWifiRequestKind completed = controller->request.kind; + int result = bloom_shell_wifi_request_poll(&controller->request, wifi, now_ms); + if (result == 0) + return 0; + if (result < 0) { + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; + return -1; + } + switch (completed) { + case BLOOM_SHELL_WIFI_REQUEST_SCAN_START: + if (controller->cancel_pending) + return controller_start(controller, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL, + wifi->scan_id, now_ms, + BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING) == 0 + ? 1 + : -1; + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_POLLING; + controller->next_poll_ms = now_ms; + return 1; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS: + if (wifi->scan_phase == BLOOM_SHELL_WIFI_SCAN_QUEUED || + wifi->scan_phase == BLOOM_SHELL_WIFI_SCAN_RUNNING) { + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_POLLING; + controller->next_poll_ms = now_ms + BLOOM_SHELL_WIFI_SCAN_POLL_INTERVAL_MS; + return 1; + } + if (wifi->scan_phase == BLOOM_SHELL_WIFI_SCAN_SUCCEEDED) + return controller_start(controller, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS, + wifi->scan_id, now_ms, + BLOOM_SHELL_WIFI_CONTROLLER_FETCHING) == 0 + ? 1 + : -1; + if (wifi->scan_phase == BLOOM_SHELL_WIFI_SCAN_FAILED || + wifi->scan_phase == BLOOM_SHELL_WIFI_SCAN_CANCELLED) + return controller_discard(controller, bloomctl_path, wifi, now_ms) == 0 + ? 1 + : -1; + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; + return -1; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS: + return controller_discard(controller, bloomctl_path, wifi, now_ms) == 0 + ? 1 + : -1; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL: + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_POLLING; + controller->next_poll_ms = now_ms; + return 1; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD: + controller_discarded(controller, wifi); + return 1; + default: + controller->state = BLOOM_SHELL_WIFI_CONTROLLER_FAILED; + return -1; + } +} + +int bloom_shell_wifi_controller_cancel(BloomShellWifiController *controller, + const char *bloomctl_path, + const BloomShellWifiUi *wifi, + uint64_t now_ms) +{ + if (controller == NULL || wifi == NULL) + return -1; + if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_STARTING) { + controller->cancel_pending = 1; + return 0; + } + if (controller->state == BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING) + return 0; + if (wifi->scan_id[0] == '\0') + return -1; + if (bloom_shell_wifi_request_active(&controller->request)) + bloom_shell_wifi_request_destroy(&controller->request); + controller->cancel_pending = 0; + return controller_start(controller, bloomctl_path, + BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL, + wifi->scan_id, now_ms, + BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING); +} + +int bloom_shell_wifi_controller_active(const BloomShellWifiController *controller) +{ + if (controller == NULL) + return 0; + return bloom_shell_wifi_request_active(&controller->request) || + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_STARTING || + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_POLLING || + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_FETCHING || + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING || + controller->state == BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING; +} + +void bloom_shell_wifi_controller_destroy(BloomShellWifiController *controller) +{ + if (controller == NULL) + return; + bloom_shell_wifi_request_destroy(&controller->request); + bloom_shell_wifi_controller_init(controller); +} diff --git a/src/bloomShell/bloom_shell_wifi_ui.h b/src/bloomShell/bloom_shell_wifi_ui.h index 5319c6870..661e34043 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.h +++ b/src/bloomShell/bloom_shell_wifi_ui.h @@ -9,6 +9,7 @@ #define BLOOM_SHELL_WIFI_NETWORK_LIMIT 32 #define BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT 16384 #define BLOOM_SHELL_WIFI_REQUEST_TIMEOUT_MS 5000 +#define BLOOM_SHELL_WIFI_SCAN_POLL_INTERVAL_MS 250 typedef enum { BLOOM_SHELL_WIFI_SECURITY_OPEN = 0, @@ -72,6 +73,26 @@ typedef struct { char output[BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT + 2]; } BloomShellWifiRequest; +typedef enum { + BLOOM_SHELL_WIFI_CONTROLLER_IDLE = 0, + BLOOM_SHELL_WIFI_CONTROLLER_STARTING, + BLOOM_SHELL_WIFI_CONTROLLER_POLLING, + BLOOM_SHELL_WIFI_CONTROLLER_FETCHING, + BLOOM_SHELL_WIFI_CONTROLLER_CANCELLING, + BLOOM_SHELL_WIFI_CONTROLLER_DISCARDING, + BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE, + BLOOM_SHELL_WIFI_CONTROLLER_FAILED, + BLOOM_SHELL_WIFI_CONTROLLER_CANCELLED, +} BloomShellWifiControllerState; + +typedef struct { + BloomShellWifiRequest request; + BloomShellWifiControllerState state; + BloomShellWifiScanPhase terminal_phase; + uint64_t next_poll_ms; + int cancel_pending; +} BloomShellWifiController; + void bloom_shell_wifi_ui_init(BloomShellWifiUi *wifi); int bloom_shell_wifi_profiles_parse(const char *json, BloomShellWifiUi *wifi); int bloom_shell_wifi_profiles_load(const char *bloomctl_path, BloomShellWifiUi *wifi); @@ -87,5 +108,18 @@ int bloom_shell_wifi_request_poll(BloomShellWifiRequest *request, BloomShellWifiUi *wifi, uint64_t now_ms); int bloom_shell_wifi_request_active(const BloomShellWifiRequest *request); void bloom_shell_wifi_request_destroy(BloomShellWifiRequest *request); +void bloom_shell_wifi_controller_init(BloomShellWifiController *controller); +int bloom_shell_wifi_controller_begin(BloomShellWifiController *controller, + const char *bloomctl_path, + BloomShellWifiUi *wifi, uint64_t now_ms); +int bloom_shell_wifi_controller_poll(BloomShellWifiController *controller, + const char *bloomctl_path, + BloomShellWifiUi *wifi, uint64_t now_ms); +int bloom_shell_wifi_controller_cancel(BloomShellWifiController *controller, + const char *bloomctl_path, + const BloomShellWifiUi *wifi, + uint64_t now_ms); +int bloom_shell_wifi_controller_active(const BloomShellWifiController *controller); +void bloom_shell_wifi_controller_destroy(BloomShellWifiController *controller); #endif diff --git a/test/test_bloom_shell_wifi_ui.cpp b/test/test_bloom_shell_wifi_ui.cpp index b7a599f0f..b147d7fd2 100644 --- a/test/test_bloom_shell_wifi_ui.cpp +++ b/test/test_bloom_shell_wifi_ui.cpp @@ -258,3 +258,125 @@ TEST(BloomShellWifiUi, BoundsAndCleansFailedAsynchronousRequests) bloom_shell_wifi_request_destroy(&request); std::filesystem::remove_all(directory); } + +static int drive_wifi_controller(BloomShellWifiController *controller, + const std::filesystem::path &script, + BloomShellWifiUi *wifi, uint64_t started_ms) +{ + for (uint64_t attempt = 0; attempt < 500; ++attempt) { + int result = bloom_shell_wifi_controller_poll( + controller, script.c_str(), wifi, started_ms + attempt * 50); + if (result < 0) + return result; + if (!bloom_shell_wifi_controller_active(controller)) + return 1; + usleep(5000); + } + return -1; +} + +TEST(BloomShellWifiUi, ControllerSequencesResultsAndDiscardsTerminalState) +{ + auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-controller-" + std::to_string(getpid())); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + auto script = directory / "bloomctl"; + auto arguments = directory / "arguments"; + auto count = directory / "status-count"; + { + std::ofstream output(script); + output << "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" << arguments.string() + << "'\ncase \"$*\" in\n" + " 'network scan start') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"start\",\"accepted\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " 'network scan status 1234567890-abcdef0123456789')\n" + " n=0; [ ! -f '" + << count.string() + << "' ] || n=$(cat '" << count.string() << "'); n=$((n + 1)); printf '%s' \"$n\" > '" + << count.string() + << "'\n" + " if [ \"$n\" -eq 1 ]; then printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation_id\":\"1234567890-abcdef0123456789\",\"phase\":\"running\",\"error\":\"none\",\"result_count\":0,\"started_at\":100,\"completed_at\":0}'; else printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation_id\":\"1234567890-abcdef0123456789\",\"phase\":\"succeeded\",\"error\":\"none\",\"result_count\":1,\"started_at\":100,\"completed_at\":103}'; fi ;;\n" + " 'network scan results 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"networks\":[{\"ssid\":\"Fixture WiFi\",\"security\":\"wpa2-psk\",\"signal_dbm\":-42}]}' ;;\n" + " 'network scan discard 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"discard\",\"discarded\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " *) exit 2 ;;\n" + "esac\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + BloomShellWifiUi wifi{}; + BloomShellWifiController controller; + bloom_shell_wifi_controller_init(&controller); + ASSERT_EQ(0, bloom_shell_wifi_controller_begin( + &controller, script.c_str(), &wifi, 1000)); + ASSERT_EQ(1, drive_wifi_controller(&controller, script, &wifi, 1000)); + EXPECT_EQ(BLOOM_SHELL_WIFI_CONTROLLER_COMPLETE, controller.state); + EXPECT_STREQ("", wifi.scan_id); + ASSERT_EQ(1U, wifi.network_count); + EXPECT_STREQ("Fixture WiFi", wifi.networks[0].ssid); + + std::ifstream input(arguments); + std::string recorded; + const char *expected[] = { + "network scan start", + "network scan status 1234567890-abcdef0123456789", + "network scan status 1234567890-abcdef0123456789", + "network scan results 1234567890-abcdef0123456789", + "network scan discard 1234567890-abcdef0123456789", + }; + for (const char *line : expected) { + ASSERT_TRUE(std::getline(input, recorded)); + EXPECT_EQ(line, recorded); + } + EXPECT_FALSE(std::getline(input, recorded)); + bloom_shell_wifi_controller_destroy(&controller); + std::filesystem::remove_all(directory); +} + +TEST(BloomShellWifiUi, ControllerHonorsCancelWhileStartIsInFlight) +{ + auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-cancel-" + std::to_string(getpid())); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + auto script = directory / "bloomctl"; + auto arguments = directory / "arguments"; + { + std::ofstream output(script); + output << "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '" << arguments.string() + << "'\ncase \"$*\" in\n" + " 'network scan start') sleep 0.05; printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"start\",\"accepted\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " 'network scan cancel 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"cancel\",\"cancelled\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " 'network scan status 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation_id\":\"1234567890-abcdef0123456789\",\"phase\":\"cancelled\",\"error\":\"cancelled\",\"result_count\":0,\"started_at\":100,\"completed_at\":101}' ;;\n" + " 'network scan discard 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"discard\",\"discarded\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\n" + " *) exit 2 ;;\n" + "esac\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + BloomShellWifiUi wifi{}; + BloomShellWifiController controller; + bloom_shell_wifi_controller_init(&controller); + ASSERT_EQ(0, bloom_shell_wifi_controller_begin( + &controller, script.c_str(), &wifi, 2000)); + ASSERT_EQ(0, bloom_shell_wifi_controller_cancel( + &controller, script.c_str(), &wifi, 2001)); + ASSERT_TRUE(controller.cancel_pending); + ASSERT_EQ(1, drive_wifi_controller(&controller, script, &wifi, 2000)); + EXPECT_EQ(BLOOM_SHELL_WIFI_CONTROLLER_CANCELLED, controller.state); + EXPECT_STREQ("", wifi.scan_id); + EXPECT_EQ(0U, wifi.network_count); + + std::ifstream input(arguments); + std::string recorded; + const char *expected[] = { + "network scan start", + "network scan cancel 1234567890-abcdef0123456789", + "network scan status 1234567890-abcdef0123456789", + "network scan discard 1234567890-abcdef0123456789", + }; + for (const char *line : expected) { + ASSERT_TRUE(std::getline(input, recorded)); + EXPECT_EQ(line, recorded); + } + EXPECT_FALSE(std::getline(input, recorded)); + bloom_shell_wifi_controller_destroy(&controller); + std::filesystem::remove_all(directory); +}