From 998b481e83dcfaed958743029c6e85bb1c446c14 Mon Sep 17 00:00:00 2001 From: boburning Date: Sun, 30 Aug 2026 00:28:56 -0400 Subject: [PATCH] feat(network): add nonblocking Wi-Fi scan client --- docs/1.0_TEST_MATRIX.md | 2 +- docs/ARCHITECTURE.md | 7 + docs/DEVELOPMENT.md | 8 + docs/SECURITY.md | 8 + docs/program/BloomOS_Work_Program_v3.json | 9 +- src/bloomShell/bloom_shell_wifi_ui.c | 249 +++++++++++++++++++++- src/bloomShell/bloom_shell_wifi_ui.h | 31 +++ test/test_bloom_shell_wifi_ui.cpp | 145 +++++++++++++ 8 files changed, 450 insertions(+), 9 deletions(-) diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 8e211013a..1a3aff4ef 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; exact profile/scan 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; 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 | | 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 cb58ed790..b98534c89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -552,6 +552,13 @@ saved networks only. Left/right on the Wi-Fi quick row is deliberately inert. Scan, association, credential entry, forget, and a recovery-qualified power control remain later NET-05 slices. +The scan surface uses a separate nonblocking command client instead of waiting +inside the SDL input or render path. It exposes only fixed `scan start`, +`status`, `results`, `cancel`, and `discard` operations, accepts only the +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. + 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 36e121f74..f77f02a8a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -373,6 +373,14 @@ left/right on the Wi-Fi quick row is a no-op. Do not restore a direct loading must remain a single bounded fixed-argv read outside `draw()`, and UI tests must use synthetic SSIDs only. +Wire scan UI events only through `BloomShellWifiRequest`. The request object is +single-flight and nonblocking; poll it from a bounded SDL timer event and call +its destroy function on every Shell exit. Do not invoke a scan command from +`draw()`, add an arbitrary argv path, raise the 16-KiB output ceiling, or reuse +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. + 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 8d395ca7b..a29798a96 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -135,4 +135,12 @@ 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. +The future scan UI may invoke only the nonblocking fixed-argv request client. +That boundary requires a regular non-symlinked `bloomctl`, redirects stdin and +stderr to `/dev/null`, closes inherited descriptors, validates operation IDs +before fork, accepts at most 16 KiB of stdout, enforces a five-second monotonic +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. + 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 79d616a7f..ac8342e63 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -881,18 +881,21 @@ "UI-05" ], "github_prs": [ - 545 + 545, + 546 ], "risk_tier": 1, "deployment_tier": "hot", "device_gate_required": true, - "last_audited_main": "3b0a13a30d34733f03831b0d041785c4fc6dc3ae", + "last_audited_main": "7dee3fc27b5b0cd5b743af99f8e66b631c514c12", "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", "the bounded Shell model accepts at most 32 unique scan results, one 18-32 byte operation ID, fixed scan phases and errors, integral timestamps and signal values, and exact schema-1 service identities; this slice adds no rendered surface, radio toggle, scan, association, profile transition, listener mutation, reboot, update, or device mutation", "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" + "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" ] }, { diff --git a/src/bloomShell/bloom_shell_wifi_ui.c b/src/bloomShell/bloom_shell_wifi_ui.c index d8ffe8ee1..570611f5a 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.c +++ b/src/bloomShell/bloom_shell_wifi_ui.c @@ -3,11 +3,13 @@ #include #include +#include #include #include #include #include #include +#include #include #include @@ -44,20 +46,25 @@ static int safe_id(const cJSON *value) return 1; } -static int operation_id(const cJSON *value) +static int operation_id_string(const char *value) { - if (!bounded_string(value, 32, 1)) + if (value == NULL || value[0] == '\0') return 0; - size_t length = strlen(value->valuestring); - if (length < 18 || value->valuestring[0] == '-' || value->valuestring[length - 1] == '-') + size_t length = strlen(value); + if (length < 18 || length > 32 || value[0] == '-' || value[length - 1] == '-') return 0; - for (const char *cursor = value->valuestring; *cursor != '\0'; ++cursor) + for (const char *cursor = value; *cursor != '\0'; ++cursor) if (!((*cursor >= '0' && *cursor <= '9') || (*cursor >= 'a' && *cursor <= 'f') || *cursor == '-')) return 0; return 1; } +static int operation_id(const cJSON *value) +{ + return bounded_string(value, 32, 1) && operation_id_string(value->valuestring); +} + static int unsigned_integer(const cJSON *value, uint64_t maximum, uint64_t *result) { if (!cJSON_IsNumber(value) || value->valuedouble < 0.0 || @@ -344,3 +351,235 @@ int bloom_shell_wifi_scan_results_parse(const char *json, BloomShellWifiUi *wifi cJSON_Delete(root); return valid ? 0 : -1; } + +void bloom_shell_wifi_request_init(BloomShellWifiRequest *request) +{ + if (request == NULL) + return; + memset(request, 0, sizeof(*request)); + request->output_fd = -1; +} + +static int executable_regular(const char *path) +{ + struct stat metadata; + return path != NULL && path[0] == '/' && lstat(path, &metadata) == 0 && + S_ISREG(metadata.st_mode) && !S_ISLNK(metadata.st_mode) && access(path, X_OK) == 0; +} + +static int request_kind_valid(BloomShellWifiRequestKind kind) +{ + return kind >= BLOOM_SHELL_WIFI_REQUEST_SCAN_START && + kind <= BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD; +} + +static const char *request_operation(BloomShellWifiRequestKind kind) +{ + switch (kind) { + case BLOOM_SHELL_WIFI_REQUEST_SCAN_START: + return "start"; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS: + return "status"; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS: + return "results"; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL: + return "cancel"; + case BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD: + return "discard"; + default: + return NULL; + } +} + +static void close_inherited_descriptors(void) +{ + long descriptor_limit = sysconf(_SC_OPEN_MAX); + if (descriptor_limit < 0 || descriptor_limit > 4096) + descriptor_limit = 4096; + for (int descriptor = STDERR_FILENO + 1; descriptor < descriptor_limit; ++descriptor) + close(descriptor); +} + +int bloom_shell_wifi_request_start(BloomShellWifiRequest *request, + const char *bloomctl_path, + BloomShellWifiRequestKind kind, + const char *operation_id_value, uint64_t now_ms) +{ + const char *operation = request_operation(kind); + int needs_identifier = kind != BLOOM_SHELL_WIFI_REQUEST_SCAN_START; + if (request == NULL || request->child > 0 || request->output_fd != -1 || + !request_kind_valid(kind) || !executable_regular(bloomctl_path) || + (needs_identifier && !operation_id_string(operation_id_value)) || + (!needs_identifier && operation_id_value != NULL && operation_id_value[0] != '\0')) + return -1; + int output[2]; + if (pipe(output) != 0) + return -1; + pid_t child = fork(); + if (child < 0) { + close(output[0]); + close(output[1]); + return -1; + } + if (child == 0) { + close(output[0]); + if (setpgid(0, 0) != 0 || dup2(output[1], STDOUT_FILENO) < 0) + _exit(127); + close(output[1]); + int null_descriptor = open("/dev/null", O_RDWR); + if (null_descriptor < 0 || dup2(null_descriptor, STDIN_FILENO) < 0 || + dup2(null_descriptor, STDERR_FILENO) < 0) + _exit(127); + if (null_descriptor > STDERR_FILENO) + close(null_descriptor); + close_inherited_descriptors(); + if (needs_identifier) + execl(bloomctl_path, bloomctl_path, "network", "scan", operation, + operation_id_value, (char *)NULL); + else + execl(bloomctl_path, bloomctl_path, "network", "scan", operation, + (char *)NULL); + _exit(127); + } + close(output[1]); + int descriptor_flags = fcntl(output[0], F_GETFL); + if ((setpgid(child, child) != 0 && errno != EACCES && errno != ESRCH) || + descriptor_flags < 0 || + fcntl(output[0], F_SETFL, descriptor_flags | O_NONBLOCK) < 0) { + kill(-child, SIGKILL); + kill(child, SIGKILL); + close(output[0]); + while (waitpid(child, NULL, 0) < 0 && errno == EINTR) { + } + return -1; + } + request->child = child; + request->output_fd = output[0]; + request->kind = kind; + request->started_ms = now_ms; + request->output_length = 0; + request->output[0] = '\0'; + if (needs_identifier) + snprintf(request->operation_id, sizeof(request->operation_id), "%s", + operation_id_value); + else + request->operation_id[0] = '\0'; + return 0; +} + +static int request_read(BloomShellWifiRequest *request) +{ + while (request->output_length <= BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT) { + ssize_t count = read(request->output_fd, + request->output + request->output_length, + sizeof(request->output) - request->output_length - 1); + if (count > 0) { + request->output_length += (size_t)count; + if (request->output_length > BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT) + return -1; + continue; + } + if (count == 0) + return 1; + if (errno == EINTR) + continue; + return errno == EAGAIN || errno == EWOULDBLOCK ? 0 : -1; + } + return -1; +} + +static void request_close(BloomShellWifiRequest *request) +{ + if (request->output_fd >= 0) + close(request->output_fd); + request->output_fd = -1; + request->child = 0; +} + +static int scan_ack_parse(const char *json, BloomShellWifiRequestKind kind, + const char *expected_id) +{ + cJSON *root = cJSON_ParseWithOpts(json, NULL, 1); + const cJSON *operation = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "operation"); + const cJSON *identifier = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "operation_id"); + const char *operation_name = request_operation(kind); + const char *result_name = kind == BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL ? "cancelled" : "discarded"; + const cJSON *result = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, result_name); + int valid = exact_object(root, 5) && root_identity(root, "bloom-wifi-scan") && + bounded_string(operation, 16, 1) && operation_name != NULL && + strcmp(operation->valuestring, operation_name) == 0 && + cJSON_IsTrue(result) && operation_id(identifier) && expected_id != NULL && + strcmp(identifier->valuestring, expected_id) == 0; + cJSON_Delete(root); + return valid ? 0 : -1; +} + +static int request_parse(const BloomShellWifiRequest *request, BloomShellWifiUi *wifi) +{ + switch (request->kind) { + case BLOOM_SHELL_WIFI_REQUEST_SCAN_START: + return bloom_shell_wifi_scan_start_parse(request->output, wifi); + case BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS: + return bloom_shell_wifi_scan_status_parse(request->output, wifi); + case BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS: + return bloom_shell_wifi_scan_results_parse(request->output, wifi); + case BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL: + case BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD: + return scan_ack_parse(request->output, request->kind, request->operation_id); + default: + return -1; + } +} + +int bloom_shell_wifi_request_poll(BloomShellWifiRequest *request, + BloomShellWifiUi *wifi, uint64_t now_ms) +{ + if (request == NULL || wifi == NULL || request->child <= 0 || request->output_fd < 0) + return -1; + int read_result = request_read(request); + int status = 0; + pid_t result = waitpid(request->child, &status, WNOHANG); + if (read_result < 0 || + (result == 0 && now_ms - request->started_ms >= BLOOM_SHELL_WIFI_REQUEST_TIMEOUT_MS)) { + kill(-request->child, SIGKILL); + kill(request->child, SIGKILL); + while (waitpid(request->child, NULL, 0) < 0 && errno == EINTR) { + } + request_close(request); + return -1; + } + if (result == 0) + return 0; + if (result < 0) { + if (errno == EINTR) + return 0; + request_close(request); + return -1; + } + read_result = request_read(request); + request_close(request); + if (read_result < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0) + return -1; + request->output[request->output_length] = '\0'; + return request_parse(request, wifi) == 0 ? 1 : -1; +} + +int bloom_shell_wifi_request_active(const BloomShellWifiRequest *request) +{ + return request != NULL && request->child > 0 && request->output_fd >= 0; +} + +void bloom_shell_wifi_request_destroy(BloomShellWifiRequest *request) +{ + if (request == NULL) + return; + if (request->child > 0) { + kill(-request->child, SIGKILL); + kill(request->child, SIGKILL); + while (waitpid(request->child, NULL, 0) < 0 && errno == EINTR) { + } + } + if (request->output_fd >= 0) + close(request->output_fd); + bloom_shell_wifi_request_init(request); +} diff --git a/src/bloomShell/bloom_shell_wifi_ui.h b/src/bloomShell/bloom_shell_wifi_ui.h index 913f37541..5319c6870 100644 --- a/src/bloomShell/bloom_shell_wifi_ui.h +++ b/src/bloomShell/bloom_shell_wifi_ui.h @@ -3,9 +3,12 @@ #include #include +#include #define BLOOM_SHELL_WIFI_PROFILE_LIMIT 16 #define BLOOM_SHELL_WIFI_NETWORK_LIMIT 32 +#define BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT 16384 +#define BLOOM_SHELL_WIFI_REQUEST_TIMEOUT_MS 5000 typedef enum { BLOOM_SHELL_WIFI_SECURITY_OPEN = 0, @@ -50,11 +53,39 @@ typedef struct { BloomShellWifiNetwork networks[BLOOM_SHELL_WIFI_NETWORK_LIMIT]; } BloomShellWifiUi; +typedef enum { + BLOOM_SHELL_WIFI_REQUEST_NONE = 0, + BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS, + BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS, + BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL, + BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD, +} BloomShellWifiRequestKind; + +typedef struct { + pid_t child; + int output_fd; + BloomShellWifiRequestKind kind; + uint64_t started_ms; + size_t output_length; + char operation_id[33]; + char output[BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT + 2]; +} BloomShellWifiRequest; + 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); int bloom_shell_wifi_scan_start_parse(const char *json, BloomShellWifiUi *wifi); int bloom_shell_wifi_scan_status_parse(const char *json, BloomShellWifiUi *wifi); int bloom_shell_wifi_scan_results_parse(const char *json, BloomShellWifiUi *wifi); +void bloom_shell_wifi_request_init(BloomShellWifiRequest *request); +int bloom_shell_wifi_request_start(BloomShellWifiRequest *request, + const char *bloomctl_path, + BloomShellWifiRequestKind kind, + const char *operation_id, uint64_t now_ms); +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); #endif diff --git a/test/test_bloom_shell_wifi_ui.cpp b/test/test_bloom_shell_wifi_ui.cpp index 47464dced..b7a599f0f 100644 --- a/test/test_bloom_shell_wifi_ui.cpp +++ b/test/test_bloom_shell_wifi_ui.cpp @@ -113,3 +113,148 @@ TEST(BloomShellWifiUi, ParsesPrivacySafeNetworksAndRejectsMalformedState) R"({"schema":1,"service":"bloom-wifi-scan","operation_id":"1234567890-abcdef0123456789","phase":"succeeded","error":"backend_failed","result_count":0,"started_at":100,"completed_at":101})", &wifi)); } + +static int poll_wifi_request(BloomShellWifiRequest *request, BloomShellWifiUi *wifi, + uint64_t started_ms) +{ + for (uint64_t attempt = 0; attempt < 200; ++attempt) { + int result = bloom_shell_wifi_request_poll(request, wifi, + started_ms + attempt * 10); + if (result != 0) + return result; + usleep(10000); + } + return -1; +} + +TEST(BloomShellWifiUi, RunsOnlyFixedAsynchronousScanCommands) +{ + auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-request-" + 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') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"start\",\"accepted\":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\":\"succeeded\",\"error\":\"none\",\"result_count\":1,\"started_at\":100,\"completed_at\":103}' ;;\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 cancel 1234567890-abcdef0123456789') printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-wifi-scan\",\"operation\":\"cancel\",\"cancelled\":true,\"operation_id\":\"1234567890-abcdef0123456789\"}' ;;\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{}; + BloomShellWifiRequest request; + bloom_shell_wifi_request_init(&request); + uint64_t now = 1000; + + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + nullptr, now)); + EXPECT_TRUE(bloom_shell_wifi_request_active(&request)); + ASSERT_EQ(1, poll_wifi_request(&request, &wifi, now)); + EXPECT_FALSE(bloom_shell_wifi_request_active(&request)); + EXPECT_STREQ("1234567890-abcdef0123456789", wifi.scan_id); + + const BloomShellWifiRequestKind operations[] = { + BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS, + BLOOM_SHELL_WIFI_REQUEST_SCAN_RESULTS, + BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL, + BLOOM_SHELL_WIFI_REQUEST_SCAN_DISCARD, + }; + for (BloomShellWifiRequestKind operation : operations) { + now += 1000; + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &request, script.c_str(), operation, wifi.scan_id, now)); + ASSERT_EQ(1, poll_wifi_request(&request, &wifi, now)); + } + 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 results 1234567890-abcdef0123456789", + "network scan cancel 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_request_destroy(&request); + std::filesystem::remove_all(directory); +} + +TEST(BloomShellWifiUi, BoundsAndCleansFailedAsynchronousRequests) +{ + auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-request-failure-" + std::to_string(getpid())); + std::filesystem::remove_all(directory); + std::filesystem::create_directories(directory); + auto script = directory / "bloomctl"; + auto symlink = directory / "linked-bloomctl"; + BloomShellWifiUi wifi{}; + BloomShellWifiRequest request; + bloom_shell_wifi_request_init(&request); + + { + std::ofstream output(script); + output << "#!/bin/sh\nsleep 10\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + nullptr, 100)); + EXPECT_EQ(-1, bloom_shell_wifi_request_poll( + &request, &wifi, 100 + BLOOM_SHELL_WIFI_REQUEST_TIMEOUT_MS)); + EXPECT_FALSE(bloom_shell_wifi_request_active(&request)); + + { + std::ofstream output(script); + output << "#!/bin/sh\nhead -c " + << BLOOM_SHELL_WIFI_REQUEST_OUTPUT_LIMIT + 1 + << " /dev/zero | tr '\\000' X\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + nullptr, 1000)); + EXPECT_EQ(-1, poll_wifi_request(&request, &wifi, 1000)); + EXPECT_FALSE(bloom_shell_wifi_request_active(&request)); + + { + std::ofstream output(script); + output << "#!/bin/sh\nprintf '%s\\n' " + "'{\"schema\":1,\"service\":\"bloom-wifi-scan\"," + "\"operation\":\"cancel\",\"cancelled\":true," + "\"operation_id\":\"1234567890-deadbeefdeadbeef\"}'\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + ASSERT_EQ(0, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_CANCEL, + "1234567890-abcdef0123456789", 1500)); + EXPECT_EQ(-1, poll_wifi_request(&request, &wifi, 1500)); + EXPECT_FALSE(bloom_shell_wifi_request_active(&request)); + + ASSERT_EQ(0, ::symlink(script.c_str(), symlink.c_str())); + EXPECT_EQ(-1, bloom_shell_wifi_request_start( + &request, symlink.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + nullptr, 2000)); + EXPECT_EQ(-1, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_STATUS, + "../unsafe", 2000)); + EXPECT_EQ(-1, bloom_shell_wifi_request_start( + &request, script.c_str(), BLOOM_SHELL_WIFI_REQUEST_SCAN_START, + "1234567890-abcdef0123456789", 2000)); + bloom_shell_wifi_request_destroy(&request); + std::filesystem::remove_all(directory); +}