diff --git a/.gitattributes b/.gitattributes index 100f5edd5..33a00e9d8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ *.py text eol=lf *.json text eol=lf build/openbor/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab +tools/patches/*.patch text eol=lf whitespace=-trailing-space,-space-before-tab tools/bloom-device text eol=lf tools/*.sh text eol=lf tools/bloom-mini-test text eol=lf diff --git a/.github/workflows/shell-test.yml b/.github/workflows/shell-test.yml index a561b73a4..874322ed9 100644 --- a/.github/workflows/shell-test.yml +++ b/.github/workflows/shell-test.yml @@ -18,6 +18,7 @@ on: - 'tools/core_manifest.py' - 'tools/validate_core_inventory_disposition.py' - 'tools/provenance_policy.py' + - 'tools/run-shell-tests.sh' - 'tools/shell_test_inputs.py' - 'tools/legacy_manifest.py' - 'tools/validate_core_selection_policy.py' @@ -76,9 +77,9 @@ jobs: if [ "$targets" = full ]; then make test-shell elif [ "$targets" != none ]; then - docker run --rm --volume "$PWD:/workspace:ro" \ + ./tools/run-shell-tests.sh "$PWD" \ ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 \ - $targets + 1 $targets fi { echo '| Gate | Seconds | Scope |' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89229edd1..0a60702d8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,7 @@ name: Test build on: merge_group: + workflow_dispatch: pull_request: paths: - '.github/workflows/test.yml' @@ -29,6 +30,55 @@ concurrency: cancel-in-progress: true jobs: + static-analysis: + name: Static analysis + runs-on: ubuntu-latest + + steps: + - name: Install cppcheck + run: | + sudo apt-get update + sudo apt-get install --yes cppcheck + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + - name: Resolve static-analysis scope + id: scope + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: echo "targets=$(git diff --name-only "$BASE_SHA"...HEAD | sh tools/ci-scope.sh static-analysis)" >> "$GITHUB_OUTPUT" + - name: Run static analysis + shell: bash + run: | + started=$SECONDS + targets='${{ steps.scope.outputs.targets }}' + if [ '${{ github.event_name }}' != 'pull_request' ]; then + # Full-source integration remains exhaustive by ownership boundary, + # while native, ASan, and ARM compile the real configurations. Avoid + # cppcheck's speculative exponential preprocessor expansion here too. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS=--max-configs=1 + targets=full + elif [ "$targets" = full ]; then + # PR feedback keeps the complete source closure but bounds speculative + # configuration expansion. Merge-group runs retain the exhaustive gate. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS=--max-configs=1 + elif [ "$targets" != none ]; then + # Native, ASAN, and ARM builds cover the real configurations. Bound + # speculative preprocessor expansion for the affected PR components; + # unused-function analysis needs the full source closure below. + make static-analysis CPPCHECK_JOBS="$(nproc)" \ + CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction" \ + STATIC_ANALYSIS_TARGETS="$targets" + fi + echo "| Gate | Seconds | Scope |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---:|---|" >> "$GITHUB_STEP_SUMMARY" + echo "| Static analysis | $((SECONDS - started)) | $targets |" >> "$GITHUB_STEP_SUMMARY" + build: name: Test build runs-on: ubuntu-latest @@ -37,7 +87,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update - sudo apt-get install --yes build-essential cmake cppcheck libgtest-dev libsdl1.2-dev libsdl-image1.2-dev libsdl-ttf2.0-dev + sudo apt-get install --yes build-essential cmake libgtest-dev libsdl1.2-dev libsdl-image1.2-dev libsdl-ttf2.0-dev - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -65,27 +115,6 @@ jobs: run: | echo '| Gate | Seconds | Scope |' >> "$GITHUB_STEP_SUMMARY" echo '|---|---:|---|' >> "$GITHUB_STEP_SUMMARY" - - name: Resolve static-analysis scope - id: scope - if: github.event_name == 'pull_request' - shell: bash - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: echo "targets=$(git diff --name-only "$BASE_SHA"...HEAD | sh tools/ci-scope.sh static-analysis)" >> "$GITHUB_OUTPUT" - - name: Run static analysis - run: | - started=$SECONDS - targets='${{ steps.scope.outputs.targets }}' - if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$targets" = full ]; then - make static-analysis - targets=full - elif [ "$targets" != none ]; then - make external-libs - # Native, ASAN, and ARM builds cover the real configurations. Bound - # speculative preprocessor expansion for the affected PR components. - cppcheck --max-configs=1 -I include --enable=all $targets - fi - echo "| Static analysis | $((SECONDS - started)) | $targets |" >> "$GITHUB_STEP_SUMMARY" - name: Resolve affected test risk id: tests if: github.event_name == 'pull_request' @@ -103,10 +132,10 @@ jobs: started=$SECONDS filters='${{ steps.tests.outputs.filters }}' if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$filters" = full ]; then - GTEST_INCLUDE_DIR=/usr/include make test + GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test filters=full elif [ "$filters" != none ]; then - GTEST_INCLUDE_DIR=/usr/include make test GTEST_FILTER="$filters" + GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test GTEST_FILTER="$filters" fi echo "| Native | $((SECONDS - started)) | risk ${{ steps.tests.outputs.risk || '3' }}, $filters; visuals ${{ steps.tests.outputs.visuals || 'full' }} |" >> "$GITHUB_STEP_SUMMARY" - name: Test with AddressSanitizer @@ -117,9 +146,9 @@ jobs: started=$SECONDS filters='${{ steps.tests.outputs.filters }}' if [ '${{ github.event_name }}' != 'pull_request' ] || [ "$filters" = full ]; then - GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make test + GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make -j"$(nproc)" test filters=full elif [ "$filters" != none ]; then - GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make test GTEST_FILTER="$filters" + GTEST_INCLUDE_DIR=/usr/include SANITIZE=1 make -j"$(nproc)" test GTEST_FILTER="$filters" fi echo "| ASan | $((SECONDS - started)) | risk ${{ steps.tests.outputs.risk || '3' }}, $filters |" >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index 03ec1e9ef..7d42369c2 100644 --- a/Makefile +++ b/Makefile @@ -54,6 +54,11 @@ endif TOOLCHAIN := aemiii91/miyoomini-toolchain@sha256:e5123590ad75d27f0f4c91196e3119a255cad45f3ae15243e29a8e0a2ec50132 SHELL_TEST_IMAGE := ghcr.io/boburning/bloomos-shell-test@sha256:36adeca7c265345688ebf47b575c56e350b9016257e0a9ed15c63dc7e0447d63 +SHELL_TEST_JOBS ?= 8 +CPPCHECK_JOBS ?= 8 +CPPCHECK_BUILD_DIR ?= $(BUILD_TEST_DIR)/cppcheck +CPPCHECK_CONFIG_FLAGS ?= +STATIC_ANALYSIS_TARGETS ?= $(SRC_DIR) include ./src/common/commands.mk @@ -172,6 +177,7 @@ core: $(CACHE)/.setup shared-libs @cp $(ROOT_DIR)/build/ra-core-policy.json $(BUILD_DIR)/.tmp_update/config/ra-core-policy.json @cp $(ROOT_DIR)/build/core-selection-policy.json $(BUILD_DIR)/.tmp_update/config/core-selection-policy.json @cp $(ROOT_DIR)/build/save-migration-policy.json $(BUILD_DIR)/.tmp_update/config/save-migration-policy.json + @cp $(ROOT_DIR)/build/transfer-policy.json $(BUILD_DIR)/.tmp_update/config/transfer-policy.json @cp $(ROOT_DIR)/build/system-catalog.json $(BUILD_DIR)/.tmp_update/config/system-catalog.json # Build dependencies for installer @@ -274,6 +280,9 @@ package-release-unsigned: --policy $(ROOT_DIR)/build/save-migration-policy.json \ --schema $(ROOT_DIR)/build/save-migration-policy.schema.json \ --core-policy $(ROOT_DIR)/build/core-selection-policy.json + @python3 $(ROOT_DIR)/tools/validate_transfer_policy.py \ + --policy $(ROOT_DIR)/build/transfer-policy.json \ + --schema $(ROOT_DIR)/build/transfer-policy.schema.json @python3 $(ROOT_DIR)/tools/legacy_manifest.py validate \ --repository $(ROOT_DIR) \ --manifest $(ROOT_DIR)/build/legacy-manifest.json @@ -360,14 +369,15 @@ test: external-libs @cp -R $(TEST_SRC_DIR)/infoPanel_test_data $(BUILD_TEST_DIR)/ cd $(BUILD_TEST_DIR) && LD_LIBRARY_PATH=$(ROOT_DIR)/lib/ ./test $(if $(GTEST_FILTER),--gtest_filter=$(GTEST_FILTER),) -static-analysis: external-libs - @cd $(ROOT_DIR) && cppcheck -I $(INCLUDE_DIR) --enable=all $(SRC_DIR) +static-analysis: + @mkdir -p $(CPPCHECK_BUILD_DIR) + @cd $(ROOT_DIR) && cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS) $(CPPCHECK_CONFIG_FLAGS) -I $(INCLUDE_DIR) --enable=all $(STATIC_ANALYSIS_TARGETS) format: @find ./src -regex '.*\.\(c\|h\|cpp\|hpp\)' -exec clang-format -style=file -i {} \; test-shell: - docker run --rm --volume "$(ROOT_DIR):/workspace:ro" $(SHELL_TEST_IMAGE) /workspace/test/shell + ./tools/run-shell-tests.sh "$(ROOT_DIR)" "$(SHELL_TEST_IMAGE)" "$(SHELL_TEST_JOBS)" rebuild-shell-test-image: rm -rf $(BUILD_TEST_DIR)/shell-test-inputs diff --git a/build/dependencies.lock b/build/dependencies.lock index b815f9ade..aaafa1598 100644 --- a/build/dependencies.lock +++ b/build/dependencies.lock @@ -184,10 +184,10 @@ legacy_channels = ["development"] [legacy_inventory] manifest = "build/legacy-manifest.json" schema = 1 -component_count = 329 -file_count = 2128 -byte_count = 314611699 -source_build_components = 225 +component_count = 330 +file_count = 2129 +byte_count = 314637632 +source_build_components = 226 unresolved_components = 104 inherited_baseline = "07505ea58c7bba698d6b9220ff43946a43cac76b" diff --git a/build/legacy-manifest.json b/build/legacy-manifest.json index ca8032314..f7d72a69c 100644 --- a/build/legacy-manifest.json +++ b/build/legacy-manifest.json @@ -85,8 +85,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/runtime.sh", "file_count": 1, - "byte_count": 43892, - "tree_sha256": "1bb79b1f7830bed84571c6279f96ae8516a5f85cbdbbbceeb022382127c8357a", + "byte_count": 45051, + "tree_sha256": "c6a9227bdbe235e730a9610bff62444889e975b7de8ba135f9c3c639948d657a", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -488,8 +488,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-deploy", "file_count": 1, - "byte_count": 29280, - "tree_sha256": "25ea32af0dcb972020ec907a244910b95eb95358208e01b2e3c46d237a6a0a75", + "byte_count": 30546, + "tree_sha256": "2ebc4df09a587d005dbc16815376341d1df83d7075908e0bbf79956a24616dc2", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -501,8 +501,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-dev-ssh", "file_count": 1, - "byte_count": 12254, - "tree_sha256": "5b3e19adbaf8ffd584980316584a38f5287822f8db69b98038de50d76b3cbc20", + "byte_count": 13539, + "tree_sha256": "967940060ee432fe115d726afef2ad59ce07d07bcf1e11db5e3e7a71e5514b9f", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -592,8 +592,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-listener-audit", "file_count": 1, - "byte_count": 2713, - "tree_sha256": "76dd00d7010451a0e519e451c388a7473a90c83a421b071d3ae3b8b208c0ff1f", + "byte_count": 2723, + "tree_sha256": "d0d0cc448e9122be7275d73c474619bb0515a9785b7beecf11af0574f73756ed", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -605,8 +605,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network", "file_count": 1, - "byte_count": 5444, - "tree_sha256": "13919974b5733721c9a68e847086d101aa4968850e237c8e8bd1cdeca0161bca", + "byte_count": 8583, + "tree_sha256": "ee0dd293f4c6e56efd7c853203db60209d116b8f8858a51aaa0734019f11662b", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -618,8 +618,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-services", "file_count": 1, - "byte_count": 11212, - "tree_sha256": "eaa9064c27affd9517c3ab015d74ca6575292749f6f58f8aa08bfbdfff7374e1", + "byte_count": 11448, + "tree_sha256": "8623b11bbf007bd87380f8acd514c41cea1f2b83ecca0a8f5186ef8cbda65a3e", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -631,8 +631,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-network-transaction", "file_count": 1, - "byte_count": 24317, - "tree_sha256": "63c23451c6fdfcb24d59fbee2a65ce87d1e2f484674c72a6af5423f780f123ca", + "byte_count": 27658, + "tree_sha256": "96398c18ceb12113691600ebdf18de86ff8a1dc96022ecaa9d0111f851495efe", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -787,8 +787,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-sftp-gate", "file_count": 1, - "byte_count": 695, - "tree_sha256": "b8bb9b4ffae1e679834ab32a331baec358d9b0e9167c0ea2852f1b32f0375d99", + "byte_count": 1700, + "tree_sha256": "87505677c82771b4a49bbb222ac1f163d25b95782944f4d370ae920cdc017fca", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -847,6 +847,19 @@ "build_recipe": "Makefile", "resolution": "source-build" }, + { + "id": "runtime-tmp-update-bin-bloom-transfer-policy", + "kind": "runtime", + "path": "static/build/.tmp_update/bin/bloom-transfer-policy", + "file_count": 1, + "byte_count": 7277, + "tree_sha256": "c2030f81a3648e610495089dc41a7cf18fe1ba12e9436ff4cd55ff7d871e895a", + "source": "https://github.com/boburning/BloomOS", + "source_revision": "release-commit", + "license": "GPL-3.0-only", + "build_recipe": "Makefile", + "resolution": "source-build" + }, { "id": "runtime-tmp-update-bin-bloom-update-activate", "kind": "runtime", @@ -969,8 +982,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/bin/bloom-wifi", "file_count": 1, - "byte_count": 3258, - "tree_sha256": "8e83b7a25c221b52a046847d5b81c68f3b7f9b04bc7b6cb417d91b5a780ed175", + "byte_count": 9401, + "tree_sha256": "d7dd29d8ae55b7d5f2e8b2febf1bfc318dd60f389e7bab0fe59da12e14f3d7ab", "source": "https://github.com/boburning/BloomOS", "source_revision": "release-commit", "license": "GPL-3.0-only", @@ -2295,8 +2308,8 @@ "kind": "runtime", "path": "static/build/.tmp_update/script/network", "file_count": 7, - "byte_count": 32703, - "tree_sha256": "2b826c557f9d7c948a61e9e5a45e98f111f973e80d55b70b089b1ca2d73a9289", + "byte_count": 33775, + "tree_sha256": "b8a1ea55882c2d64a63debcaf88f8bb2f45ea3038a149eab8b4877e0c97eb6df", "source": "https://github.com/OnionUI/Onion", "source_revision": "07505ea58c7bba698d6b9220ff43946a43cac76b", "license": "GPL-3.0-only", diff --git a/build/transfer-policy.json b/build/transfer-policy.json new file mode 100644 index 000000000..25ab28776 --- /dev/null +++ b/build/transfer-policy.json @@ -0,0 +1,53 @@ +{ + "schema": 1, + "policy_id": "bloomos-transfer-policy-v1", + "publication": "same_filesystem_atomic_replace", + "limits": { + "max_path_bytes": 1024, + "max_components": 32, + "max_component_bytes": 255, + "max_upload_bytes": 4294967295 + }, + "roots": [ + { + "id": "roms", + "virtual_path": "/Roms", + "device_path": "Roms", + "access": "read_write" + }, + { + "id": "bios", + "virtual_path": "/BIOS", + "device_path": "BIOS", + "access": "read_write" + }, + { + "id": "artwork", + "virtual_path": "/Artwork", + "device_path": "Imgs", + "access": "read_write" + }, + { + "id": "screenshots", + "virtual_path": "/Screenshots", + "device_path": "Screenshots", + "access": "read_write" + }, + { + "id": "themes", + "virtual_path": "/Themes", + "device_path": "Themes", + "access": "read_write" + }, + { + "id": "saves", + "virtual_path": "/Saves", + "device_path": "Saves", + "access": "read_only" + } + ], + "private_device_roots": [ + ".bloom", + ".tmp_update" + ] +} diff --git a/build/transfer-policy.schema.json b/build/transfer-policy.schema.json new file mode 100644 index 000000000..d891c1063 --- /dev/null +++ b/build/transfer-policy.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bloomos.dev/schemas/transfer-policy-v1.json", + "type": "object", + "additionalProperties": false, + "required": ["schema", "policy_id", "publication", "limits", "roots", "private_device_roots"], + "properties": { + "schema": {"const": 1}, + "policy_id": {"const": "bloomos-transfer-policy-v1"}, + "publication": {"const": "same_filesystem_atomic_replace"}, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_path_bytes", "max_components", "max_component_bytes", "max_upload_bytes"], + "properties": { + "max_path_bytes": {"type": "integer", "minimum": 1, "maximum": 4096}, + "max_components": {"type": "integer", "minimum": 1, "maximum": 64}, + "max_component_bytes": {"type": "integer", "minimum": 1, "maximum": 255}, + "max_upload_bytes": {"type": "integer", "minimum": 1, "maximum": 4294967295} + } + }, + "roots": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "virtual_path", "device_path", "access"], + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,31}$"}, + "virtual_path": {"type": "string", "pattern": "^/[A-Za-z][A-Za-z0-9_-]{0,31}$"}, + "device_path": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"}, + "access": {"enum": ["read_only", "read_write"]} + } + } + }, + "private_device_roots": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": {"type": "string", "pattern": "^\\.[a-z][a-z0-9_-]{0,31}$"} + } + } +} diff --git a/docs/1.0_TEST_MATRIX.md b/docs/1.0_TEST_MATRIX.md index 4737c5e92..751869207 100644 --- a/docs/1.0_TEST_MATRIX.md +++ b/docs/1.0_TEST_MATRIX.md @@ -23,7 +23,7 @@ observable properties and never substitutes for physical UX review. | Performance | Input/render timings, 30-minute shell soak, 500 open/back cycles | | Privacy | Allowlisted health/support export contains no private data | | Developer deployment | Dual-marker/update/session preflight; deterministic allowlisted manifest; exact byte/hash/mode transfer; sealed slots; atomic activation; readiness auto-rollback; active/previous overlay; bounded cleanup; ordinary network-helper activation denied | -| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; apply-failure, explicit, and timeout restoration; transaction-bound network overlay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | +| Remote network transaction | Separate local marker; known-good station/key/update/session preflight; private prior state; nonce digest; monotonic detached watchdog; one bounded mutation; unqualified remote Wi-Fi disable rejected before state or radio mutation; legacy Wi-Fi-disable recovery; apply-failure, explicit, timeout, and boot-interruption restoration; safe empty stale-lock recovery; transaction-bound network/boot-runtime overlay; already-restored overlay closes without preference replay; privacy-bounded health; fresh-connection confirmation; sole-route disable confirmation denied | ## Task-based UX certification @@ -46,7 +46,7 @@ shoulder button. | Themes | Declarative bounded manifest; Classic/Dense/Large Text/High Contrast; fallback on malformed input; immutable Safe Mode/recovery/credential UI | | Settings | One sectioned surface; inline values; capability filtering; one-layer details; hidden Developer section; optional overlapping L1/R1 list paging | | Quick Settings | START from core surfaces; immediate brightness/volume/mute; capability-gated Wi-Fi; readable battery; B/START close | -| Network services | Desired/runtime separation; Wi-Fi reconciliation; SSH shell and SFTP independent key-only states; SD confinement; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | +| Network services | Desired/runtime separation; carrier without IPv4 remains acquiring/not-ready; canonical DHCP restart and bounded post-address listener reconciliation; SSH shell and SFTP independent key-only states; canonical transfer roots and modes; Saves read-only; private roots denied; traversal/symlink/path bounds; same-filesystem atomic publication; authorized-key import; quiesce/health; stale FTP/Telnet/FileBrowser/inherited-Samba/WPS/hotspot flags cannot activate listeners; process-aware listener audit; Samba hidden until a materially different source-build candidate passes feasibility | | GameSwitcher | MENU open/toggle; Left/Right select; L1/R1 page; A resume; X actions; START Quick Settings; B Home; no inherited brightness/view/delete behavior | | Apps/Tweaks | Every app disposition recorded; normal configuration requires no Tweaks; supervised launch remains bounded | | Persistent state | Versioned atomic publication; exact restoration across Details/game/GameSwitcher/Quick Settings; malformed/oversized/symlink input defaults without Safe Mode | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4b0985b7e..387d6c87e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,6 +283,16 @@ the same canonical booleans with the same v1 fallback, so no listener consumer retains a parallel marker authority. Key-only SSH and command-forced, allowlisted SFTP remain unchanged. +Carrier alone is not network readiness. The station boundary reports usable +association only after a non-zero global IPv4 address exists. If carrier is up +without an address, `bloom-wifi` replaces any inherited DHCP client with its +fixed client invocation rather than preserving the incomplete state. A +detached, single-instance bounded readiness watcher reconciles listeners after the system DHCP +hook publishes that address and route. The initiating boot or UI request never +waits for the lease, the watcher accepts no command or destination input, and +it expires after 60 one-second checks. A late lease therefore restores +key-only services without another UI action or boot tick. + Bloom Shell applies that policy at a single supervised launch boundary. Only `bloom-native` and reviewed `onion-compatible` rows may stage a command; `mainui-dependent` and `development-only` rows remain visible but cannot launch @@ -436,6 +446,19 @@ keys, authorized-key storage, update state, JFFS, or the firmware filesystem. Stopping or reconciling the transport unmounts the allowlist, and a failed transport restart rolls desired markers back to the prior matrix. +`transfer-policy.json` is the versioned authority behind that chroot and the +future Web Transfer boundary. `bloom-transfer-policy mounts` is the only source +for the SFTP root/mode projection: Roms, BIOS, Artwork, Screenshots, and Themes +are read-write; Saves is read-only; `.bloom` and `.tmp_update` are never +exposed. The same helper authorizes bounded virtual paths, rejects traversal, +control characters, overlong components, hidden Bloom work names, and every +existing symlink boundary, and maps no path outside those six roots. Its +`publish` operation accepts only a regular file from the private staging root, +copies it to a private sibling on the destination filesystem, verifies the byte +count, syncs, and renames it over the destination. A failed copy therefore +cannot expose a partial final file. The Web service must consume this helper +rather than recreating the root or publication rules. + `bloom-listener-audit` is the privacy-bounded process/listener check for this boundary. It reports only reviewed or prohibited service classes, never local addresses or credentials. A clean result permits Bloom's key-only Dropbear @@ -448,10 +471,22 @@ service preferences in private schema-1 state, stores only a SHA-256 nonce digest, publishes a separate schema-1 recovery lease containing only monotonic bounds and the fixed known-good-station/key-only-SSH allowlist, and detaches a watchdog before returning the one-time capability. `apply` permits one fixed mutation and -publishes `applying` before invoking its canonical owner. `confirm` consumes the +publishes `applying` before invoking its canonical owner. Remote Wi-Fi disable +fails before that publication because a real Mini Plus radio-cycle did not +restore the sole automation route; historical persisted Wi-Fi-disable state +remains recoverable for backward compatibility. `confirm` consumes the capability only while the deadline and `applied` phase remain valid. `rollback` and the watchdog restore canonical preferences, reconcile service state, undo an activated network overlay, audit listeners, and publish a terminal phase. +If power interrupts that path, early runtime startup validates the matching +transaction and recovery lease, removes only its own empty stale lock, and runs +the same bounded rollback after canonical settings load but before ordinary +network startup. Thus a persisted Wi-Fi-off mutation cannot become the next +boot's unreviewed baseline. +If an overlay activator has already restored its prior bytes, rollback first +verifies the exact saved network and service preferences. An exact match closes +the lease without replaying service requests, avoiding both unnecessary +listener restarts and failures from unavailable optional helpers. Monotonic deadlines make wall-clock repair irrelevant to recovery. SSH/SFTP helper changes use the fixed `ssh-shadow` transaction operation. The @@ -463,7 +498,8 @@ service preferences. Outside that bounded transaction the listener audit marks port 2222 as prohibited, so it cannot become stable background state. `bloom-dev-deploy activate-network` is an internal cross-owner handshake. It -activates a sealed `network-helpers` slot only when the transaction service +activates a sealed `network-helpers` slot, including the cooperating boot +runtime, only when the transaction service confirms the same transaction, phase, operation, and deployment identifier. The normal activation path remains denied. `bloomctl health` reports a bounded transaction phase and recovery-lease diagnostic without making developer state diff --git a/docs/CI_SCOPING.md b/docs/CI_SCOPING.md index c2e4fac6b..b451f3627 100644 --- a/docs/CI_SCOPING.md +++ b/docs/CI_SCOPING.md @@ -34,6 +34,21 @@ ASan, static-analysis, shell, and ARM build paths. This is the cross-PR integration boundary. Required CI never depends on a personal handheld or live network/provider. +When the repository does not provide a protected merge queue, dispatch the +`Test build` workflow manually against the exact candidate head before merge. +The dispatch follows the same full-source path as `merge_group`; a green bounded +pull-request run is not a substitute for that exact-head integration gate. + +Cppcheck uses one bounded preprocessor configuration in every mode. Full-source +workflow-dispatch and merge-group gates still analyze the complete source +closure and retain whole-program checks; pull-request feedback analyzes either +that same closure or the affected components. The affected-component gate +suppresses unused-function reports that cannot be proven without omitted +sources. Native, ASan, and ARM builds compile the real configurations, so +speculative cppcheck configuration expansion adds latency without replacing a +compiler gate. All modes use cppcheck's build directory and the hosted runner's +available cores; test selection and assertions remain unchanged. + Each required workflow writes gate duration, selected risk, and selected scope to the GitHub step summary. Collect those records before setting p50/p95 budgets; no maintenance-heavy cache or test container should be introduced diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e8b872a8b..664390061 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -171,6 +171,14 @@ data roots are rejected. Ordinary activation refuses `network-helpers`; remote network mutations use a separate rollback-timed workflow so the only working SSH path cannot be replaced by this command. +The complete settings, platform, Wi-Fi, listener/service, transaction, remote +key, SFTP, transfer-policy, and boot-recovery runtime set forms one guarded 17-file +`network-helpers` deployment group. Host and device manifest validation reject +every partial group so incompatible schema generations cannot be mixed on a +live device. Activate that bundle only through the rollback-timed network +overlay path, then validate policy status, exact mount modes, key-only access, +listener state, and rollback through an independent connection. + `launch-session` is an indivisible Warm bundle. Its manifest must contain the exact Bloom Shell, GameSwitcher, keymon, RetroAchievements, launch, session, override, preparation, and trace-helper runtime set. Partial manifests are @@ -232,13 +240,28 @@ and the raw one-time nonce. The lease contains only its transaction identity, monotonic bounds, state, and the fixed `known_good_station`/`key_only_ssh` allowlist. It cannot add a service, key, profile, or listener. +Wi-Fi restoration is not a single radio-start attempt. The watchdog reruns the +complete prior-state restore and waits for association, IPv4, key-only SSH, and +listener health in four bounded 45-second windows. A transient SSD202D radio or +DHCP start failure therefore triggers a fresh recovery attempt; exhaustion is +reported as `rollback_failed` with the recovery lease still armed instead of +silently claiming recovery. + +If power is lost while the lease remains armed, early runtime startup invokes +the same bounded rollback before ordinary boot networking consumes the saved +Wi-Fi preference. Boot recovery accepts only a validated matching +transaction/lease pair and removes only its own empty stale lock directory; an +incomplete record, identity mismatch, symlink, or non-empty lock fails closed. + Confirmation never reuses the OpenSSH control socket. It rechecks the configured model, station association, key-only SSH runtime, prohibited listeners, aggregate `bloomctl health --json`, and active overlay identity over independent -connections. The host deliberately refuses to confirm `wifi-disable` or -`ssh-disable`; those sole-route operations must be explicitly rolled back or -allowed to expire under the watchdog. Transaction state cannot create either -developer marker or alter signed update identity. +connections. The host and device reject a new `wifi-disable` apply before +persistent state or radio mutation because the Mini Plus radio-cycle restore +path is not qualified. Historical Wi-Fi-disable state remains recoverable. The +host also refuses to confirm `ssh-disable`; that sole-route operation must be +explicitly rolled back or allowed to expire under the watchdog. Transaction +state cannot create either developer marker or alter signed update identity. An activated update is not considered successful merely because the installer or MainUI started. The runtime records each unconfirmed boot in durable diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b3915acb9..79c9fc42f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -20,6 +20,21 @@ stops their known processes. `bloom-listener-audit` provides a bounded, process-aware check that names service classes without publishing addresses. The reviewed key-only Dropbear transport is the only stable remote listener; SFTP is independently gated and confined through that same transport. +Its chroot is materialized from the versioned canonical transfer policy rather +than a second root list. Only Roms, BIOS, Artwork, Screenshots, and Themes are +read-write; Saves is read-only. Credential, update, and other private roots are +absent. Shared path authorization rejects traversal, links, hidden Bloom work +names, and bounded-length violations. Programmatic uploads publish from a +private staging root through a same-filesystem temporary file and atomic rename; +future Web Transfer is required to use this same helper and policy. + +Wi-Fi carrier is not treated as proof of a usable remote path. Network and +listener readiness require a non-zero global IPv4 address. An associated +station without one is repaired through Bloom's fixed DHCP invocation. A +detached single-instance watcher checks only that fixed interface for at most 60 seconds and +reconciles reviewed listeners after the system DHCP hook publishes the address +and route. It accepts no caller-selected scripts, interfaces, commands, or +destinations. Developer component deployment is a separate, visible overlay rather than an update or release channel. It requires Developer Mode, a second local enablement @@ -35,11 +50,18 @@ Remote network mutation adds a distinct local enablement marker and a known-good associated station, running key-only SSH, retained station profile, valid authorized key, and idle update and session owners. It atomically records only the exact prior preferences and a hash of the one-time nonce, then starts a -detached watchdog before any change. Apply is limited to one fixed Wi-Fi/SSH -preference operation, one sealed network-helper overlay, or the fixed-port -key-only SSH shadow preflight. The shadow never replaces or stops port 22 and -is removed on confirmation, rollback, failure, or expiry. Timeout, apply +detached watchdog before any change. Apply is limited to the non-destructive +fixed Wi-Fi-enable operation, one fixed SSH preference operation, one sealed +network-helper overlay, or the fixed-port key-only SSH shadow preflight. Remote +Wi-Fi disable is rejected because the Mini Plus radio-cycle restore path is not +device-qualified and has no independent recovery network. The shadow never +replaces or stops port 22 and is removed on confirmation, rollback, failure, or +expiry. Timeout, apply failure, and explicit rollback restore prior preferences and listener policy. +For a network overlay that has already self-restored, rollback verifies the +exact saved preference matrix and listener audit before replaying anything. An +exact match closes the lease directly, so an unavailable optional service +cannot turn an already-safe state into `rollback_failed`. The lease cannot enable Developer Mode, create either local marker, change authorized keys, replace port 22 destructively, or promote update state. diff --git a/docs/program/BloomOS_Work_Program_v3.json b/docs/program/BloomOS_Work_Program_v3.json index 756243949..732525bff 100644 --- a/docs/program/BloomOS_Work_Program_v3.json +++ b/docs/program/BloomOS_Work_Program_v3.json @@ -412,7 +412,7 @@ "id": "CORE-03", "track": "Core", "title": "Shared native bloomCore resolver and consumer adapters", - "status": "published", + "status": "merged", "dependencies": [ "CORE-02" ], @@ -750,29 +750,55 @@ "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "9ad774455fd7aed81bb6ac5278f27807083c61e7", + "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", "evidence": [ "candidate 4beb681ae16416d5e7c48d2d63a8854e6e0e69ad provides schema-2 desired/runtime separation, bounded reasons, canonical non-secret settings migration, v1 compatibility projections, and PLAT-01 capability joins", "native and ASan passed 441/441, the complete shell gate passed 629/629, focused transaction/deployment passed 27/27, and static analysis, formatting, ARM EABI5, manifest, dependency-lock, provenance, and FAT payload gates passed", "the exact-hash Mini Plus Warm bundle independently confirmed key-only SSH with Wi-Fi associated, SFTP and Samba stopped, one idempotent migration, unchanged settings/key hashes across repeated reads, and no listener restart for an already-enabled SSH request", "device testing exposed redundant prior-state replay after overlay rollback; corrective commit 4beb681a skipped replay when the restored state already matched, and a fresh explicit rollback completed without restarting tracked Dropbear PID 1197", - "machine-readable evidence is docs/validation/network/plus-net01-network-service-v2-20260826.json; temporary deployments were cleaned, installed settings and harness bytes were restored exactly, aggregate health is green, and remote evidence does not claim physical or other-device certification" + "machine-readable evidence is docs/validation/network/plus-net01-network-service-v2-20260826.json; temporary deployments were cleaned, installed settings and harness bytes were restored exactly, aggregate health is green, and remote evidence does not claim physical or other-device certification", + "GitHub PR #481 passed every required check and squash-merged to main as 9f339e90a773a288014bd5612fcb8bcc23790641 on 2026-08-26" ] }, { "id": "NET-02", "track": "Network", "title": "Canonical transfer/share policy", - "status": "planned", + "status": "merge_ready", "dependencies": [ "NET-01" ], - "github_prs": [], + "github_prs": [ + 483 + ], "risk_tier": 3, "deployment_tier": "warm", "device_gate_required": true, - "last_audited_main": "3a7e78290285806e1ce3eb0c126887644ff665f2", - "evidence": [] + "last_audited_main": "9f339e90a773a288014bd5612fcb8bcc23790641", + "evidence": [ + "host candidate defines one schema-validated transfer authority for five read-write roots, read-only Saves, private-root denial, bounded paths, and same-filesystem atomic publication consumed by SFTP mount and write gates", + "the final corrected snapshot passed the complete pinned shell gate 641/641 from an immutable read-only mount, plus 48/48 focused network/transaction tests; legacy inventory, exact dependency totals, development provenance, FAT payload, JSON, POSIX syntax, and CI scope gates passed", + "the exact pinned OpenSSH source accepted both reviewed patches and produced a stripped statically linked ARM EABI5 sftp-server", + "Mini Plus preflight found the canonical Imgs artwork root absent; the candidate now provisions only missing public read-write roots while a missing or unsafe read-only Saves root remains fail-closed, and focused corrective validation passed 40/40", + "RAM-only exact-hash Mini Plus validation proved policy authorization, Saves denial, successful atomic replacement, and prior-final preservation with zero residue after abrupt transport loss; machine-readable evidence is docs/validation/network/plus-net02-transfer-policy-20260826.json", + "the upstream Warm deployment allowlist predates the policy and replacement SFTP paths, so a signed Full migration is required before durable Mini Plus activation; installed files remained unchanged and the device returned to green health with a clean key-only listener audit", + "corrective commit 9fbaea2494cfce1abfd970bc6612cd80d12fabff requires explicit SFTP fsync before atomic publication, discards ordinary CLOSE/cancel staging, filters internal chroot scaffolding, fail-closes direct private paths and unbounded mutations, and derives the upload-size gate from the canonical policy", + "the corrective native protocol probe passed cancel preservation, durable publication, Saves/private denial, hidden scaffolding, and zero-residue gates; the complete read-only pinned shell suite passed 634/634 and the exact ARM EABI5 artifact is 562156 bytes with SHA-256 ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae", + "signed Full candidates aff110ba from workflow 32969798436 and ef9dbec5 from workflow 33025501252 both failed to restore reachable key-only SSH; ef9dbec5 exposed carrier-only readiness with no global IPv4 address, an unresponsive request path, and missing DHCP recovery before automatic rollback to retained known-good b073b423", + "the corrected host candidate requires carrier plus global IPv4, reports carrier-only as acquiring_address, restarts only inherited udhcpc when address acquisition is missing, and performs delayed service reconciliation from a detached bounded watcher whose startup must be acknowledged and whose launch failure is rejected, so UI and boot request paths remain bounded without silently losing listener restoration", + "an isolated exact-hash BusyBox ash fixture on the Mini Plus exercised the corrected DHCP recovery without changing its live radio, route, listeners, or processes; a guarded Warm overlay was refused outside the legacy allowlist and an allowlisted one-file attempt self-restored, exposing a legacy rollback replay failure that now has a focused transaction regression", + "machine-readable signed recovery evidence is docs/validation/network/plus-net02-signed-recovery-20260826.json; the device is retained on known-good b073b423 with IPv4 and route restored, SSH on and key-only, SFTP and Samba off, an IDLE session, and a clean listener audit; the corrected signed durable Mini Plus gate remains pending and no additional Full or Warm deployment is authorized from this checkpoint", + "exact head 72ec706926a1276e1650ecacf1eee633ade96b0b passed all ordinary checks, exhaustive workflow 33118211692, and protected hardware workflow 33118213384; its signed archive is retained but was never staged or activated because the Mini Plus remained in recovery", + "the failed real Wi-Fi-disable gate preserved transaction tx-5415-933 as rollback_failed with its recovery lease armed and exposed that a persisted per-device wifi=0 profile could override a settings-only offline repair on the next boot; the complete active development overlay was subsequently restored from 14 signed known-good backups plus one reviewed policy backup, with zero hash mismatches and the sealed slot retained", + "the second offline recovery changed only canonical and derived Wi-Fi state plus the verified current Mini Plus profile 5E649300ABEA.json, preserved three unrelated device profiles, retained the failed transaction evidence, passed chkdsk with a final Healthy OK and NOT Dirty FAT32 state, and safely ejected without staging another candidate", + "the current host correction persists a successful boot-recovery result immediately to the per-device profile so a later power loss cannot reintroduce wifi=0 after the recovery lease closes; successful ordinary network preference changes and their rollbacks also project the canonical system state atomically to a validated current-device profile and reject symlinked or malformed identities; focused network validation passed 16/16, transaction/runtime validation passed 23/23, and the optimized complete shell gate passed 659/659 after manifest, provenance, transfer-policy, and exact 314636496-byte dependency preflight ran once", + "a signed exact-head 17-file Warm overlay was independently confirmed without changing installed version, route, profile, services, session, or aggregate health; a no-mutation detached-watchdog dry-run then exposed that the firmware listener runs as busybox telnetd, so name-only pgrep/killall skipped it, compatibility reconciliation left port 23 active, and rollback correctly failed the listener gate; no real Wi-Fi disable was attempted, the lease and overlay were reconciled, and signed b073b423 helper hashes were restored; the host correction matches only exact BusyBox telnet applet argv under a bounded proc root and audits a BusyBox-owned port 23 as Telnet", + "exact head fced08216e680fedda39b2787045820c213ff6fb passed ordinary, exhaustive, protected signing, release metadata, signature, CRC, payload reconstruction, sealed-bundle, and isolated BusyBox fixture gates; its confirmed 17-file Warm overlay kept Wi-Fi associated and listeners clean, but the no-mutation watchdog unnecessarily replayed an already-matching prior network state, briefly restarted SSH, and ended rollback_failed after the endpoint had automatically recovered; no real Wi-Fi disable was attempted, the exact baseline was verified twice before closing the lease, and the overlay was rolled back to signed b073b423; the next host correction closes an untouched timed transaction directly when its saved baseline already matches and retains bounded restoration when it does not", + "a prohibited real sole-route Wi-Fi-disable attempt at e04539d7533c09f16a3b4d9cfe5f1aa21c467274 created transaction tx-6625-4060 and failed all bounded in-session restoration windows; the next boot retried the persisted rollback and failed again while the shell inaccurately reported Wi-Fi connected and the known LAN address and key-only SSH remained unreachable; offline card recovery preserved exact transaction evidence, confirmed signed known-good b073b423 plus the still-active e04539d7 overlay, restored 16 sealed signed-baseline backups, removed the one originally absent overlay file, removed the active pointer, quarantined the failed transaction directory, disabled the developer network-mutation marker, passed a read-only FAT32 chkdsk with no errors, and safely ejected; the host harness and device transaction helper now reject new remote Wi-Fi-disable apply requests before persistent state or radio mutation while retaining recovery support for historical Wi-Fi-disable state", + "a clean post-recovery boot returned strict key-only SSH on the first probe; signed known-good b073b423 remained installed and healthy with associated Wi-Fi, a default route, clean listener audit, IDLE transaction, inactive lease, no active overlay or pointer, the network-mutation enable marker absent, and the failed four-file transaction evidence quarantined with exact hashes", + "exact code head 6565d34874600d42e7b60cdb75f08a9e4c9b3791 passed every ordinary check plus exhaustive workflow 33166683195 in 3m51s and protected hardware workflow 33166684506 in 9m25s; its 203248605-byte signed archive has SHA-256 dee6cc27cf02ad0604c76acae331d30121765b4a749fe3150ec51375a927f535, complete payload reconstruction, repository Ed25519 verification, exact dependency identity, and changed embedded script bytes matching the reviewed tree", + "the exact signed 27658-byte transaction helper with SHA-256 37f08d8e5502ca1d56ac79448156a88c321b456a61f06cfe2b5a2daad01e9497 was transferred to a bounded Mini Plus temporary path and executed only against an isolated nonexistent root; remote Wi-Fi disable was rejected before the root or any transaction state was created, the temporary helper was removed, and fresh inspection retained associated Wi-Fi, IDLE transaction, inactive lease, clean listener audit, signed known-good b073b423, and aggregate health" + ] }, { "id": "NET-03", diff --git a/docs/validation/network/plus-net02-signed-recovery-20260826.json b/docs/validation/network/plus-net02-signed-recovery-20260826.json new file mode 100644 index 000000000..fe7ca3ae3 --- /dev/null +++ b/docs/validation/network/plus-net02-signed-recovery-20260826.json @@ -0,0 +1,120 @@ +{ + "schema": 1, + "package": "NET-02", + "date_utc": "2026-08-26", + "device": { + "model": "mini_plus", + "model_id": "354", + "transport": "key-only LAN SSH" + }, + "candidate": { + "commit": "aff110ba73eca83dfd52d18afd26b316caa9e7fd", + "version": "0.1.0-dev-aff110ba", + "workflow_run": 32969798436, + "deployment_tier": "full_signed", + "archive": { + "bytes": 203243467, + "sha256": "21c656282568150d3279226085726e812ec33ae0eeb2e85367b3c38346aa8ed4" + }, + "manifest_sha256": "473329f8d8972f2080ea0f97131c167b2333f656409b455a1c203b60559be9b3", + "signature_sha256": "c892254e7cc52399c7ae26ae728333c636d5d075a4b16e54e1d7a448d7413390" + }, + "follow_up_candidate": { + "commit": "ef9dbec54f0faffc0efd2eaf6cac1009957bb307", + "version": "0.1.0-dev-ef9dbec5", + "workflow_run": 33025501252, + "deployment_tier": "full_signed", + "archive": { + "bytes": 203232278, + "sha256": "6d4e5628a0ebf76772c7bb81fd92ee035f809ab8404505794d72fd88f94fe417" + }, + "manifest_sha256": "d5dee32cad13211777fb535b9c465b4dcf43f2375b744ab9f31d3ae29792bb68", + "signature_sha256": "fb2582bec6c03ad4bf4c616c1643a5fe7a385c2a6a9fa990998395b591da3e17" + }, + "result": { + "status": "rejected_and_recovered", + "candidate_boot_attempts": { + "0.1.0-dev-aff110ba": 1, + "0.1.0-dev-ef9dbec5": 2 + }, + "observed_failure": "both signed candidates treated Wi-Fi carrier as usable networking before wlan0 had a global IPv4 address; the follow-up candidate showed connected in Bloom Shell without LAN details, did not accept the Wi-Fi toggle, and did not restore reachable key-only SSH", + "recovery_state": { + "phase": "known_good", + "version": "0.1.0-dev-b073b423", + "recovered_from": "0.1.0-dev-ef9dbec5", + "snapshot_id": "19700101T000116Z-pre-rollback-893" + } + }, + "diagnosis": { + "classification": "carrier-only readiness and missing DHCP recovery", + "evidence": [ + "the rejected follow-up candidate reached carrier while wlan0 had no global IPv4 address and no usable LAN route", + "bloom-network and bloom-network-services accepted carrier as associated and ready, so remote services were reconciled before DHCP had succeeded", + "the follow-up candidate's bounded carrier wait ran in the request path, making the Wi-Fi control appear unresponsive without repairing DHCP", + "the installed known-good predecessor recovered 192.168.1.180/24 and the default route through 192.168.1.1" + ], + "corrective_scope": "require carrier plus a nonzero global IPv4 address for associated/ready claims; report carrier-only state as acquiring_address; when carrier exists without IPv4, restart the inherited udhcpc client with the fixed interface and script and reconcile remote services from a detached bounded watcher so UI and boot callers return immediately" + }, + "post_recovery": { + "update_state_known_good": true, + "listener_audit_clean": true, + "ssh": "on/running key-only", + "sftp": "off/stopped", + "samba": "off/stopped", + "ipv4": "192.168.1.180/24", + "default_route": "192.168.1.1", + "authorized_key_count": 1, + "authorized_key_suffix": "b9002791", + "session_state": "IDLE", + "screen": "Bloom Shell visible with Wi-Fi 100%; privacy-bounded framebuffer evidence retained outside the repository", + "shutdown_log": { + "mode": "reboot", + "remount_ro_final": 0, + "umount_recursive": 0 + } + }, + "corrected_candidate_validation": { + "device_fixture_bloom_wifi": { + "bytes": 7826, + "sha256": "22f28287284ce62fe4bc00be487c621f52c6324666ddc7f5c11d0e4ec346e176" + }, + "current_bloom_wifi": { + "bytes": 7826, + "sha256": "22f28287284ce62fe4bc00be487c621f52c6324666ddc7f5c11d0e4ec346e176" + }, + "host_focused_shell_tests": "48/48 passed, including detached watcher startup acknowledgement, launch-failure rejection, reuse, termination cleanup, stale-lock recovery, and the self-restored transaction regression", + "complete_shell_tests": "641/641 passed from an immutable read-only repository mount", + "device_fixture": "the exact current bloom-wifi bytes passed an isolated /tmp BusyBox ash fixture on the Mini Plus, called only mocked pkill/sleep/udhcpc/service commands, acknowledged and cleaned its single detached watcher, and removed every fixture path; installed version, live IPv4, route, listener, service preferences, listener audit, and session state were unchanged", + "current_candidate_device_status": "executed only against isolated mocks; not installed, activated, or executed on the live network path" + }, + "warm_overlay_attempt": { + "deployment_id": "net02-wifi-480b7ca0", + "transaction_id": "tx-1972-2057", + "result": "the legacy development activator self-restored the exact installed bloom-wifi bytes, but the legacy transaction helper replayed prior service preferences and ended rollback_failed when unavailable Samba restoration returned failure", + "installed_bloom_wifi_sha256": "5ec86c0d09d25dd7dcb8196725c9e20935478f1cab582a3bb5952e5a04af4c35", + "overlay_active": false, + "recovery_lease": "inactive", + "cleanup": "complete", + "follow_up": "the source transaction now verifies an already-restored network overlay before replaying preferences; a regression covers an inactive self-restored overlay with an unavailable service helper" + }, + "physical_observation": { + "reported_by": "maintainer", + "context": "update and recovery loop", + "power_button": "shutdown required holding the power button continuously until power-off instead of releasing after the ordinary multi-second hold", + "classification": "not yet reproduced in the idle Bloom Shell; update-loop power ownership remains a limitation requiring a focused physical retest" + }, + "limitations": [ + "The failed candidates could not be inspected over SSH while their listeners were unavailable; the diagnosis joins retained local state, maintainer-reported UI behavior, installed rollback state, and reviewed source ordering.", + "Recovery included maintainer physical restart and power-button interaction, so this run is not evidence of unattended recovery.", + "No signed durable SFTP behavior is claimed from the rejected candidate.", + "The corrected DHCP and transaction paths have not received another signed Full deployment; no further device mutation is permitted from this checkpoint until the host and CI gates are green and a separately reviewed deployment path avoids the legacy transaction failure.", + "The installed known-good build lacks the current health aggregator and retains the terminal rollback_failed transaction record as honest evidence even though update state, IPv4, route, key-only SSH, service preferences, session state, and listener audit were independently verified.", + "Mini Plus remote and maintainer-reported physical evidence does not certify Mini V1-V4 or Mini Flip." + ], + "retained_evidence": { + "signed_artifacts": "retained outside the repository in the bounded D: task workspace", + "diagnostics": "privacy-bounded device and runtime logs retained outside the repository", + "rom_bios_or_save_content_collected": false, + "credentials_or_private_keys_collected": false + } +} diff --git a/docs/validation/network/plus-net02-transfer-policy-20260826.json b/docs/validation/network/plus-net02-transfer-policy-20260826.json new file mode 100644 index 000000000..fd9223b86 --- /dev/null +++ b/docs/validation/network/plus-net02-transfer-policy-20260826.json @@ -0,0 +1,126 @@ +{ + "schema": 1, + "package": "NET-02", + "date_utc": "2026-08-26", + "device": { + "model": "mini_plus", + "model_id": "354", + "installed_version": "v0.1.0-dev-b073b423" + }, + "source": { + "base_main": "9f339e90a773a288014bd5612fcb8bcc23790641", + "implementation_commit": "d43d372c01a7504900cc35dcd18b20760bee90d9", + "device_corrective_commit": "2415ead2099745b39152c81afa6ad43333cfdd75", + "cancel_safe_corrective_commit": "9fbaea2494cfce1abfd970bc6612cd80d12fabff" + }, + "candidate": { + "mode": "ram_only_direct_sftp_server", + "durably_activated": false, + "files": { + "bloom-dev-ssh": { + "bytes": 13539, + "sha256": "17ee694e9e53ee7c1d3d6682ac3c44325a999b0fd81acc951d88023528980a02" + }, + "bloom-sftp-gate": { + "bytes": 1158, + "sha256": "895fb9984b8909156eb0d50fb885a875238ec5f2d63151a3c567a7eb7a1e13d6" + }, + "bloom-sftp-server": { + "bytes": 562156, + "sha256": "461de6c579320f2bff30a3414cb5a42242676be0adc784fc7ded605e614ac832" + }, + "bloom-transfer-policy": { + "bytes": 7032, + "sha256": "4d1fd6217873a6429c684c260c6520876984eea7c12a9170196642f4660b3b75" + }, + "transfer-policy.json": { + "bytes": 1103, + "sha256": "68563c3202ce397ddaa90dffa36668e6a23adf162c694374fe3f2e74eb7e2c26" + } + }, + "corrective_files": { + "bloom-sftp-gate": { + "bytes": 1700, + "sha256": "0e3b494c8042c08dc8a70ea8b8dbc80cb563596bfb9265119bcf1b23f8648d9b" + }, + "bloom-sftp-server": { + "bytes": 562156, + "sha256": "ade73040dfe42ba72650d67841e9a9e8d8398f6cec4e3ea558d43ba9e62676ae" + }, + "bloom-transfer-policy": { + "bytes": 7277, + "sha256": "b35ef875efbdb8d90fde544dd88f89dc2172a786fa87d85f32c55056072c8868" + }, + "transfer-policy.json": { + "bytes": 1103, + "sha256": "68563c3202ce397ddaa90dffa36668e6a23adf162c694374fe3f2e74eb7e2c26" + } + } + }, + "host_validation": { + "complete_shell": "634/634 passed from a read-only repository mount after the cancel-safe correction", + "focused_corrective": "29/29 transfer, SSH, and network service tests passed; dependency and read-only harness checks passed 13/13", + "arm_binary": "ELF 32-bit LSB ARM EABI5, statically linked, stripped", + "native_sftp_protocol_probe": "ordinary CLOSE failed and preserved the prior final; explicit fsync committed atomically; Saves and private scaffolding were denied; zero staging residue remained", + "policy_schema_semantics": "passed", + "legacy_manifest_and_dependency_lock": "passed", + "development_provenance": "passed", + "patch_hunk_counts": "passed" + }, + "remote_validation": { + "preflight": { + "session": "STOPPED", + "update_phase": "known_good", + "aggregate_health": true, + "listener_audit_clean_after_inherited_telnet_cleanup": true, + "ssh": "on/running key-only", + "sftp": "off/stopped", + "samba": "off/stopped", + "authorized_key_count": 1 + }, + "policy": { + "candidate_validate": "passed", + "screenshots_write_authorized": true, + "saves_write_denied": true, + "private_device_roots_mounted": false + }, + "sftp": { + "successful_atomic_replace": { + "original_sha256": "db6172bc793afe98145dadef29e44ff7e02b5614f6b354aae95869a769798dde", + "replacement_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8" + }, + "abrupt_transport_loss": { + "prior_final_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8", + "final_sha256": "41fd99139c8eaf93f529678043a66758a4a6441f07483fc93eb3c1ee802cb3d8", + "staging_residue_count": 0 + }, + "saves_write_result": "permission_denied", + "root_entries": [ + "Artwork", + "BIOS", + "Roms", + "Saves", + "Screenshots", + "Themes", + ".bloom-bin", + "dev" + ] + } + }, + "limitations": [ + "The upstream installed Warm deployment allowlist predates the new policy and replacement SFTP paths, so durable activation requires a signed Full migration.", + "No local Bloom release signing key was available, so this run did not replace installed runtime files or claim Warm rollback evidence.", + "The cancel-safe correction requires an explicit SFTP fsync extension before CLOSE can publish; clients without durable-upload support receive a close failure and preserve the prior final.", + "The corrective host probe filters .bloom-bin, dev, and staging names and denies their direct access; signed durable device validation of that correction remains pending.", + "Remote SSH and SFTP evidence does not certify physical controls, LCD, audio, gameplay, save semantics, Mini V1-V4, or Mini Flip." + ], + "cleanup": { + "candidate_mounts_remaining": 0, + "ram_candidate_removed": true, + "sd_fixture_removed": true, + "task_created_artwork_root_removed_empty": true, + "installed_files_replaced": false, + "post_cleanup_health": true, + "post_cleanup_listener_audit_clean": true + } +} diff --git a/src/bloomSettings/bloom_settings.c b/src/bloomSettings/bloom_settings.c index 02be60723..60e4468e8 100644 --- a/src/bloomSettings/bloom_settings.c +++ b/src/bloomSettings/bloom_settings.c @@ -1665,10 +1665,13 @@ int bloom_settings_set(const char *settings_path, const char *onion_system_path, } else if (field_values_equal(current, replacement, policy->type)) { result->generation = current_values.generation; - result->materialized = 1; cJSON_Delete(replacement); cJSON_Delete(root); release_settings_lock(lock); + if (bloom_settings_materialize_onion(settings_path, onion_system_path, + onion_config_root, error, error_size) != 0) + return -1; + result->materialized = 1; return 0; } else if (!cJSON_ReplaceItemInObjectCaseSensitive(target, policy->key, replacement)) { diff --git a/src/bloomShell/bloom_shell_settings.c b/src/bloomShell/bloom_shell_settings.c index 702f98bdc..ca1b6d93a 100644 --- a/src/bloomShell/bloom_shell_settings.c +++ b/src/bloomShell/bloom_shell_settings.c @@ -168,7 +168,14 @@ int bloom_shell_settings_row_format(const BloomShellCapabilities *capabilities, : snprintf(label, label_size, "Mute unavailable"); break; case BLOOM_SHELL_SETTINGS_WIFI: - length = values->ready ? snprintf(label, label_size, "Wi-Fi %s", values->wifi_enabled ? "On" : "Off") + length = values->ready ? snprintf(label, label_size, "Wi-Fi %s", + !values->wifi_enabled + ? "Off" + : !values->wifi_status_ready + ? "On" + : values->wifi_associated + ? "On / Connected" + : "On / No connection") : snprintf(label, label_size, "Wi-Fi unavailable"); break; case BLOOM_SHELL_SETTINGS_SSH: @@ -622,7 +629,14 @@ int bloom_shell_quick_settings_format(const BloomShellCapabilities *capabilities else if (row == 1) length = snprintf(label, label_size, "Volume: %s%d", values->mute ? "Muted / " : "", values->volume); else if (capabilities->wifi && row == 2) - length = snprintf(label, label_size, "Wi-Fi: %s", values->wifi_enabled ? "On" : "Off"); + length = snprintf(label, label_size, "Wi-Fi: %s", + !values->wifi_enabled + ? "Off" + : !values->wifi_status_ready + ? "On" + : values->wifi_associated + ? "On / Connected" + : "On / No connection"); else return -1; return length >= 0 && (size_t)length < label_size ? 0 : -1; @@ -715,6 +729,113 @@ static int run_request(const char *path, const char *first, const char *second, return wait_child(child); } +static int run_network_request(const char *path, const char *operation) +{ + if (path == NULL || path[0] != '/' || operation == NULL) + return -1; + pid_t child = fork(); + if (child < 0) + return -1; + if (child == 0) { + if (setpgid(0, 0) != 0) + _exit(127); + FILE *null = fopen("/dev/null", "w"); + if (null != NULL) { + dup2(fileno(null), STDOUT_FILENO); + dup2(fileno(null), STDERR_FILENO); + } + execl(path, path, "request", operation, (char *)NULL); + _exit(127); + } + if (setpgid(child, child) != 0 && errno != EACCES && errno != ESRCH) { + kill(child, SIGKILL); + wait_child(child); + return -1; + } + int status = 0; + for (int attempt = 0; attempt < 100; ++attempt) { + pid_t result = waitpid(child, &status, WNOHANG); + if (result == child) + return WIFEXITED(status) && WEXITSTATUS(status) == 0 ? 0 : -1; + if (result < 0 && errno != EINTR) + return -1; + poll(NULL, 0, 50); + } + kill(-child, SIGTERM); + kill(child, SIGTERM); + for (int attempt = 0; attempt < 10; ++attempt) { + pid_t result = waitpid(child, &status, WNOHANG); + if (result == child) { + kill(-child, SIGKILL); + return -1; + } + if (result < 0 && errno != EINTR) + return -1; + poll(NULL, 0, 50); + } + kill(-child, SIGKILL); + kill(child, SIGKILL); + while (waitpid(child, &status, 0) < 0) + if (errno != EINTR) + break; + return -1; +} + +int bloom_shell_network_status_parse(const char *json, BloomShellQuickValues *values) +{ + if (json == NULL || values == NULL) + return -1; + cJSON *root = cJSON_Parse(json); + const cJSON *schema = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "schema"); + const cJSON *service = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "service"); + const cJSON *available = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "available"); + const cJSON *capable = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "wifi_capable"); + const cJSON *wifi = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "wifi_enabled"); + const cJSON *associated = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "associated"); + const cJSON *state = root == NULL ? NULL : cJSON_GetObjectItemCaseSensitive(root, "state"); + int valid = cJSON_IsNumber(schema) && schema->valuedouble == 1.0 && cJSON_IsString(service) && + service->valuestring != NULL && strcmp(service->valuestring, "bloom-network") == 0 && + cJSON_IsBool(available) && cJSON_IsBool(capable) && cJSON_IsBool(wifi) && + cJSON_IsBool(associated) && cJSON_IsString(state) && state->valuestring != NULL; + int enabled = valid && cJSON_IsTrue(wifi); + int connected = valid && cJSON_IsTrue(associated); + if (valid) { + if (strcmp(state->valuestring, "associated") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && enabled && connected; + else if (strcmp(state->valuestring, "acquiring_address") == 0 || + strcmp(state->valuestring, "not_associated") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && enabled && !connected; + else if (strcmp(state->valuestring, "wifi_disabled") == 0) + valid = cJSON_IsTrue(available) && cJSON_IsTrue(capable) && !enabled && !connected; + else if (strcmp(state->valuestring, "no_network_hardware") == 0) + valid = cJSON_IsTrue(available) && !cJSON_IsTrue(capable) && !enabled && !connected; + else + valid = 0; + } + if (valid) { + values->wifi_enabled = enabled; + values->wifi_associated = connected; + values->wifi_status_ready = 1; + } + cJSON_Delete(root); + return valid ? 0 : -1; +} + +int bloom_shell_network_status_load(const char *network_path, BloomShellQuickValues *values) +{ + if (values == NULL) + return -1; + char json[QUICK_OUTPUT_MAX + 1]; + int result = load_output(network_path, "status", NULL, json, sizeof(json)) == 0 + ? bloom_shell_network_status_parse(json, values) + : -1; + if (result != 0) { + values->wifi_status_ready = 0; + values->wifi_associated = 0; + } + return result; +} + void bloom_shell_network_credential_clear(BloomShellNetworkCredential *credential) { if (credential == NULL) @@ -912,9 +1033,14 @@ int bloom_shell_quick_settings_adjust(const BloomShellCapabilities *capabilities int next = direction > 0; if (next == values->wifi_enabled) return 0; - if (run_request(network_path, "request", next ? "enable" : "disable", NULL) != 0) + if (run_network_request(network_path, next ? "enable" : "disable") != 0) { + bloom_shell_network_status_load(network_path, values); return -1; + } values->wifi_enabled = next; + values->wifi_status_ready = 0; + values->wifi_associated = 0; + bloom_shell_network_status_load(network_path, values); return 0; } return -1; diff --git a/src/bloomShell/bloom_shell_settings.h b/src/bloomShell/bloom_shell_settings.h index 934da1bc3..dba3978aa 100644 --- a/src/bloomShell/bloom_shell_settings.h +++ b/src/bloomShell/bloom_shell_settings.h @@ -17,6 +17,8 @@ typedef struct { int volume; int mute; int wifi_enabled; + int wifi_status_ready; + int wifi_associated; int network_services_ready; int ssh_available; int ssh_enabled; @@ -117,6 +119,8 @@ size_t bloom_shell_quick_settings_count(const BloomShellCapabilities *capabiliti const char *bloom_shell_quick_settings_label(const BloomShellCapabilities *capabilities, size_t row); int bloom_shell_quick_values_parse(const char *json, BloomShellQuickValues *values); int bloom_shell_quick_values_load(const char *settings_path, BloomShellQuickValues *values); +int bloom_shell_network_status_parse(const char *json, BloomShellQuickValues *values); +int bloom_shell_network_status_load(const char *network_path, BloomShellQuickValues *values); int bloom_shell_network_services_parse(const char *json, BloomShellQuickValues *values); int bloom_shell_network_services_load(const char *services_path, BloomShellQuickValues *values); int bloom_shell_network_service_change(BloomShellQuickValues *values, diff --git a/src/bloomShell/main.c b/src/bloomShell/main.c index 2852df337..7f071ea59 100644 --- a/src/bloomShell/main.c +++ b/src/bloomShell/main.c @@ -2714,6 +2714,7 @@ int main(int argc, char **argv) bloom_shell_first_run_load(BLOOM_SETTINGS_BINARY, &first_run); BloomShellQuickValues quick_values = {0}; bloom_shell_quick_values_load(BLOOM_SETTINGS_BINARY, &quick_values); + bloom_shell_network_status_load(BLOOM_NETWORK_BINARY, &quick_values); bloom_shell_quick_battery_load(BLOOM_PLATFORM_BINARY, &quick_values); bloom_shell_network_services_load(BLOOM_NETWORK_SERVICES_BINARY, &quick_values); capabilities.samba = diff --git a/static/build/.tmp_update/bin/bloom-dev-deploy b/static/build/.tmp_update/bin/bloom-dev-deploy index d96209c13..61c994050 100755 --- a/static/build/.tmp_update/bin/bloom-dev-deploy +++ b/static/build/.tmp_update/bin/bloom-dev-deploy @@ -73,9 +73,13 @@ allowed_path() { network-helpers:bin/bloom-settings|network-helpers:bin/bloom-network|\ network-helpers:bin/bloom-network-services|network-helpers:bin/bloom-network-transaction|\ network-helpers:bin/bloom-platform|network-helpers:bin/bloom-dev-ssh|\ - network-helpers:bin/bloom-sftp-gate|network-helpers:bin/bloom-remote-keys|\ + network-helpers:bin/bloom-sftp-gate|network-helpers:bin/bloom-sftp-server|\ + network-helpers:bin/bloom-transfer-policy|\ + network-helpers:bin/bloom-remote-keys|\ network-helpers:bin/bloomctl|\ network-helpers:bin/bloom-listener-audit|network-helpers:bin/bloom-wifi|\ + network-helpers:config/transfer-policy.json|\ + network-helpers:runtime.sh|\ network-helpers:script/network/update_networking.sh|\ network-helpers:script/network/wpsclient.sh) return 0 ;; *) return 1 ;; @@ -168,6 +172,27 @@ validate_manifest() { bin/keymon \ script/bloom-launch-trace.sh | sort)" [ "$actual_paths" = "$expected_paths" ] || fail "launch/session/RA bundle is incomplete" + elif [ "$manifest_component" = network-helpers ]; then + actual_paths="$($JQ -r '.files[].path' "$manifest" | sort)" + expected_paths="$(printf '%s\n' \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + runtime.sh \ + script/network/update_networking.sh \ + script/network/wpsclient.sh | sort)" + [ "$actual_paths" = "$expected_paths" ] || fail "network helper bundle is incomplete" fi } @@ -337,7 +362,11 @@ readiness() { ;; network-helpers) output="$($SYSTEM_ROOT/bin/bloom-network-services status 2>/dev/null)" || return 1 - case "$output" in *'"service":"bloom-network-services"'*) return 0 ;; *) return 1 ;; esac + case "$output" in *'"service":"bloom-network-services"'*) ;; *) return 1 ;; esac + if [ -x "$SYSTEM_ROOT/bin/bloom-transfer-policy" ]; then + "$SYSTEM_ROOT/bin/bloom-transfer-policy" validate >/dev/null 2>&1 || return 1 + fi + return 0 ;; launch-session) output="$($SYSTEM_ROOT/bin/bloom-session status --json 2>/dev/null)" || return 1 diff --git a/static/build/.tmp_update/bin/bloom-dev-ssh b/static/build/.tmp_update/bin/bloom-dev-ssh index 12650486f..11d048499 100755 --- a/static/build/.tmp_update/bin/bloom-dev-ssh +++ b/static/build/.tmp_update/bin/bloom-dev-ssh @@ -22,6 +22,7 @@ HOST_KEY_DIR="$SD_ROOT/.bloom/ssh" HOST_KEY="$HOST_KEY_DIR/dropbear_ed25519_host_key" SFTP_SERVER=${BLOOM_SFTP_SERVER:-$SYS_DIR/bin/bloom-sftp-server} SFTP_CHROOT="$ROOT/tmp/bloom-sftp-root" +TRANSFER_POLICY=${BLOOM_TRANSFER_POLICY_BIN:-$SYS_DIR/bin/bloom-transfer-policy} DEV_NULL=${BLOOM_DEV_NULL:-$ROOT/dev/null} NETWORK_TRANSACTION=${BLOOM_NETWORK_TRANSACTION_BIN:-$SYS_DIR/bin/bloom-network-transaction} @@ -88,18 +89,19 @@ start_shadow() { } cleanup_sftp_chroot() { - for target in \ - "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" \ - "$SFTP_CHROOT/Roms" "$SFTP_CHROOT/BIOS" "$SFTP_CHROOT/Saves" \ - "$SFTP_CHROOT/Screenshots" "$SFTP_CHROOT/Themes" "$SFTP_CHROOT/Artwork" + mount_names="Roms BIOS Saves Screenshots Themes Artwork" + if [ -x "$TRANSFER_POLICY" ]; then + policy_names="$($TRANSFER_POLICY mounts 2>/dev/null | awk -F '\t' '{print $1}' | tr '\n' ' ' || true)" + [ -z "$policy_names" ] || mount_names=$policy_names + fi + for target in "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" do umount "$target" >/dev/null 2>&1 || true done + for name in $mount_names; do umount "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true; done rm -f "$SFTP_CHROOT/.bloom-bin/sftp-server" "$SFTP_CHROOT/dev/null" rmdir "$SFTP_CHROOT/.bloom-bin" "$SFTP_CHROOT/dev" >/dev/null 2>&1 || true - for name in Roms BIOS Saves Screenshots Themes Artwork; do - rmdir "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true - done + for name in $mount_names; do rmdir "$SFTP_CHROOT/$name" >/dev/null 2>&1 || true; done rmdir "$SFTP_CHROOT" >/dev/null 2>&1 || true } @@ -150,21 +152,43 @@ stop_dropbear() { prepare_sftp_chroot() { [ -x "$SFTP_SERVER" ] && [ -c "$DEV_NULL" ] || return 1 + [ -x "$TRANSFER_POLICY" ] && "$TRANSFER_POLICY" validate || return 1 [ ! -L "$SFTP_CHROOT" ] || return 1 mkdir -p "$SFTP_CHROOT/.bloom-bin" "$SFTP_CHROOT/dev" || return 1 - for name in Roms BIOS Saves Screenshots Themes Artwork; do - if [ "$name" = Artwork ]; then source="$SD_ROOT/Imgs"; else source="$SD_ROOT/$name"; fi - [ -d "$source" ] && [ ! -L "$source" ] || continue - mkdir -p "$SFTP_CHROOT/$name" || { cleanup_sftp_chroot; return 1; } + mounts_file="$ROOT/tmp/bloom-transfer-mounts.$$" + [ ! -e "$mounts_file" ] && [ ! -L "$mounts_file" ] || return 1 + "$TRANSFER_POLICY" mounts >"$mounts_file" || { rm -f "$mounts_file"; return 1; } + tab="$(printf '\t')" + while IFS="$tab" read -r name source_path access; do + case "$name" in ''|*[!A-Za-z0-9_-]*) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + case "$source_path" in ''|*[!A-Za-z0-9_-]*) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + case "$access" in read_only|read_write) ;; *) rm -f "$mounts_file"; cleanup_sftp_chroot; return 1 ;; esac + source="$SD_ROOT/$source_path" + if [ ! -e "$source" ] && [ ! -L "$source" ]; then + [ "$access" = read_write ] && mkdir "$source" || { + rm -f "$mounts_file" + cleanup_sftp_chroot + return 1 + } + fi + [ -d "$source" ] && [ ! -L "$source" ] || { + rm -f "$mounts_file" + cleanup_sftp_chroot + return 1 + } + mkdir -p "$SFTP_CHROOT/$name" || { rm -f "$mounts_file"; cleanup_sftp_chroot; return 1; } mount -o bind "$source" "$SFTP_CHROOT/$name" || { + rm -f "$mounts_file" cleanup_sftp_chroot return 1 } - if [ "$name" = Saves ] && ! mount -o remount,bind,ro "$SFTP_CHROOT/$name"; then + if [ "$access" = read_only ] && ! mount -o remount,bind,ro "$SFTP_CHROOT/$name"; then + rm -f "$mounts_file" cleanup_sftp_chroot return 1 fi - done + done <"$mounts_file" + rm -f "$mounts_file" : >"$SFTP_CHROOT/.bloom-bin/sftp-server" || { cleanup_sftp_chroot; return 1; } mount -o bind "$SFTP_SERVER" "$SFTP_CHROOT/.bloom-bin/sftp-server" || { cleanup_sftp_chroot diff --git a/static/build/.tmp_update/bin/bloom-listener-audit b/static/build/.tmp_update/bin/bloom-listener-audit index 5567ff118..2e52b3408 100755 --- a/static/build/.tmp_update/bin/bloom-listener-audit +++ b/static/build/.tmp_update/bin/bloom-listener-audit @@ -41,7 +41,7 @@ process_present() { } listener_present '(:|[.])21[[:space:]].*(bftpd|ftpd|ftp)' && append_unique prohibited ftp -listener_present '(:|[.])23[[:space:]].*telnetd' && append_unique prohibited telnet +listener_present '(:|[.])23[[:space:]].*(telnetd|busybox)' && append_unique prohibited telnet listener_present '(:|[.])(80|8080)[[:space:]].*(filebrowser|uhttpd)' && append_unique prohibited filebrowser_http listener_present '(:|[.])(137|138|139|445)[[:space:]].*smbd' && append_unique prohibited samba listener_present '(:|[.])(53|67)[[:space:]].*(dnsmasq|hostapd)' && append_unique prohibited hotspot diff --git a/static/build/.tmp_update/bin/bloom-network b/static/build/.tmp_update/bin/bloom-network index 981e01086..30ac2c740 100755 --- a/static/build/.tmp_update/bin/bloom-network +++ b/static/build/.tmp_update/bin/bloom-network @@ -2,13 +2,18 @@ set -eu ROOT=${BLOOM_NETWORK_ROOT:-} +SD_ROOT="$ROOT/mnt/SDCARD" +SYSTEM_ROOT="$SD_ROOT/.tmp_update" PLATFORM=${BLOOM_PLATFORM_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-platform} JSONVAL=${BLOOM_NETWORK_JSONVAL_BIN:-/customer/app/jsonval} -LIB_DIR=${BLOOM_NETWORK_LIB_DIR:-$ROOT/mnt/SDCARD/.tmp_update/lib} +LIB_DIR=${BLOOM_NETWORK_LIB_DIR:-$SYSTEM_ROOT/lib} BACKEND=${BLOOM_NETWORK_BACKEND_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} COMPAT=${BLOOM_NETWORK_COMPAT_BIN:-/mnt/SDCARD/.tmp_update/script/network/update_networking.sh} SETTINGS=${BLOOM_SETTINGS_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-settings} SERVICES=${BLOOM_NETWORK_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} +IP=${BLOOM_NETWORK_IP_BIN:-/bin/ip} +READ_UUID=${BLOOM_NETWORK_READ_UUID_BIN:-/mnt/SDCARD/.tmp_update/bin/read_uuid} +DEVICE_SETTINGS_ROOT=${BLOOM_NETWORK_DEVICE_SETTINGS_ROOT:-$SYSTEM_ROOT/config/system} result() { printf '{"schema":1,"service":"bloom-network","available":%s,"wifi_capable":%s,"wifi_enabled":%s,"associated":%s,"state":"%s"}\n' \ @@ -30,8 +35,68 @@ wifi_capability() { case "$wifi" in available) printf true ;; unavailable|not_applicable) printf false ;; *) return 1 ;; esac } -reconcile_services() { - [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1 +reconcile_services() { [ ! -x "$SERVICES" ] || "$SERVICES" reconcile >/dev/null 2>&1; } + +persist_device_profile() { + [ -f "$SD_ROOT/system.json" ] && [ ! -L "$SD_ROOT/system.json" ] || return 1 + [ -d "$DEVICE_SETTINGS_ROOT" ] && [ ! -L "$DEVICE_SETTINGS_ROOT" ] || return 1 + [ -x "$READ_UUID" ] || return 1 + device_uuid="$($READ_UUID 2>/dev/null)" || return 1 + [ "${#device_uuid}" -eq 12 ] || return 1 + case "$device_uuid" in *[!0-9A-Fa-f]*) return 1 ;; esac + device_profile="$DEVICE_SETTINGS_ROOT/$device_uuid.json" + [ ! -L "$device_profile" ] || return 1 + device_profile_tmp="$device_profile.tmp.$$" + { [ ! -e "$device_profile_tmp" ] && [ ! -L "$device_profile_tmp" ]; } || return 1 + if ! cp "$SD_ROOT/system.json" "$device_profile_tmp"; then + rm -f "$device_profile_tmp" + return 1 + fi + if ! mv -f "$device_profile_tmp" "$device_profile"; then + rm -f "$device_profile_tmp" + return 1 + fi +} + +restore_preference() { + restore_desired=$1 + "$SETTINGS" set device.wifi_enabled "$restore_desired" >/dev/null 2>&1 || return 1 + if "$BACKEND" reconcile >/dev/null 2>&1; then + if [ "$restore_desired" = true ] && [ -x "$COMPAT" ]; then + "$COMPAT" services >/dev/null 2>&1 || return 1 + fi + else + restore_status=$? + [ "$restore_status" -eq 3 ] && [ -x "$COMPAT" ] && + "$COMPAT" check >/dev/null 2>&1 || return 1 + fi + reconcile_services || return 1 + persist_device_profile +} + +rollback_preference_failure() { + rollback_operation=$1 + rollback_state=$2 + rollback_desired=$3 + if restore_preference "$rollback_desired"; then + preference_result "$rollback_operation" false false "${rollback_state}_rolled_back" + else + preference_result "$rollback_operation" true false "${rollback_state}_rollback_failed" + fi + return 1 +} + +ipv4_ready() { + [ -x "$IP" ] || return 1 + "$IP" -4 -o addr show dev wlan0 scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' +} + +wifi_preference() { + [ -x "$JSONVAL" ] || return 1 + value=$(LD_LIBRARY_PATH="/lib:/config/lib:/customer/lib:$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + "$JSONVAL" wifi 2>/dev/null) || return 1 + case "$value" in 0|1) printf '%s' "$value" ;; *) return 1 ;; esac } status() { @@ -41,8 +106,7 @@ status() { esac [ -x "$JSONVAL" ] || { result false true false false wifi_state_unavailable; return 1; } - enabled=$(LD_LIBRARY_PATH="/lib:/config/lib:/customer/lib:$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ - "$JSONVAL" wifi 2>/dev/null) || { result false true false false wifi_state_unavailable; return 1; } + enabled=$(wifi_preference) || { result false true false false wifi_state_unavailable; return 1; } case "$enabled" in 0) result true true false false wifi_disabled; return 0 ;; 1) ;; @@ -57,6 +121,7 @@ status() { [ ! -r "$interface/operstate" ] || IFS= read -r operstate <"$interface/operstate" || true [ ! -r "$interface/carrier" ] || IFS= read -r carrier <"$interface/carrier" || true [ "$operstate" = up ] && [ "$carrier" = 1 ] || { result true true true false not_associated; return 0; } + ipv4_ready || { result true true true false acquiring_address; return 0; } result true true true true associated } @@ -81,6 +146,7 @@ reconcile() { operation_result false backend_failed return 1 fi + wifi_preference >/dev/null || { operation_result false wifi_state_unavailable; return 1; } reconcile_services || { operation_result false remote_services_failed; return 1; } operation_result true applied } @@ -95,27 +161,40 @@ set_preference() { wifi=$(wifi_capability) || { preference_result "$operation" false false platform_unavailable; return 1; } [ "$wifi" = true ] || { preference_result "$operation" false false no_network_hardware; return 1; } [ -x "$SETTINGS" ] || { preference_result "$operation" false false settings_unavailable; return 1; } + [ -x "$BACKEND" ] || { preference_result "$operation" false false backend_unavailable; return 1; } + previous=$(wifi_preference) || { + preference_result "$operation" false false wifi_state_unavailable + return 1 + } + case "$previous" in + 0) previous_desired=false ;; + 1) previous_desired=true ;; + *) preference_result "$operation" false false wifi_state_unavailable; return 1 ;; + esac "$SETTINGS" set device.wifi_enabled "$desired" >/dev/null 2>&1 || { preference_result "$operation" false false settings_rejected return 1 } - [ -x "$BACKEND" ] || { preference_result "$operation" true false backend_unavailable; return 1; } if "$BACKEND" reconcile >/dev/null 2>&1; then - if [ -x "$COMPAT" ]; then + if [ "$desired" = true ] && [ -x "$COMPAT" ]; then "$COMPAT" services >/dev/null 2>&1 || { - preference_result "$operation" true false compatibility_failed + rollback_preference_failure "$operation" compatibility_failed "$previous_desired" return 1 } fi else status=$? if [ "$status" -ne 3 ] || [ ! -x "$COMPAT" ] || ! "$COMPAT" check >/dev/null 2>&1; then - preference_result "$operation" true false backend_failed + rollback_preference_failure "$operation" backend_failed "$previous_desired" return 1 fi fi reconcile_services || { - preference_result "$operation" true false remote_services_failed + rollback_preference_failure "$operation" remote_services_failed "$previous_desired" + return 1 + } + persist_device_profile || { + rollback_preference_failure "$operation" profile_persistence_failed "$previous_desired" return 1 } preference_result "$operation" true true applied diff --git a/static/build/.tmp_update/bin/bloom-network-services b/static/build/.tmp_update/bin/bloom-network-services index b1a4f0279..f276fc499 100755 --- a/static/build/.tmp_update/bin/bloom-network-services +++ b/static/build/.tmp_update/bin/bloom-network-services @@ -13,6 +13,7 @@ SAMBA_HELPER=${BLOOM_SAMBA_SERVICE_BIN:-$SYS_DIR/bin/bloom-samba-service} PLATFORM=${BLOOM_PLATFORM_BIN:-$SYS_DIR/bin/bloom-platform} SETTINGS=${BLOOM_SETTINGS_BIN:-$SYS_DIR/bin/bloom-settings} LISTENER_AUDIT=${BLOOM_LISTENER_AUDIT_BIN:-$SYS_DIR/bin/bloom-listener-audit} +IP=${BLOOM_NETWORK_SERVICES_IP_BIN:-/bin/ip} boolean() { "$@" >/dev/null 2>&1 && printf true || printf false; } legacy_enabled() { [ -f "$STATE_DIR/$1.enabled" ]; } @@ -38,7 +39,10 @@ wifi_ready() { interface="$ROOT/sys/class/net/wlan0" [ -r "$interface/operstate" ] && [ -r "$interface/carrier" ] || return 1 [ "$(cat "$interface/operstate" 2>/dev/null || true)" = up ] && - [ "$(cat "$interface/carrier" 2>/dev/null || true)" = 1 ] + [ "$(cat "$interface/carrier" 2>/dev/null || true)" = 1 ] || return 1 + [ -x "$IP" ] || return 1 + "$IP" -4 -o addr show dev wlan0 scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' } settings_get() { diff --git a/static/build/.tmp_update/bin/bloom-network-transaction b/static/build/.tmp_update/bin/bloom-network-transaction index 11b9dad23..a197e7c1c 100755 --- a/static/build/.tmp_update/bin/bloom-network-transaction +++ b/static/build/.tmp_update/bin/bloom-network-transaction @@ -143,6 +143,18 @@ publish_state() { sync } +archive_state() { + [ ! -L "$LAST" ] || fail "prior transaction evidence path is unsafe" + last_tmp="$LAST.tmp.$$" + cp -p "$STATE" "$last_tmp" || fail "cannot preserve prior transaction evidence" + "$JQ" -e . "$last_tmp" >/dev/null || { + rm -f "$last_tmp" + fail "prior transaction evidence is invalid" + } + mv -f "$last_tmp" "$LAST" || fail "cannot publish prior transaction evidence" + sync +} + valid_key() { [ -f "$AUTHORIZED_KEYS" ] && [ ! -L "$AUTHORIZED_KEYS" ] && [ -s "$AUTHORIZED_KEYS" ] || return 1 awk ' @@ -185,7 +197,7 @@ command_begin() { if current="$(read_state 2>/dev/null)"; then phase="$(printf '%s\n' "$current" | "$JQ" -er '.phase')" case "$phase" in armed|applying|applied|rolling_back) fail "another network transaction is active" ;; esac - cp -p "$STATE" "$LAST" || fail "cannot preserve prior transaction evidence" + archive_state elif [ -e "$STATE" ]; then fail "transaction state is invalid" fi @@ -293,6 +305,32 @@ verify_prior_unchanged() { [ ! -x "$LISTENER_AUDIT" ] || "$LISTENER_AUDIT" >/dev/null 2>&1 || return 1 } +wait_for_prior_unchanged() { + wait_limit=$1 + case "$wait_limit" in ''|*[!0-9]*|0) return 1 ;; esac + wait_attempt=0 + while [ "$wait_attempt" -lt "$wait_limit" ]; do + verify_prior_unchanged && return 0 + wait_attempt=$((wait_attempt + 1)) + [ "$wait_attempt" -lt "$wait_limit" ] || break + "$SLEEP" 1 || return 1 + done + return 1 +} + +restore_prior_bounded() { + restore_attempt=0 + while [ "$restore_attempt" -lt 4 ]; do + restore_attempt=$((restore_attempt + 1)) + if restore_prior && wait_for_prior_unchanged 45; then + return 0 + fi + [ "$restore_attempt" -lt 4 ] || break + "$SLEEP" 1 || return 1 + done + return 1 +} + rollback_locked() { reason=$1 state="$(read_state)" || fail "transaction state is unavailable" @@ -305,21 +343,21 @@ rollback_locked() { if [ "$shadow_port" -gt 0 ]; then "$SSH_HELPER" shadow-stop >/dev/null 2>&1 || restored=false fi - overlay_restored=false if [ "$operation" = network-overlay ] && [ -n "$overlay" ] && [ -x "$DEV_DEPLOY" ]; then dev_status="$($DEV_DEPLOY status 2>/dev/null || true)" active_overlay="$(printf '%s\n' "$dev_status" | "$JQ" -er '.active.deployment_id // ""' 2>/dev/null || true)" if [ "$active_overlay" = "$overlay" ]; then "$DEV_DEPLOY" rollback >/dev/null 2>&1 || restored=false - [ "$restored" != true ] || overlay_restored=true fi fi if [ "$operation" = ssh-shadow ]; then verify_prior_unchanged || restored=false - elif [ "$overlay_restored" = true ] && verify_prior_unchanged; then + elif [ "$operation" = none ] && verify_prior_unchanged; then + : + elif [ "$operation" = network-overlay ] && wait_for_prior_unchanged 15; then : else - restore_prior || restored=false + restore_prior_bounded || restored=false fi if [ "$restored" = true ]; then final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rolled_back"|.recovery_lease="inactive"')" @@ -330,7 +368,7 @@ rollback_locked() { rm -f "$WATCHDOG_READY" return 0 fi - final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rollback_failed"|.failure_reason="restore_failed"|.recovery_lease="inactive"')" + final="$(printf '%s\n' "$rolling" | "$JQ" -c '.phase="rollback_failed"|.failure_reason="restore_failed"|.recovery_lease="armed"')" publish_state "$final" rm -f "$WATCHDOG_READY" return 1 @@ -342,7 +380,8 @@ command_apply() { overlay=${3:-} [ "$SHADOW_PORT" = 2222 ] || fail "SSH shadow port configuration is invalid" case "$operation" in - wifi-enable|wifi-disable|ssh-enable|ssh-disable|ssh-shadow) [ -z "$overlay" ] || fail "operation has unexpected arguments" ;; + wifi-disable) fail "remote Wi-Fi disable is not device-qualified" ;; + wifi-enable|ssh-enable|ssh-disable|ssh-shadow) [ -z "$overlay" ] || fail "operation has unexpected arguments" ;; network-overlay) safe_id "$overlay" || fail "network overlay id is unsafe" ;; *) fail "network mutation operation is unsupported" ;; esac @@ -357,7 +396,6 @@ command_apply() { applied=true case "$operation" in wifi-enable) "$NETWORK" request enable >/dev/null 2>&1 || applied=false ;; - wifi-disable) "$NETWORK" request disable >/dev/null 2>&1 || applied=false ;; ssh-enable) "$SERVICES" request ssh enable >/dev/null 2>&1 || applied=false ;; ssh-disable) "$SERVICES" request ssh disable >/dev/null 2>&1 || applied=false ;; ssh-shadow) "$SSH_HELPER" shadow-start "$(printf '%s\n' "$state" | "$JQ" -r '.transaction_id')" "$SHADOW_PORT" >/dev/null 2>&1 || applied=false ;; @@ -392,11 +430,52 @@ command_rollback() { lock state="$(read_state)" || fail "transaction state is unavailable" phase="$(printf '%s\n' "$state" | "$JQ" -r '.phase')" + if [ "$phase" = rollback_failed ]; then + verify_prior_unchanged || fail "failed rollback baseline is not restored" + archive_state + close_lease || fail "cannot close developer recovery lease" + verify_prior_unchanged || fail "failed rollback baseline changed during reconciliation" + final="$(printf '%s\n' "$state" | "$JQ" -c '.phase="rolled_back"|.failure_reason="explicit"|.recovery_lease="inactive"')" + publish_state "$final" + rm -f "$WATCHDOG_READY" + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"rollback","restored":true,"reconciled":true}' + return + fi case "$phase" in armed|applying|applied) ;; *) fail "transaction is not rollback eligible" ;; esac rollback_locked explicit || fail "network rollback failed" printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"rollback","restored":true}' } +command_boot_recover() { + state="$(read_state 2>/dev/null || true)" + lease="$(read_lease 2>/dev/null || true)" + if [ -z "$state" ] && [ -z "$lease" ]; then + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"not_required"}' + return 0 + fi + [ -n "$state" ] && [ -n "$lease" ] || fail "boot recovery state is incomplete" + transaction_id="$(printf '%s\n' "$state" | "$JQ" -er '.transaction_id')" + [ "$(printf '%s\n' "$lease" | "$JQ" -er '.transaction_id')" = "$transaction_id" ] || + fail "boot recovery transaction identity is invalid" + phase="$(printf '%s\n' "$state" | "$JQ" -er '.phase')" + lease_state="$(printf '%s\n' "$lease" | "$JQ" -er '.state')" + case "$phase:$lease_state" in + confirmed:inactive|rolled_back:inactive) + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"not_required"}' + return 0 + ;; + armed:armed|applying:armed|applied:armed|rolling_back:armed|rollback_failed:armed) ;; + *) fail "boot recovery state is not recoverable" ;; + esac + if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then + [ -d "$LOCK" ] && [ ! -L "$LOCK" ] || fail "boot recovery lock is unsafe" + rmdir "$LOCK" 2>/dev/null || fail "boot recovery lock is not empty" + fi + lock + rollback_locked boot_recovery || fail "boot recovery failed" + printf '%s\n' '{"schema":1,"service":"bloom-network-transaction","operation":"boot-recover","recovery":"restored"}' +} + command_status() { if ! state="$(read_state 2>/dev/null)"; then { [ ! -e "$STATE" ] && [ ! -L "$STATE" ]; } || fail "transaction state is invalid" @@ -478,8 +557,9 @@ case "$#:${1:-}" in 4:apply) command_apply "$2" "$3" "$4" ;; 2:confirm) command_confirm "$2" ;; 1:rollback) command_rollback ;; + 1:boot-recover) command_boot_recover ;; 3:authorize-overlay) command_authorize_overlay "$2" "$3" ;; 3:authorize-shadow) command_authorize_shadow "$2" "$3" ;; 2:watchdog) command_watchdog "$2" ;; - *) fail "expected {status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback|authorize-overlay TRANSACTION ID|authorize-shadow TRANSACTION PORT}" ;; + *) fail "expected {status|begin SECONDS|apply NONCE OPERATION [ID]|confirm NONCE|rollback|boot-recover|authorize-overlay TRANSACTION ID|authorize-shadow TRANSACTION PORT}" ;; esac diff --git a/static/build/.tmp_update/bin/bloom-sftp-gate b/static/build/.tmp_update/bin/bloom-sftp-gate index 9a035db72..edb79d611 100755 --- a/static/build/.tmp_update/bin/bloom-sftp-gate +++ b/static/build/.tmp_update/bin/bloom-sftp-gate @@ -5,6 +5,7 @@ ROOT=${BLOOM_ROOT:-} SD_ROOT="$ROOT/mnt/SDCARD" STATE_DIR="$SD_ROOT/.bloom/network-services" SETTINGS=${BLOOM_SETTINGS_BIN:-$SD_ROOT/.tmp_update/bin/bloom-settings} +TRANSFER_POLICY=${BLOOM_TRANSFER_POLICY_BIN:-$SD_ROOT/.tmp_update/bin/bloom-transfer-policy} SFTP_ROOT="$ROOT/tmp/bloom-sftp-root" SERVER="$SFTP_ROOT/.bloom-bin/sftp-server" CHROOT=${BLOOM_SFTP_CHROOT_BIN:-$ROOT/usr/sbin/chroot} @@ -14,5 +15,25 @@ case "$enabled" in true) ;; false) exit 1 ;; *) [ -f "$STATE_DIR/sftp.enabled" ] [ -d "$SFTP_ROOT" ] && [ ! -L "$SFTP_ROOT" ] || exit 1 [ -x "$SERVER" ] || exit 1 [ -x "$CHROOT" ] || exit 1 +[ -x "$TRANSFER_POLICY" ] && "$TRANSFER_POLICY" validate || exit 1 +BLOOM_TRANSFER_WRITE_ROOTS="$($TRANSFER_POLICY mounts | awk -F '\t' '$3 == "read_write" {if (value != "") value = value ":"; value = value $1} END {print value}')" +BLOOM_TRANSFER_PUBLIC_ROOTS="$($TRANSFER_POLICY mounts | awk -F '\t' '{if (value != "") value = value ":"; value = value $1} END {print value}')" +BLOOM_TRANSFER_LIMITS="$($TRANSFER_POLICY limits)" +tab="$(printf '\t')" +case "$BLOOM_TRANSFER_WRITE_ROOTS" in + Roms:BIOS:Artwork:Screenshots:Themes) ;; + *) exit 1 ;; +esac +case "$BLOOM_TRANSFER_PUBLIC_ROOTS" in + Roms:BIOS:Artwork:Screenshots:Themes:Saves) ;; + *) exit 1 ;; +esac +case "$BLOOM_TRANSFER_LIMITS" in + "1024${tab}32${tab}255${tab}4294967295") ;; + *) exit 1 ;; +esac +BLOOM_TRANSFER_MAX_UPLOAD_BYTES=4294967295 +export BLOOM_TRANSFER_WRITE_ROOTS BLOOM_TRANSFER_PUBLIC_ROOTS \ + BLOOM_TRANSFER_MAX_UPLOAD_BYTES exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \ -P home-directory,users-groups-by-id diff --git a/static/build/.tmp_update/bin/bloom-transfer-policy b/static/build/.tmp_update/bin/bloom-transfer-policy new file mode 100755 index 000000000..329337ff8 --- /dev/null +++ b/static/build/.tmp_update/bin/bloom-transfer-policy @@ -0,0 +1,163 @@ +#!/bin/sh +set -eu +umask 077 + +ROOT=${BLOOM_ROOT:-} +SD_ROOT=${BLOOM_SD_ROOT:-$ROOT/mnt/SDCARD} +SYSTEM_ROOT=${BLOOM_SYSTEM_ROOT:-$SD_ROOT/.tmp_update} +POLICY=${BLOOM_TRANSFER_POLICY:-$SYSTEM_ROOT/config/transfer-policy.json} +JQ=${BLOOM_JQ_BIN:-$SYSTEM_ROOT/bin/jq} +STAGING_ROOT=${BLOOM_TRANSFER_STAGING_ROOT:-$SD_ROOT/.bloom/transfer-staging} +TEMPORARY= + +fail() { printf 'bloom-transfer-policy: %s\n' "$*" >&2; exit 1; } +cleanup() { [ -z "$TEMPORARY" ] || rm -f "$TEMPORARY"; } +trap cleanup EXIT HUP INT TERM + +validate_policy() { + [ -x "$JQ" ] || return 1 + [ -f "$POLICY" ] && [ ! -L "$POLICY" ] || return 1 + "$JQ" -e ' + (keys | sort) == ["limits", "policy_id", "private_device_roots", "publication", "roots", "schema"] and + .schema == 1 and .policy_id == "bloomos-transfer-policy-v1" and + .publication == "same_filesystem_atomic_replace" and + .limits == {"max_path_bytes":1024,"max_components":32,"max_component_bytes":255,"max_upload_bytes":4294967295} and + [.roots[] | [.id, .virtual_path, .device_path, .access]] == [ + ["roms", "/Roms", "Roms", "read_write"], + ["bios", "/BIOS", "BIOS", "read_write"], + ["artwork", "/Artwork", "Imgs", "read_write"], + ["screenshots", "/Screenshots", "Screenshots", "read_write"], + ["themes", "/Themes", "Themes", "read_write"], + ["saves", "/Saves", "Saves", "read_only"] + ] and .private_device_roots == [".bloom", ".tmp_update"] + ' "$POLICY" >/dev/null 2>&1 +} + +validate_path_text() { + value=$1 + [ "$(printf %s "$value" | wc -c | tr -d ' ')" -le 1024 ] || return 1 + case "$value" in + /*) ;; + *) return 1 ;; + esac + case "$value" in + /|*//*|*/./*|*/../*|*/.|*/..|*/|*\\*) return 1 ;; + esac + "$JQ" -en --arg value "$value" ' + ($value | test("[\u0000-\u001f\u007f]")) | not + ' >/dev/null 2>&1 +} + +load_path() { + TRANSFER_PATH=$1 + validate_policy || return 1 + validate_path_text "$TRANSFER_PATH" || return 1 + without_slash=${TRANSFER_PATH#/} + first=${without_slash%%/*} + VIRTUAL_ROOT=/$first + ROOT_RECORD="$($JQ -ce --arg root "$VIRTUAL_ROOT" '[.roots[] | select(.virtual_path == $root)] | select(length == 1) | .[0]' "$POLICY" 2>/dev/null)" || return 1 + ROOT_ID="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.id')" || return 1 + DEVICE_PATH="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.device_path')" || return 1 + ACCESS="$(printf '%s\n' "$ROOT_RECORD" | "$JQ" -er '.access')" || return 1 + if [ "$TRANSFER_PATH" = "$VIRTUAL_ROOT" ]; then RELATIVE=; else RELATIVE=${TRANSFER_PATH#"$VIRTUAL_ROOT"/}; fi + component_count=0 + old_ifs=$IFS + IFS=/ + for component in $RELATIVE; do + component_count=$((component_count + 1)) + [ "$component_count" -le 32 ] || { IFS=$old_ifs; return 1; } + [ "$(printf %s "$component" | wc -c | tr -d ' ')" -le 255 ] || { IFS=$old_ifs; return 1; } + case "$component" in ''|.|..|.bloom*) IFS=$old_ifs; return 1 ;; esac + done + IFS=$old_ifs + TARGET="$SD_ROOT/$DEVICE_PATH" + [ -z "$RELATIVE" ] || TARGET="$TARGET/$RELATIVE" +} + +safe_target_boundary() { + source_root="$SD_ROOT/$DEVICE_PATH" + [ -d "$source_root" ] && [ ! -L "$source_root" ] || return 1 + [ -z "$RELATIVE" ] && return 0 + current=$source_root + old_ifs=$IFS + IFS=/ + for component in $RELATIVE; do + current="$current/$component" + if [ -L "$current" ]; then IFS=$old_ifs; return 1; fi + if [ -e "$current" ] && [ "$current" != "$TARGET" ] && [ ! -d "$current" ]; then + IFS=$old_ifs + return 1 + fi + done + IFS=$old_ifs +} + +command_mounts() { + validate_policy || fail "policy is unavailable or invalid" + "$JQ" -er '.roots[] | [.virtual_path[1:], .device_path, .access] | @tsv' "$POLICY" +} + +command_limits() { + validate_policy || fail "policy is unavailable or invalid" + "$JQ" -er '.limits | [.max_path_bytes, .max_components, .max_component_bytes, .max_upload_bytes] | @tsv' "$POLICY" +} + +command_authorize() { + operation=$1 + load_path "$2" || fail "path is outside the canonical transfer policy" + case "$operation" in + read) ;; + write) [ "$ACCESS" = read_write ] || fail "root is read-only" ;; + *) fail "operation is unsupported" ;; + esac + safe_target_boundary || fail "path crosses a symlink or non-directory boundary" + "$JQ" -cn --arg operation "$operation" --arg root "$ROOT_ID" \ + --arg access "$ACCESS" --arg virtual_path "$TRANSFER_PATH" --arg device_path "$TARGET" \ + '{schema:1,service:"bloom-transfer-policy",authorized:true,operation:$operation,root:$root,access:$access,virtual_path:$virtual_path,device_path:$device_path}' +} + +command_publish() { + load_path "$1" || fail "path is outside the canonical transfer policy" + [ "$ACCESS" = read_write ] || fail "root is read-only" + [ -n "$RELATIVE" ] || fail "a root cannot be replaced" + safe_target_boundary || fail "path crosses a symlink or non-directory boundary" + parent=${TARGET%/*} + [ -d "$parent" ] && [ ! -L "$parent" ] || fail "destination parent is unavailable" + [ ! -L "$TARGET" ] || fail "destination is a symlink" + staged=$2 + case "$staged" in "$STAGING_ROOT"/*) ;; *) fail "staged file is outside the private staging root" ;; esac + [ -d "$STAGING_ROOT" ] && [ ! -L "$STAGING_ROOT" ] || fail "staging root is unsafe" + [ -f "$staged" ] && [ ! -L "$staged" ] || fail "staged file is unavailable" + size="$(wc -c <"$staged" | tr -d ' ')" + case "$size" in ''|*[!0-9]*) fail "staged file size is invalid" ;; esac + [ "$size" -le 4294967295 ] || fail "staged file exceeds the policy limit" + TEMPORARY="$parent/.bloom-replace-$$" + [ ! -e "$TEMPORARY" ] && [ ! -L "$TEMPORARY" ] || fail "atomic replacement slot is occupied" + cp "$staged" "$TEMPORARY" || fail "cannot copy staged bytes into the destination filesystem" + chmod 0644 "$TEMPORARY" || fail "cannot set replacement permissions" + [ "$(wc -c <"$TEMPORARY" | tr -d ' ')" = "$size" ] || fail "replacement byte count changed" + sync + mv -f "$TEMPORARY" "$TARGET" || fail "cannot atomically publish replacement" + TEMPORARY= + sync + rm -f "$staged" + "$JQ" -cn --arg root "$ROOT_ID" --arg virtual_path "$TRANSFER_PATH" --argjson size "$size" \ + '{schema:1,service:"bloom-transfer-policy",operation:"publish",published:true,publication:"same_filesystem_atomic_replace",root:$root,virtual_path:$virtual_path,size:$size}' +} + +command_status() { + validate_policy || fail "policy is unavailable or invalid" + sha="$(sha256sum "$POLICY" | awk '{print $1}')" + "$JQ" -cn --arg sha256 "$sha" \ + '{schema:1,service:"bloom-transfer-policy",state:"ready",policy_id:"bloomos-transfer-policy-v1",policy_sha256:$sha256,roots:6,read_write_roots:5,read_only_roots:1,publication:"same_filesystem_atomic_replace"}' +} + +case "$#:${1:-}" in + 1:validate) validate_policy || fail "policy is unavailable or invalid" ;; + 1:mounts) command_mounts ;; + 1:limits) command_limits ;; + 1:status) command_status ;; + 3:authorize) command_authorize "$2" "$3" ;; + 3:publish) command_publish "$2" "$3" ;; + *) fail "usage: status | validate | mounts | limits | authorize | publish " ;; +esac diff --git a/static/build/.tmp_update/bin/bloom-wifi b/static/build/.tmp_update/bin/bloom-wifi index 3c5fad750..820fa1f39 100755 --- a/static/build/.tmp_update/bin/bloom-wifi +++ b/static/build/.tmp_update/bin/bloom-wifi @@ -12,9 +12,16 @@ WPA_CONFIG=${BLOOM_WIFI_CONFIG:-/appconfigs/wpa_supplicant.conf} UDHCPC=${BLOOM_WIFI_UDHCPC_BIN:-/sbin/udhcpc} UDHCPC_SCRIPT=${BLOOM_WIFI_UDHCPC_SCRIPT:-/etc/init.d/udhcpc.script} IW=${BLOOM_WIFI_IW_BIN:-/mnt/SDCARD/.tmp_update/bin/iw} +IP=${BLOOM_WIFI_IP_BIN:-/bin/ip} PKILL=${BLOOM_WIFI_PKILL_BIN:-/usr/bin/pkill} SLEEP=${BLOOM_WIFI_SLEEP_BIN:-sleep} +START_SLEEP=${BLOOM_WIFI_START_SLEEP_BIN:-sleep} +SERVICES=${BLOOM_WIFI_SERVICES_BIN:-/mnt/SDCARD/.tmp_update/bin/bloom-network-services} +NOHUP=${BLOOM_WIFI_NOHUP_BIN:-/usr/bin/nohup} +SELF=${BLOOM_WIFI_SELF:-/mnt/SDCARD/.tmp_update/bin/bloom-wifi} INTERFACE=${BLOOM_WIFI_INTERFACE:-wlan0} +WATCH_DIR=${BLOOM_WIFI_WATCH_DIR:-$ROOT/tmp/bloom-wifi-reconcile-watcher} +PROC_ROOT=${BLOOM_WIFI_PROC_ROOT:-/proc} result() { printf '{"schema":1,"service":"bloom-wifi","supported":%s,"applied":%s,"state":"%s"}\n' "$1" "$2" "$3" @@ -37,23 +44,178 @@ associated() { [ "$state" = up ] && [ "$carrier" = 1 ] } +ipv4_ready() { + require_executable "$IP" || return 1 + "$IP" -4 -o addr show dev "$INTERFACE" scope global 2>/dev/null | + awk '$3 == "inet" && $4 !~ /^0[.]0[.]0[.]0\// { found=1 } END { exit !found }' +} + require_executable() { [ -x "$1" ]; } +launch_dhcp() { + "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & +} + +restart_dhcp_client() { + "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true + "$SLEEP" 1 || return 1 + launch_dhcp +} + +watcher_is_live() { + watcher_pid=$1 + case "$watcher_pid" in + ''|*[!0-9]*) return 1 ;; + esac + kill -0 "$watcher_pid" 2>/dev/null || return 1 + watcher_command=$(tr '\000' ' ' <"$PROC_ROOT/$watcher_pid/cmdline" 2>/dev/null || true) + case "$watcher_command" in + *"$SELF watch"*) return 0 ;; + *) return 1 ;; + esac +} + +wait_for_started_watcher() { + watcher_child_pid=$1 + start_attempt=0 + while [ "$start_attempt" -lt 20 ]; do + if [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ]; then + watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) + watcher_is_live "$watcher_pid" && return 0 + fi + if ! kill -0 "$watcher_child_pid" 2>/dev/null; then + if wait "$watcher_child_pid"; then + return 0 + else + watcher_status=$? + fi + rm -f "$WATCH_DIR/pid" 2>/dev/null || true + rmdir "$WATCH_DIR" 2>/dev/null || true + return "$watcher_status" + fi + "$START_SLEEP" 0.05 || return 1 + start_attempt=$((start_attempt + 1)) + done + kill -TERM "$watcher_child_pid" 2>/dev/null || true + wait "$watcher_child_pid" 2>/dev/null || true + rm -f "$WATCH_DIR/pid" 2>/dev/null || true + rmdir "$WATCH_DIR" 2>/dev/null || true + return 1 +} + +start_reconcile_watcher() { + [ -x "$SERVICES" ] || return 0 + [ -x "$NOHUP" ] && [ -x "$SELF" ] || return 1 + if [ -e "$WATCH_DIR" ] || [ -L "$WATCH_DIR" ]; then + [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 + start_attempt=0 + while [ ! -f "$WATCH_DIR/pid" ] && [ "$start_attempt" -lt 20 ]; do + [ ! -L "$WATCH_DIR/pid" ] || return 1 + "$START_SLEEP" 0.05 || return 1 + start_attempt=$((start_attempt + 1)) + done + if [ -e "$WATCH_DIR/pid" ] || [ -L "$WATCH_DIR/pid" ]; then + [ -f "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 1 + watcher_pid=$(cat "$WATCH_DIR/pid" 2>/dev/null || true) + watcher_is_live "$watcher_pid" && return 0 + fi + rm -f "$WATCH_DIR/pid" || return 1 + rmdir "$WATCH_DIR" || return 1 + fi + mkdir "$WATCH_DIR" 2>/dev/null || return 0 + "$NOHUP" "$SELF" watch >/dev/null 2>&1 /dev/null || true) + if watcher_is_live "$watcher_pid"; then + kill -TERM "$watcher_pid" 2>/dev/null || return 1 + stop_attempt=0 + while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 40 ]; do + "$START_SLEEP" 0.05 || return 1 + stop_attempt=$((stop_attempt + 1)) + done + if kill -0 "$watcher_pid" 2>/dev/null; then + watcher_is_live "$watcher_pid" || return 1 + kill -KILL "$watcher_pid" 2>/dev/null || return 1 + stop_attempt=0 + while kill -0 "$watcher_pid" 2>/dev/null && [ "$stop_attempt" -lt 20 ]; do + "$START_SLEEP" 0.05 || return 1 + stop_attempt=$((stop_attempt + 1)) + done + kill -0 "$watcher_pid" 2>/dev/null && return 1 + fi + elif kill -0 "$watcher_pid" 2>/dev/null; then + return 1 + fi + rm -f "$WATCH_DIR/pid" || return 1 + fi + rmdir "$WATCH_DIR" 2>/dev/null || [ ! -e "$WATCH_DIR" ] +} + +reconcile_watcher() { + [ -d "$WATCH_DIR" ] && [ ! -L "$WATCH_DIR" ] || return 1 + [ ! -e "$WATCH_DIR/pid" ] && [ ! -L "$WATCH_DIR/pid" ] || return 0 + cleanup_watcher() { + rm -f "$WATCH_DIR/pid.$$" + [ "$(cat "$WATCH_DIR/pid" 2>/dev/null || true)" != "$$" ] || rm -f "$WATCH_DIR/pid" + rmdir "$WATCH_DIR" 2>/dev/null || true + } + trap cleanup_watcher EXIT + trap 'exit 0' HUP INT TERM + pid_tmp="$WATCH_DIR/pid.$$" + (umask 077; printf '%s\n' "$$" >"$pid_tmp") || return 1 + mv -f "$pid_tmp" "$WATCH_DIR/pid" || return 1 + attempt=0 + while [ "$attempt" -lt 60 ]; do + if ipv4_ready; then + "$SERVICES" reconcile >/dev/null 2>&1 || true + return 0 + fi + case "$attempt" in + 20|40) restart_dhcp_client || return 0 ;; + esac + attempt=$((attempt + 1)) + "$SLEEP" 1 || return 0 + done +} + start_wifi() { - for dependency in "$RADIO" "$IFCONFIG" "$WPA" "$UDHCPC" "$IW"; do + for dependency in "$RADIO" "$IFCONFIG" "$WPA" "$UDHCPC" "$UDHCPC_SCRIPT" "$IW" "$IP" "$PKILL"; do require_executable "$dependency" || return 1 done [ -r "$WPA_CONFIG" ] && [ ! -L "$WPA_CONFIG" ] || return 1 - [ -r "$UDHCPC_SCRIPT" ] && [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + unset LD_PRELOAD + "$PKILL" -TERM wpa_supplicant >/dev/null 2>&1 || true + "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true "$RADIO" wifion >/dev/null 2>&1 || return 1 "$SLEEP" 2 || return 1 "$IFCONFIG" "$INTERFACE" up >/dev/null 2>&1 || return 1 "$WPA" -B -D nl80211 "-i$INTERFACE" -c "$WPA_CONFIG" >/dev/null 2>&1 || return 1 - "$UDHCPC" -i "$INTERFACE" -s "$UDHCPC_SCRIPT" >/dev/null 2>&1 & + launch_dhcp "$IW" dev "$INTERFACE" set power_save off >/dev/null 2>&1 || return 1 + start_reconcile_watcher +} + +restart_dhcp() { + for dependency in "$UDHCPC" "$UDHCPC_SCRIPT" "$PKILL"; do + require_executable "$dependency" || return 1 + done + [ ! -L "$UDHCPC_SCRIPT" ] || return 1 + unset LD_PRELOAD + restart_dhcp_client || return 1 + start_reconcile_watcher } stop_wifi() { + stop_reconcile_watcher || return 1 if require_executable "$PKILL"; then "$PKILL" -TERM wpa_supplicant >/dev/null 2>&1 || true "$PKILL" -TERM udhcpc >/dev/null 2>&1 || true @@ -68,10 +230,15 @@ reconcile() { [ "$model" = mini_plus ] || { result false false unsupported_model; return 3; } preference=$(enabled) || { result true false preference_unavailable; return 1; } if [ "$preference" = 1 ]; then - if associated; then + if associated && ipv4_ready; then result true false preserved return 0 fi + if associated; then + restart_dhcp || { result true false dhcp_restart_failed; return 1; } + result true true dhcp_restarted + return 0 + fi start_wifi || { result true false start_failed; return 1; } result true true started return 0 @@ -80,5 +247,8 @@ reconcile() { result true true stopped } -[ "$#" -eq 1 ] && [ "$1" = reconcile ] || { result false false invalid_arguments; exit 2; } -reconcile +case "$#:${1:-}" in + 1:reconcile) reconcile ;; + 1:watch) reconcile_watcher ;; + *) result false false invalid_arguments; exit 2 ;; +esac diff --git a/static/build/.tmp_update/runtime.sh b/static/build/.tmp_update/runtime.sh index 604337f04..0c8105fe6 100644 --- a/static/build/.tmp_update/runtime.sh +++ b/static/build/.tmp_update/runtime.sh @@ -45,6 +45,13 @@ main() { fi init_system + # A remote mutation may have lost power after persisting its bounded prior + # state. Recover that exact known-good station and service baseline before + # ordinary boot networking consumes the saved Wi-Fi preference. A restored + # root setting must also replace the per-device boot profile immediately; + # otherwise a later hard power loss can copy the failed preference back + # after the recovery lease has already closed. + recover_network_transaction || : update_time # Remount passwd/group to add our own users @@ -1215,6 +1222,26 @@ save_settings() { fi } +recover_network_transaction() { + [ -x "$sysdir/bin/bloom-network-transaction" ] || return 0 + recovery_result="$("$sysdir/bin/bloom-network-transaction" boot-recover 2>&1)" + recovery_status=$? + printf '%s\n' "$recovery_result" >> "$sysdir/logs/bloom-network-transaction.log" + if [ "$recovery_status" -ne 0 ]; then + log "Bloom network transaction boot recovery failed" + return 1 + fi + case "$recovery_result" in + *'"recovery":"restored"'*) + if ! save_settings; then + log "Bloom network transaction recovery profile persistence failed" + return 1 + fi + ;; + esac + return 0 +} + update_time() { if time_result=$($sysdir/bin/bloom-time reconcile); then log "$time_result" diff --git a/static/build/.tmp_update/script/network/update_networking.sh b/static/build/.tmp_update/script/network/update_networking.sh index 4eca53369..8b1371684 100644 --- a/static/build/.tmp_update/script/network/update_networking.sh +++ b/static/build/.tmp_update/script/network/update_networking.sh @@ -1,5 +1,7 @@ #!/bin/sh root=${BLOOM_NETWORK_ROOT:-} +procroot=${BLOOM_NETWORK_PROC_ROOT:-/proc} +killbin=${BLOOM_NETWORK_KILL_BIN:-kill} sysdir=$root/mnt/SDCARD/.tmp_update miyoodir=$root/mnt/SDCARD/miyoo customerdir=$root/customer @@ -103,11 +105,33 @@ check_services() { # Bloom retains this compatibility script only for station bring-up, time, OTA, # and delegation to the reviewed key-only SSH/SFTP owner. Inherited listener # flags are never authoritative and cannot activate a service. +stop_busybox_telnet() { + for process_dir in "$procroot"/[0-9]*; do + [ -d "$process_dir" ] && [ ! -L "$process_dir" ] && + [ -r "$process_dir/cmdline" ] && [ ! -L "$process_dir/cmdline" ] || continue + process_id=${process_dir##*/} + case "$process_id" in ''|*[!0-9]*) continue ;; esac + command_line=$(tr '\000' ' ' <"$process_dir/cmdline" 2>/dev/null || true) + case "$command_line" in + 'busybox telnetd'|'busybox telnetd '|*/busybox\ telnetd|*/busybox\ telnetd\ ) + if "$killbin" -9 "$process_id" 2>/dev/null; then + log "Bloom security: stopped inherited busybox telnetd process" + fi + ;; + esac + done +} + lockdown_legacy_listeners() { for service in $legacy_services; do disable_flag "$service" done + # The firmware launches Telnet as the BusyBox applet `busybox telnetd`. + # Its process name is therefore `busybox`, so pgrep/killall by `telnetd` + # cannot see it. Match only the exact applet argv and kill only that PID. + stop_busybox_telnet + for process in bftpd filebrowser telnetd hostapd dnsmasq smbd; do if is_running "$process"; then killall -9 "$process" diff --git a/test/shell/bloom_device.bats b/test/shell/bloom_device.bats index f7ae235ae..a1934d5ef 100644 --- a/test/shell/bloom_device.bats +++ b/test/shell/bloom_device.bats @@ -416,6 +416,9 @@ PY [ "$status" -eq 1 ] run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef network-overlay '../escape' [ "$status" -eq 1 ] + run "$DEVICE_TOOL" network-apply test-plus 0123456789abcdef0123456789abcdef wifi-disable + [ "$status" -eq 1 ] + [[ "$output" == *'remote Wi-Fi disable is not device-qualified'* ]] ! grep -Fq 'bloom-network-transaction begin' "$MOCK_LOG" ! grep -Fq 'bloom-network-transaction apply' "$MOCK_LOG" } diff --git a/test/shell/dev_deploy.bats b/test/shell/dev_deploy.bats index a4f5c2f90..f14d137d2 100644 --- a/test/shell/dev_deploy.bats +++ b/test/shell/dev_deploy.bats @@ -149,6 +149,60 @@ stage_audit() { "$SERVICE" seal "$id" >/dev/null } +stage_network_bundle() { + id=$1 + payload="$BATS_TEST_TMPDIR/$id-payload" + manifest="$BATS_TEST_TMPDIR/$id.json" + for path in \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + runtime.sh \ + script/network/update_networking.sh \ + script/network/wpsclient.sh; do + mkdir -p "$payload/$(dirname "$path")" + if [ "$path" = config/transfer-policy.json ]; then + printf '%s\n' '{}' >"$payload/$path" + else + printf '#!/bin/sh\nexit 0\n' >"$payload/$path" + chmod 0755 "$payload/$path" + fi + done + cat >"$payload/bin/bloom-network-services" <<'EOF' +#!/bin/sh +[ "$1" = status ] || exit 1 +printf '%s\n' '{"schema":2,"service":"bloom-network-services"}' +EOF + cat >"$payload/bin/bloom-transfer-policy" <<'EOF' +#!/bin/sh +[ "$1" = validate ] +EOF + chmod 0755 "$payload/bin/bloom-network-services" "$payload/bin/bloom-transfer-policy" + python3 /workspace/tools/dev-deploy-manifest.py \ + network-helpers "$payload" "$id" \ + 0123456789abcdef0123456789abcdef01234567 "$manifest" + manifest_size=$(wc -c <"$manifest" | tr -d ' ') + manifest_sha=$(sha256sum "$manifest" | awk '{print $1}') + "$SERVICE" prepare "$id" >/dev/null + "$SERVICE" manifest "$id" "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null + jq -r '.files[] | [.path,.size,.sha256,.mode] | @tsv' "$manifest" | + while IFS=$'\t' read -r path size sha mode; do + "$SERVICE" receive "$id" "$path" "$size" "$sha" "$mode" <"$payload/$path" >/dev/null + done + "$SERVICE" seal "$id" >/dev/null +} + @test "preflight requires developer mode local enablement and an idle update and session" { run "$SERVICE" preflight [ "$status" -eq 0 ] @@ -339,21 +393,23 @@ EOF [ "$(printf '%s\n' "$output" | grep -Ec 'Roms|Saves|BIOS|authorized_keys|appconfigs|\.bloom/update')" -eq 0 ] } -@test "ordinary activation cannot mutate network helpers" { - payload="$BATS_TEST_TMPDIR/network-audit" - manifest="$BATS_TEST_TMPDIR/network-candidate.json" - write_audit "$payload" network - size=$(wc -c <"$payload" | tr -d ' ') - sha=$(sha256sum "$payload" | awk '{print $1}') - cat >"$manifest" <"$manifest" <<'EOF' +{"schema":1,"deployment_id":"network-incomplete","component":"network-helpers","base_commit":"0123456789abcdef0123456789abcdef01234567","files":[{"path":"bin/bloom-network","size":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","mode":"0755"}]} EOF manifest_size=$(wc -c <"$manifest" | tr -d ' ') manifest_sha=$(sha256sum "$manifest" | awk '{print $1}') - "$SERVICE" prepare network-candidate >/dev/null - "$SERVICE" manifest network-candidate "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null - "$SERVICE" receive network-candidate bin/bloom-listener-audit "$size" "$sha" 0755 <"$payload" >/dev/null - "$SERVICE" seal network-candidate >/dev/null + "$SERVICE" prepare network-incomplete >/dev/null + + run "$SERVICE" manifest network-incomplete "$manifest_size" "$manifest_sha" <"$manifest" + + [ "$status" -ne 0 ] + [[ "$output" == *'network helper bundle is incomplete'* ]] +} + +@test "ordinary activation cannot mutate network helpers" { + stage_network_bundle network-candidate run "$SERVICE" activate network-candidate @@ -363,25 +419,7 @@ EOF } @test "network helper activation requires an exact active transaction authorization" { - payload="$BATS_TEST_TMPDIR/network-services" - manifest="$BATS_TEST_TMPDIR/network-authorized.json" - cat >"$payload" <<'EOF' -#!/bin/sh -[ "$1" = status ] || exit 1 -printf '%s\n' '{"schema":1,"service":"bloom-network-services"}' -EOF - chmod +x "$payload" - size=$(wc -c <"$payload" | tr -d ' ') - sha=$(sha256sum "$payload" | awk '{print $1}') - cat >"$manifest" </dev/null - "$SERVICE" manifest network-authorized "$manifest_size" "$manifest_sha" <"$manifest" >/dev/null - "$SERVICE" receive network-authorized bin/bloom-network-services "$size" "$sha" 0755 <"$payload" >/dev/null - "$SERVICE" seal network-authorized >/dev/null + stage_network_bundle network-authorized run "$SERVICE" activate-network network-authorized tx-wrong [ "$status" -ne 0 ] diff --git a/test/shell/developer_ssh.bats b/test/shell/developer_ssh.bats index 16237b859..631cb6754 100755 --- a/test/shell/developer_ssh.bats +++ b/test/shell/developer_ssh.bats @@ -14,10 +14,22 @@ load 'support/test_helper' grep -F 'export LDFLAGS="-static -Wl,--gc-sections"' \ /workspace/tools/build-sftp-server.sh grep -F 'getuid() != 0' /workspace/tools/patches/openssh-sftp-static-user.patch + grep -F 'bloom_atomic_open' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'fsync(handles[handle].fd)' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'rename(handles[handle].atomic_name' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '"BLOOM_TRANSFER_WRITE_ROOTS"' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '"BLOOM_TRANSFER_PUBLIC_ROOTS"' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'getenv("BLOOM_TRANSFER_MAX_UPLOAD_BYTES")' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'handle_set_atomic_ready(handle)' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F 'SSH2_FX_PERMISSION_DENIED' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '*scan < 0x20 || *scan == 0x7f' /workspace/tools/patches/openssh-sftp-atomic-replace.patch + grep -F '++components > 32' /workspace/tools/patches/openssh-sftp-atomic-replace.patch grep -F 'exec "$CHROOT" "$SFTP_ROOT" /.bloom-bin/sftp-server -d / \' \ /workspace/static/build/.tmp_update/bin/bloom-sftp-gate grep -F -- '-P home-directory,users-groups-by-id' \ /workspace/static/build/.tmp_update/bin/bloom-sftp-gate + grep -F '"$TRANSFER_POLICY" mounts' \ + /workspace/static/build/.tmp_update/bin/bloom-dev-ssh } setup() { @@ -46,6 +58,9 @@ EOF export BLOOM_DROPBEAR="$MOCK_BIN/dropbear" mock_command sftp-server export BLOOM_SFTP_SERVER="$MOCK_BIN/sftp-server" + export BLOOM_TRANSFER_POLICY_BIN=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" export BLOOM_DEV_NULL=/dev/null export BLOOM_NETWORK_TRANSACTION_BIN="$MOCK_BIN/network-transaction" cat >"$BLOOM_NETWORK_TRANSACTION_BIN" <<'EOF' @@ -141,7 +156,7 @@ install_valid_key() { @test "SFTP-only mode forces every authorized key through the bounded subsystem gate" { printf '285\n' >"$BLOOM_TEST_ROOT/tmp/deviceModel" install_valid_key - mkdir -p "$SDCARD/Roms" "$SDCARD/BIOS" "$SDCARD/Saves" "$SDCARD/Imgs" + mkdir -p "$SDCARD/Roms" "$SDCARD/BIOS" "$SDCARD/Saves" export BLOOM_SETTINGS_BIN="$MOCK_BIN/settings" cat >"$BLOOM_SETTINGS_BIN" <<'EOF' #!/bin/sh @@ -161,6 +176,7 @@ EOF ! cmp -s "$SDCARD/.bloom/authorized_keys" "$runtime_key" grep -F "$MOCK_BIN/mount -o bind $SDCARD/Roms $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/Roms" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o bind $SDCARD/BIOS $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/BIOS" "$MOCK_LOG" + [ -d "$SDCARD/Imgs" ] grep -F "$MOCK_BIN/mount -o bind $SDCARD/Imgs $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/Artwork" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o bind $MOCK_BIN/sftp-server $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/.bloom-bin/sftp-server" "$MOCK_LOG" grep -F "$MOCK_BIN/mount -o remount,bind,ro $BLOOM_TEST_ROOT/tmp/bloom-sftp-root/.bloom-bin/sftp-server" "$MOCK_LOG" diff --git a/test/shell/legacy_manifest.bats b/test/shell/legacy_manifest.bats index 770ec65bb..af4b33949 100644 --- a/test/shell/legacy_manifest.bats +++ b/test/shell/legacy_manifest.bats @@ -6,7 +6,7 @@ --manifest /workspace/build/legacy-manifest.json [ "$status" -eq 0 ] - [[ "$output" == "legacy manifest validate: 329 components" ]] + [[ "$output" == "legacy manifest validate: 330 components" ]] [ ! -e "/workspace/static/packages/RApp/SNK - Neo Geo (GnGeo)" ] [ ! -e "/workspace/static/packages/RApp/PICO-8 (PICO-8 standalone)" ] [ ! -e "/workspace/static/packages/RApp/SCUMM (ScummVM standalone)" ] @@ -44,6 +44,7 @@ assert components["runtime-tmp-update-bin-bloomctl"]["path"] == "static/build/.t assert components["runtime-tmp-update-bin-bloom-ra-login"]["path"] == "static/build/.tmp_update/bin/bloom-ra-login" assert components["runtime-tmp-update-bin-bloom-listener-audit"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-dev-deploy"]["resolution"] == "source-build" +assert components["runtime-tmp-update-bin-bloom-transfer-policy"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-network-transaction"]["resolution"] == "source-build" assert components["runtime-tmp-update-bin-bloom-remote-keys"]["resolution"] == "source-build" assert components["runtime-tmp-update-script-bloom-launch-trace-sh"]["resolution"] == "source-build" @@ -103,7 +104,7 @@ import sys components = json.load(open(sys.argv[1], encoding="utf-8"))["components"] runtime = [item for item in components if item["id"].startswith("runtime-")] bloom = [item for item in runtime if item["source"] == "https://github.com/boburning/BloomOS"] -assert len(bloom) == 52 +assert len(bloom) == 53 assert all(item["resolution"] == "source-build" for item in bloom) assert all(item["source_revision"] == "release-commit" for item in bloom) PY diff --git a/test/shell/listener_lockdown.bats b/test/shell/listener_lockdown.bats index 0a1e3d1b4..a9898c592 100644 --- a/test/shell/listener_lockdown.bats +++ b/test/shell/listener_lockdown.bats @@ -38,12 +38,12 @@ EOF @test "listener audit reports inherited listeners and processes without exposing addresses" { cat >"$INPUT/listeners" <<'EOF' tcp 0 0 0.0.0.0:21 0.0.0.0:* LISTEN 10/bftpd -tcp 0 0 0.0.0.0:23 0.0.0.0:* LISTEN 11/telnetd +tcp 0 0 0.0.0.0:23 0.0.0.0:* LISTEN 11/busybox tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 12/filebrowser tcp 0 0 0.0.0.0:445 0.0.0.0:* LISTEN 13/smbd udp 0 0 0.0.0.0:53 0.0.0.0:* 14/dnsmasq EOF - printf '%s\n' '10 bftpd' '11 telnetd' '12 filebrowser' '13 smbd' '14 dnsmasq' \ + printf '%s\n' '10 bftpd' '11 busybox telnetd' '12 filebrowser' '13 smbd' '14 dnsmasq' \ >"$INPUT/processes" run env BLOOM_LISTENER_AUDIT_INPUT_DIR="$INPUT" sh "$AUDIT" @@ -55,6 +55,41 @@ EOF [[ "$output" != *'0.0.0.0'* ]] } +@test "stable compatibility reconciliation stops the exact BusyBox telnet applet" { + root="$BATS_TEST_TMPDIR/root" + procroot="$BATS_TEST_TMPDIR/proc" + sysdir="$root/mnt/SDCARD/.tmp_update" + mock="$BATS_TEST_TMPDIR/mock" + calls="$BATS_TEST_TMPDIR/calls" + mkdir -p "$sysdir/config" "$sysdir/script" "$sysdir/bin" "$root/customer/app" \ + "$mock" "$procroot/3605" "$procroot/4000" "$BATS_TEST_TMPDIR/outside-process" + printf 'password\n' >"$sysdir/config/.password.txt" + printf '%s\n' 'log() { :; }' 'bootScreen() { :; }' >"$sysdir/script/log.sh" + printf '#!/bin/sh\nprintf 0\n' >"$root/customer/app/jsonval" + printf '#!/bin/sh\nexit 0\n' >"$sysdir/bin/bloom-dev-ssh" + printf 'busybox\000telnetd\000' >"$procroot/3605/cmdline" + printf 'sh\000-c\000echo busybox telnetd\000' >"$procroot/4000/cmdline" + printf 'busybox\000telnetd\000' >"$BATS_TEST_TMPDIR/outside-process/cmdline" + ln -s "$BATS_TEST_TMPDIR/outside-process" "$procroot/5000" + cat >"$mock/pgrep" <<'EOF' +#!/bin/sh +exit 1 +EOF + cat >"$mock/kill" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$CALLS" +EOF + chmod +x "$root/customer/app/jsonval" "$sysdir/bin/bloom-dev-ssh" "$mock/pgrep" "$mock/kill" + + run env BLOOM_NETWORK_ROOT="$root" BLOOM_NETWORK_PROC_ROOT="$procroot" \ + BLOOM_NETWORK_KILL_BIN="$mock/kill" CALLS="$calls" PATH="$mock:$PATH" sh "$COMPAT" services + + [ "$status" -eq 0 ] + grep -Fx -- '-9 3605' "$calls" + ! grep -Fq -- '4000' "$calls" + ! grep -Fq -- '5000' "$calls" +} + @test "stable compatibility reconciliation ignores stale flags and stops inherited processes" { root="$BATS_TEST_TMPDIR/root" sysdir="$root/mnt/SDCARD/.tmp_update" diff --git a/test/shell/network_services.bats b/test/shell/network_services.bats index df6802fe1..a05d0e0a4 100644 --- a/test/shell/network_services.bats +++ b/test/shell/network_services.bats @@ -8,7 +8,7 @@ setup() { mkdir -p "$SDCARD/.bloom" "$SDCARD/.tmp_update/config" "$MOCK" printf '%s\n' 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey bloom-test' \ >"$SDCARD/.bloom/authorized_keys" - for name in platform ssh-helper sftp-server samba-service settings; do + for name in platform ssh-helper sftp-server samba-service settings ip; do printf '#!/bin/sh\nexit 0\n' >"$MOCK/$name" chmod +x "$MOCK/$name" done @@ -27,6 +27,7 @@ export SETTINGS_READ_FAILURE="$BATS_TEST_TMPDIR/settings-read-failure" export BLOOM_SFTP_SERVER_BIN="$MOCK/sftp-server" export BLOOM_SAMBA_SERVICE_BIN="$MOCK/samba-service" export BLOOM_SETTINGS_BIN="$MOCK/settings" + export BLOOM_NETWORK_SERVICES_IP_BIN="$MOCK/ip" cat >"$MOCK/settings" <<'SH' #!/bin/sh case "$1" in @@ -40,11 +41,17 @@ case "$1" in set) field=${2#network.}; field=${field%_enabled}; printf '%s\n' "$3" >"$SETTINGS_STATE.$field"; [ -z "${SETTINGS_FAIL_AFTER_SET:-}" ] || : >"$SETTINGS_READ_FAILURE" ;; *) exit 2 ;; esac +SH + cat >"$MOCK/ip" <<'SH' +#!/bin/sh +[ -e "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' SH chmod +x "$MOCK/settings" mkdir -p "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0" printf 'up\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" printf '1\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" } @test "status reports bounded service capabilities and defaults" { @@ -208,6 +215,17 @@ EOF grep -Fx 'reconcile' "$MOCK_LOG" } +@test "carrier without a global address keeps reviewed listeners stopped" { + sh "$SERVICE" request ssh enable >/dev/null + : >"$MOCK_LOG" + rm "$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/ipv4" + run sh "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"wifi_ready":false'* ]] + grep -Fx 'stop' "$MOCK_LOG" + ! grep -Fx 'start' "$MOCK_LOG" +} + @test "offline Samba enable provisions deliberately without starting a listener" { printf 'down\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/operstate" printf '0\n' >"$BLOOM_NETWORK_SERVICES_ROOT/sys/class/net/wlan0/carrier" @@ -237,6 +255,9 @@ exec "$root$program" "$@" EOF chmod +x "$chroot" export MOCK_CHROOT_ARGS="$BATS_TEST_TMPDIR/chroot-arguments" + export BLOOM_TRANSFER_POLICY_BIN=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" run env BLOOM_ROOT="$BLOOM_NETWORK_SERVICES_ROOT" BLOOM_SFTP_CHROOT_BIN="$chroot" \ sh "$gate" [ "$status" -ne 0 ] diff --git a/test/shell/network_status.bats b/test/shell/network_status.bats index 7a4e82619..8870af086 100644 --- a/test/shell/network_status.bats +++ b/test/shell/network_status.bats @@ -10,32 +10,61 @@ setup() { export BLOOM_NETWORK_COMPAT_BIN="$BATS_TEST_TMPDIR/update-networking" export BLOOM_SETTINGS_BIN="$BATS_TEST_TMPDIR/bloom-settings" export BLOOM_NETWORK_SERVICES_BIN="$BATS_TEST_TMPDIR/bloom-network-services" + export BLOOM_NETWORK_IP_BIN="$BATS_TEST_TMPDIR/ip" + export BLOOM_NETWORK_READ_UUID_BIN="$BATS_TEST_TMPDIR/read-uuid" + export BLOOM_NETWORK_DEVICE_SETTINGS_ROOT="$BLOOM_NETWORK_ROOT/mnt/SDCARD/.tmp_update/config/system" export BACKEND_LOG="$BATS_TEST_TMPDIR/backend.log" export COMPAT_LOG="$BATS_TEST_TMPDIR/compat.log" export SETTINGS_LOG="$BATS_TEST_TMPDIR/settings.log" export SERVICES_LOG="$BATS_TEST_TMPDIR/services.log" - mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" + export APPLY_LOG="$BATS_TEST_TMPDIR/apply.log" + mkdir -p "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0" "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT" printf up >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" + printf '%s\n' '{"wifi":1}' >"$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json" + printf '#!/bin/sh\nprintf 5E649300ABEA\n' >"$BLOOM_NETWORK_READ_UUID_BIN" printf '#!/bin/sh\nprintf available\n' >"$BLOOM_PLATFORM_BIN" printf '#!/bin/sh\n[ "$1" = wifi ] || exit 2\nprintf 1\n' >"$BLOOM_NETWORK_JSONVAL_BIN" cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$BACKEND_LOG" +printf 'backend\n' >>"$APPLY_LOG" SH cat >"$BLOOM_NETWORK_COMPAT_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$COMPAT_LOG" +printf 'compat\n' >>"$APPLY_LOG" SH cat >"$BLOOM_SETTINGS_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$SETTINGS_LOG" +printf 'settings\n' >>"$APPLY_LOG" +case "$3" in + true) wifi=1 ;; + false) wifi=0 ;; + *) exit 2 ;; +esac +printf '{"wifi":%s}\n' "$wifi" >"$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json" SH cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' #!/bin/sh printf '%s\n' "$*" >"$SERVICES_LOG" +printf 'services\n' >>"$APPLY_LOG" SH - chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" + cat >"$BLOOM_NETWORK_IP_BIN" <<'SH' +#!/bin/sh +[ -e "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' +SH + chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_NETWORK_JSONVAL_BIN" "$BLOOM_NETWORK_BACKEND_BIN" "$BLOOM_NETWORK_COMPAT_BIN" "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_SERVICES_BIN" "$BLOOM_NETWORK_IP_BIN" "$BLOOM_NETWORK_READ_UUID_BIN" +} + +@test "status does not claim a usable association before DHCP publishes IPv4" { + rm "$BLOOM_NETWORK_ROOT/sys/class/net/wlan0/ipv4" + run "$SERVICE" status + [ "$status" -eq 0 ] + [[ "$output" == *'"associated":false,"state":"acquiring_address"'* ]] } @test "status reports associated Wi-Fi without exposing network identity" { @@ -142,27 +171,107 @@ SH [ "$(cat "$BACKEND_LOG")" = reconcile ] [ "$(cat "$COMPAT_LOG")" = services ] [ "$(cat "$SERVICES_LOG")" = reconcile ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend compat services ' ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json")" = 1 ] + rm -f "$COMPAT_LOG" "$SERVICES_LOG" "$APPLY_LOG" run "$SERVICE" request disable [ "$status" -eq 0 ] [[ "$output" == *'"operation":"disable"'* ]] [ "$(cat "$SETTINGS_LOG")" = 'set device.wifi_enabled false' ] [ "$(cat "$BACKEND_LOG")" = reconcile ] + [ ! -e "$COMPAT_LOG" ] + [ "$(cat "$SERVICES_LOG")" = reconcile ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend services ' ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json")" = 0 ] +} + +@test "preference persistence rejects a symlinked device profile and restores the prior state" { + outside="$BATS_TEST_TMPDIR/outside-profile" + printf '%s\n' 'do-not-overwrite' >"$outside" + ln -s "$outside" "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json" + + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"profile_persistence_failed_rollback_failed"'* ]] + [ "$(cat "$outside")" = do-not-overwrite ] + [ -L "$BLOOM_NETWORK_DEVICE_SETTINGS_ROOT/5E649300ABEA.json" ] + [ "$(jq -r .wifi "$BLOOM_NETWORK_ROOT/mnt/SDCARD/system.json")" = 1 ] } -@test "preference mutation reports settings and apply failures separately" { +@test "failed preference apply restores the prior preference and services" { printf '#!/bin/sh\nexit 1\n' >"$BLOOM_SETTINGS_BIN" chmod +x "$BLOOM_SETTINGS_BIN" run "$SERVICE" request enable [ "$status" -eq 1 ] [[ "$output" == *'"preference_saved":false,"applied":false,"state":"settings_rejected"'* ]] - printf '#!/bin/sh\nexit 0\n' >"$BLOOM_SETTINGS_BIN" + cat >"$BLOOM_SETTINGS_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$SETTINGS_LOG" +printf 'settings:%s\n' "$3" >>"$APPLY_LOG" +SH + cat >"$BLOOM_NETWORK_BACKEND_BIN" <<'SH' +#!/bin/sh +printf 'backend\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/backend-failed" ]; then + : >"$BATS_TEST_TMPDIR/backend-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_BACKEND_BIN" + rm -f "$APPLY_LOG" + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"backend_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$SETTINGS_LOG")" = 'set device.wifi_enabled false set device.wifi_enabled true ' ] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings:false backend settings:true backend compat services ' ] +} + +@test "failed preference rollback preserves explicit unsafe state" { + cat >"$BLOOM_SETTINGS_BIN" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$SETTINGS_LOG" +[ "$3" != true ] +SH printf '#!/bin/sh\nexit 1\n' >"$BLOOM_NETWORK_BACKEND_BIN" chmod +x "$BLOOM_SETTINGS_BIN" "$BLOOM_NETWORK_BACKEND_BIN" run "$SERVICE" request disable [ "$status" -eq 1 ] - [[ "$output" == *'"preference_saved":true,"applied":false,"state":"backend_failed"'* ]] + [[ "$output" == *'"preference_saved":true,"applied":false,"state":"backend_failed_rollback_failed"'* ]] + [ ! -e "$SERVICES_LOG" ] +} + +@test "compatibility failure restores the prior preference and services" { + cat >"$BLOOM_NETWORK_COMPAT_BIN" <<'SH' +#!/bin/sh +printf 'compat\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/compat-failed" ]; then + : >"$BATS_TEST_TMPDIR/compat-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_NETWORK_COMPAT_BIN" + run "$SERVICE" request enable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"compatibility_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend compat settings backend compat services ' ] +} + +@test "service failure restores the prior preference and backend" { + cat >"$BLOOM_NETWORK_SERVICES_BIN" <<'SH' +#!/bin/sh +printf 'services\n' >>"$APPLY_LOG" +if [ ! -e "$BATS_TEST_TMPDIR/services-failed" ]; then + : >"$BATS_TEST_TMPDIR/services-failed" + exit 1 +fi +SH + chmod +x "$BLOOM_NETWORK_SERVICES_BIN" + run "$SERVICE" request disable + [ "$status" -eq 1 ] + [[ "$output" == *'"preference_saved":false,"applied":false,"state":"remote_services_failed_rolled_back"'* ]] + [ "$(tr '\n' ' ' <"$APPLY_LOG")" = 'settings backend services settings backend compat services ' ] } @test "preference mutation refuses hardware without networking" { diff --git a/test/shell/network_transaction.bats b/test/shell/network_transaction.bats index c854ff09d..f4d41933c 100644 --- a/test/shell/network_transaction.bats +++ b/test/shell/network_transaction.bats @@ -44,7 +44,7 @@ if [ "$1" = status ]; then exit fi printf 'services %s %s %s\n' "$1" "${2:-}" "${3:-}" >>"$TRANSACTION_LOG" -[ "${SERVICES_FAIL_REQUEST:-0}" != 1 ] +[ "${SERVICES_FAIL_REQUEST:-0}" != 1 ] || [ "$1:${2:-}:${3:-}" != request:ssh:disable ] EOF cat >"$BLOOM_UPDATE_STATE_BIN" <<'EOF' #!/bin/sh @@ -59,7 +59,11 @@ EOF #!/bin/sh printf 'dev-deploy %s\n' "$*" >>"$TRANSACTION_LOG" if [ "$1" = status ]; then - printf '%s\n' '{"schema":1,"overlay_active":true,"active":{"deployment_id":"network-test"}}' + if [ "${DEV_OVERLAY_ACTIVE:-1}" = 1 ]; then + printf '%s\n' '{"schema":1,"overlay_active":true,"active":{"deployment_id":"network-test"}}' + else + printf '%s\n' '{"schema":1,"overlay_active":false,"active":null}' + fi fi EOF cat >"$BLOOM_LISTENER_AUDIT_BIN" <<'EOF' @@ -99,6 +103,14 @@ begin_transaction() { [[ "$output" == *'"recovery_lease":"armed"'* ]] } +seed_legacy_wifi_disable_transaction() { + begin_transaction + temporary="$TX_ROOT/state.json.legacy-wifi-disable" + jq -c '.phase="applied" | .operation="wifi-disable"' "$TX_ROOT/state.json" >"$temporary" + mv "$temporary" "$TX_ROOT/state.json" + printf '%s\n' 'network request disable' >>"$TRANSACTION_LOG" +} + @test "begin requires both local developer markers and a known-good recovery path" { begin_transaction run "$SERVICE" status @@ -188,6 +200,22 @@ EOF [ "$status" -ne 0 ] } +@test "remote Wi-Fi disable is rejected before any persistent mutation" { + begin_transaction + state_before="$(sha256sum "$TX_ROOT/state.json" | awk '{print $1}')" + lease_before="$(sha256sum "$TX_ROOT/recovery-lease.json" | awk '{print $1}')" + + run "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable + + [ "$status" -ne 0 ] + [[ "$output" == *'remote Wi-Fi disable is not device-qualified'* ]] + [ "$(sha256sum "$TX_ROOT/state.json" | awk '{print $1}')" = "$state_before" ] + [ "$(sha256sum "$TX_ROOT/recovery-lease.json" | awk '{print $1}')" = "$lease_before" ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = armed ] + [ "$(jq -r .operation "$TX_ROOT/state.json")" = none ] + ! grep -Fq 'network request disable' "$TRANSACTION_LOG" +} + @test "bounded SSH shadow listener is transaction-authorized and always cleaned up" { begin_transaction run "$SERVICE" apply 0123456789abcdef0123456789abcdef ssh-shadow @@ -219,8 +247,7 @@ EOF } @test "explicit rollback restores exact Wi-Fi and service preferences" { - begin_transaction - "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable >/dev/null + seed_legacy_wifi_disable_transaction rm "$SDCARD/.bloom/dev/network-mutation.enabled" run "$SERVICE" rollback [ "$status" -eq 0 ] @@ -232,9 +259,170 @@ EOF grep -F 'listener-audit' "$TRANSACTION_LOG" } +@test "Wi-Fi rollback retries transient radio restore failures" { + seed_legacy_wifi_disable_transaction + enable_count="$BATS_TEST_TMPDIR/enable-count" + printf '%s\n' 0 >"$enable_count" + export ENABLE_COUNT="$enable_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +if [ "$1:$2" = request:enable ]; then + count=$(cat "$ENABLE_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$ENABLE_COUNT" + [ "$count" -ge 3 ] +fi +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(cat "$enable_count")" -eq 3 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] +} + +@test "Wi-Fi rollback retries after an association convergence timeout" { + seed_legacy_wifi_disable_transaction + status_count="$BATS_TEST_TMPDIR/restore-status-count" + printf '%s\n' 0 >"$status_count" + export RESTORE_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$RESTORE_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$RESTORE_STATUS_COUNT" + if [ "$count" -le 45 ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' + else + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + fi + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(grep -Fc 'network request enable' "$TRANSACTION_LOG")" -eq 2 ] + [ "$(cat "$status_count")" -eq 46 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] +} + +@test "exhausted Wi-Fi restore attempts fail honestly with the lease armed" { + seed_legacy_wifi_disable_transaction + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":false,"associated":false}' + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +[ "$1:$2" != request:enable ] +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -ne 0 ] + [ "$(grep -Fc 'network request enable' "$TRANSACTION_LOG")" -eq 4 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rollback_failed ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = armed ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] +} + +@test "boot recovery restores an interrupted Wi-Fi mutation and removes only its empty stale lock" { + seed_legacy_wifi_disable_transaction + mkdir "$TX_ROOT/.lock" + + run "$SERVICE" boot-recover + [ "$status" -eq 0 ] + [[ "$output" == *'"recovery":"restored"'* ]] + [ ! -e "$TX_ROOT/.lock" ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .failure_reason "$TX_ROOT/state.json")" = boot_recovery ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + grep -F 'network request enable' "$TRANSACTION_LOG" +} + +@test "boot recovery is a no-op without an armed transaction and rejects unsafe stale locks" { + run "$SERVICE" boot-recover + [ "$status" -eq 0 ] + [[ "$output" == *'"recovery":"not_required"'* ]] + + seed_legacy_wifi_disable_transaction + mkdir "$TX_ROOT/.lock" + : >"$TX_ROOT/.lock/owner" + run "$SERVICE" boot-recover + [ "$status" -ne 0 ] + [[ "$output" == *'boot recovery lock is not empty'* ]] + [ -f "$TX_ROOT/.lock/owner" ] +} + +@test "runtime performs transaction recovery after settings load and before ordinary networking" { + runtime=/workspace/static/build/.tmp_update/runtime.sh + init_line="$(grep -n '^ init_system$' "$runtime" | cut -d: -f1)" + recovery_line="$(grep -n '^ recover_network_transaction || :$' "$runtime" | cut -d: -f1)" + networking_line="$(grep -n '^ start_networking$' "$runtime" | cut -d: -f1)" + + [ "$recovery_line" -gt "$init_line" ] + [ "$recovery_line" -lt "$networking_line" ] +} + +@test "runtime persists only a restored boot transaction to the device profile" { + runtime=/workspace/static/build/.tmp_update/runtime.sh + sysdir="$BATS_TEST_TMPDIR/system" + calls="$BATS_TEST_TMPDIR/calls" + mkdir -p "$sysdir/bin" "$sysdir/logs" + cat >"$sysdir/bin/bloom-network-transaction" <<'EOF' +#!/bin/sh +case "${RECOVERY_RESULT:-not_required}" in + restored) printf '%s\n' '{"schema":1,"recovery":"restored"}' ;; + not_required) printf '%s\n' '{"schema":1,"recovery":"not_required"}' ;; + failed) printf '%s\n' 'recovery failed'; exit 1 ;; +esac +EOF + chmod +x "$sysdir/bin/bloom-network-transaction" + save_settings() { + printf '%s\n' save >> "$calls" + [ "${SAVE_SETTINGS_FAIL:-0}" != 1 ] + } + log() { printf 'log:%s\n' "$*" >> "$calls"; } + eval "$(sed -n '/^recover_network_transaction() {/,/^}/p' "$runtime")" + + export RECOVERY_RESULT=restored + recover_network_transaction + [ "$(grep -Fc save "$calls")" -eq 1 ] + + export RECOVERY_RESULT=not_required + recover_network_transaction + [ "$(grep -Fc save "$calls")" -eq 1 ] + + export RECOVERY_RESULT=restored SAVE_SETTINGS_FAIL=1 + ! recover_network_transaction + grep -F 'recovery profile persistence failed' "$calls" + + export RECOVERY_RESULT=failed SAVE_SETTINGS_FAIL=0 + ! recover_network_transaction + grep -F 'boot recovery failed' "$calls" +} + @test "mutation failure automatically rolls back before returning failure" { begin_transaction - run env NETWORK_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef wifi-disable + run env SERVICES_FAIL_REQUEST=1 "$SERVICE" apply 0123456789abcdef0123456789abcdef ssh-disable [ "$status" -ne 0 ] run "$SERVICE" status [[ "$output" == *'"phase":"rolled_back"'* ]] @@ -259,6 +447,51 @@ EOF grep -F 'services request ssh enable' "$TRANSACTION_LOG" } +@test "watchdog closes an untouched transaction without replaying the network baseline" { + begin_transaction + printf '%s\n' 116 >"$TIME_FILE" + transaction_id="$(jq -r .transaction_id "$TX_ROOT/state.json")" + + run "$SERVICE" watchdog "$transaction_id" + + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .failure_reason "$TX_ROOT/state.json")" = confirmation_timeout ] + [ "$(jq -r .recovery_lease "$TX_ROOT/state.json")" = inactive ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + ! grep -Fq 'network request' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" +} + +@test "untouched watchdog retains bounded restoration when the saved baseline drifted" { + begin_transaction + status_count="$BATS_TEST_TMPDIR/untouched-status-count" + printf '%s\n' 0 >"$status_count" + export UNTOUCHED_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$UNTOUCHED_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$UNTOUCHED_STATUS_COUNT" + if [ "$count" -eq 1 ]; then associated=false; else associated=true; fi + printf '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":%s}\n' "$associated" + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + chmod +x "$BLOOM_NETWORK_BIN" + printf '%s\n' 116 >"$TIME_FILE" + transaction_id="$(jq -r .transaction_id "$TX_ROOT/state.json")" + + run "$SERVICE" watchdog "$transaction_id" + + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + grep -F 'network request enable' "$TRANSACTION_LOG" + grep -F 'services reconcile' "$TRANSACTION_LOG" +} + @test "active update session conflicts and unsafe state fail closed" { run env UPDATE_PHASE=testing "$SERVICE" begin 15 [ "$status" -ne 0 ] @@ -293,3 +526,90 @@ EOF ! grep -Fq 'services request' "$TRANSACTION_LOG" ! grep -Fq 'network request' "$TRANSACTION_LOG" } + +@test "network overlay rollback waits for bounded DHCP convergence before replaying prior state" { + begin_transaction + run "$SERVICE" apply 0123456789abcdef0123456789abcdef network-overlay network-test + [ "$status" -eq 0 ] + status_count="$BATS_TEST_TMPDIR/network-status-count" + printf '%s\n' 0 >"$status_count" + export NETWORK_STATUS_COUNT="$status_count" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +if [ "$1" = status ]; then + count=$(cat "$NETWORK_STATUS_COUNT") + count=$((count + 1)) + printf '%s\n' "$count" >"$NETWORK_STATUS_COUNT" + if [ "$count" -lt 3 ]; then + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' + else + printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":true}' + fi + exit +fi +printf 'network %s %s\n' "$1" "$2" >>"$TRANSACTION_LOG" +EOF + sleep_mock="$BATS_TEST_TMPDIR/sleep-fast" + printf '%s\n' '#!/bin/sh' 'exit 0' >"$sleep_mock" + chmod +x "$BLOOM_NETWORK_BIN" "$sleep_mock" + + run env BLOOM_TRANSACTION_SLEEP_BIN="$sleep_mock" "$SERVICE" rollback + [ "$status" -eq 0 ] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(cat "$status_count")" -ge 3 ] + grep -F 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" +} + +@test "self-restored network overlay closes rollback without replaying prior services" { + begin_transaction + run "$SERVICE" apply 0123456789abcdef0123456789abcdef network-overlay network-test + [ "$status" -eq 0 ] + + run env DEV_OVERLAY_ACTIVE=0 SERVICES_FAIL_REQUEST=1 "$SERVICE" rollback + [ "$status" -eq 0 ] + run "$SERVICE" status + [[ "$output" == *'"phase":"rolled_back"'* ]] + [[ "$output" == *'"failure_reason":"explicit"'* ]] + ! grep -Fq 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" +} + +@test "failed rollback reconciles only after the exact baseline returns" { + begin_transaction + jq '.phase="rollback_failed"|.operation="network-overlay"|.network_overlay="network-test"|.failure_reason="restore_failed"|.recovery_lease="inactive"' \ + "$TX_ROOT/state.json" >"$TX_ROOT/state.tmp" + mv "$TX_ROOT/state.tmp" "$TX_ROOT/state.json" + + run env DEV_OVERLAY_ACTIVE=0 SERVICES_FAIL_REQUEST=1 "$SERVICE" rollback + [ "$status" -eq 0 ] + [[ "$output" == *'"reconciled":true'* ]] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rolled_back ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = inactive ] + [ "$(jq -r .phase "$TX_ROOT/last.json")" = rollback_failed ] + [ "$(jq -r .failure_reason "$TX_ROOT/last.json")" = restore_failed ] + ! grep -Fq 'dev-deploy rollback' "$TRANSACTION_LOG" + ! grep -Fq 'services request' "$TRANSACTION_LOG" + ! grep -Fq 'network request' "$TRANSACTION_LOG" +} + +@test "failed rollback reconciliation refuses a changed baseline" { + begin_transaction + jq '.phase="rollback_failed"|.operation="network-overlay"|.network_overlay="network-test"|.failure_reason="restore_failed"|.recovery_lease="inactive"' \ + "$TX_ROOT/state.json" >"$TX_ROOT/state.tmp" + mv "$TX_ROOT/state.tmp" "$TX_ROOT/state.json" + cat >"$BLOOM_NETWORK_BIN" <<'EOF' +#!/bin/sh +printf '%s\n' '{"schema":1,"wifi_capable":true,"wifi_enabled":true,"associated":false}' +EOF + chmod +x "$BLOOM_NETWORK_BIN" + + run "$SERVICE" rollback + [ "$status" -ne 0 ] + [[ "$output" == *'baseline is not restored'* ]] + [ "$(jq -r .phase "$TX_ROOT/state.json")" = rollback_failed ] + [ "$(jq -r .state "$TX_ROOT/recovery-lease.json")" = armed ] + [ ! -e "$TX_ROOT/last.json" ] +} diff --git a/test/shell/transfer_policy.bats b/test/shell/transfer_policy.bats new file mode 100755 index 000000000..42f7528eb --- /dev/null +++ b/test/shell/transfer_policy.bats @@ -0,0 +1,139 @@ +#!/usr/bin/env bats + +load 'support/test_helper' + +setup() { + setup_bloom_fixture + export POLICY_HELPER=/workspace/static/build/.tmp_update/bin/bloom-transfer-policy + export BLOOM_ROOT="$BLOOM_TEST_ROOT" + export BLOOM_TRANSFER_POLICY=/workspace/build/transfer-policy.json + export BLOOM_JQ_BIN="$(command -v jq)" + export BLOOM_TRANSFER_STAGING_ROOT="$SDCARD/.bloom/transfer-staging" + mkdir -p "$SDCARD/Roms/GBA" "$SDCARD/BIOS" "$SDCARD/Imgs" \ + "$SDCARD/Screenshots" "$SDCARD/Themes" "$SDCARD/Saves" \ + "$BLOOM_TRANSFER_STAGING_ROOT" +} + +teardown() { + teardown_bloom_fixture +} + +@test "canonical policy has five read-write roots, read-only Saves, and private denials" { + run python3 /workspace/tools/validate_transfer_policy.py \ + --policy /workspace/build/transfer-policy.json \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -eq 0 ] + [ "$output" = "transfer policy valid: 6 roots, 5 read-write, 1 read-only" ] + + run "$POLICY_HELPER" status + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e ' + .state == "ready" and .roots == 6 and .read_write_roots == 5 and + .read_only_roots == 1 and .publication == "same_filesystem_atomic_replace" + ' + + run "$POLICY_HELPER" mounts + [ "$status" -eq 0 ] + [ "$output" = $'Roms\tRoms\tread_write\nBIOS\tBIOS\tread_write\nArtwork\tImgs\tread_write\nScreenshots\tScreenshots\tread_write\nThemes\tThemes\tread_write\nSaves\tSaves\tread_only' ] + + run "$POLICY_HELPER" limits + [ "$status" -eq 0 ] + [ "$output" = $'1024\t32\t255\t4294967295' ] +} + +@test "authorization rejects private roots, traversal, symlinks, and writes to Saves" { + run "$POLICY_HELPER" authorize write /Roms/GBA/game.gba + [ "$status" -eq 0 ] + printf '%s\n' "$output" | jq -e '.authorized and .root == "roms" and .access == "read_write"' + + run "$POLICY_HELPER" authorize read /Saves/CurrentProfile/save.srm + [ "$status" -eq 0 ] + run "$POLICY_HELPER" authorize write /Saves/CurrentProfile/save.srm + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /.bloom/authorized_keys + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /Roms/../.bloom/authorized_keys + [ "$status" -ne 0 ] + run "$POLICY_HELPER" authorize read /Roms//GBA/game.gba + [ "$status" -ne 0 ] + + ln -s "$SDCARD/BIOS" "$SDCARD/Roms/linked" + run "$POLICY_HELPER" authorize read /Roms/linked/private.bin + [ "$status" -ne 0 ] +} + +@test "publish copies into the destination filesystem and atomically replaces the target" { + printf 'old-bytes\n' >"$SDCARD/Roms/GBA/game.gba" + staged="$BLOOM_TRANSFER_STAGING_ROOT/upload-1" + printf 'new-complete-bytes\n' >"$staged" + + run "$POLICY_HELPER" publish /Roms/GBA/game.gba "$staged" + [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 + printf '%s\n' "$output" | jq -e '.published and .publication == "same_filesystem_atomic_replace" and .root == "roms"' + [ "$(cat "$SDCARD/Roms/GBA/game.gba")" = "new-complete-bytes" ] + [ ! -e "$staged" ] + [ -z "$(find "$SDCARD/Roms/GBA" -name '.bloom-replace-*' -print)" ] + + printf 'blocked\n' >"$BLOOM_TRANSFER_STAGING_ROOT/upload-2" + run "$POLICY_HELPER" publish /Saves/slot.srm "$BLOOM_TRANSFER_STAGING_ROOT/upload-2" + [ "$status" -ne 0 ] + [ ! -e "$SDCARD/Saves/slot.srm" ] + [ -f "$BLOOM_TRANSFER_STAGING_ROOT/upload-2" ] +} + +@test "validator rejects altered access and newly exposed roots" { + candidate="$BATS_TEST_TMPDIR/policy.json" + jq '.roots |= map(if .id == "saves" then .access = "read_write" else . end)' \ + /workspace/build/transfer-policy.json >"$candidate" + run python3 /workspace/tools/validate_transfer_policy.py --policy "$candidate" \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -ne 0 ] + + jq '.roots += [{"id":"private","virtual_path":"/Private","device_path":".bloom","access":"read_write"}]' \ + /workspace/build/transfer-policy.json >"$candidate" + run python3 /workspace/tools/validate_transfer_policy.py --policy "$candidate" \ + --schema /workspace/build/transfer-policy.schema.json + [ "$status" -ne 0 ] +} + +@test "Warm deployment requires the complete network authority bundle" { + payload="$BATS_TEST_TMPDIR/payload" + manifest="$BATS_TEST_TMPDIR/manifest.json" + for path in \ + bin/bloom-settings \ + bin/bloom-network \ + bin/bloom-network-services \ + bin/bloom-network-transaction \ + bin/bloom-platform \ + bin/bloom-dev-ssh \ + bin/bloom-sftp-gate \ + bin/bloom-sftp-server \ + bin/bloom-transfer-policy \ + bin/bloom-remote-keys \ + bin/bloomctl \ + bin/bloom-listener-audit \ + bin/bloom-wifi \ + config/transfer-policy.json \ + runtime.sh \ + script/network/update_networking.sh \ + script/network/wpsclient.sh; do + mkdir -p "$payload/$(dirname "$path")" + if [ "$path" = config/transfer-policy.json ]; then + cp /workspace/build/transfer-policy.json "$payload/$path" + else + printf '#!/bin/sh\nexit 0\n' >"$payload/$path" + chmod 0755 "$payload/$path" + fi + done + + run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ + net02-fixture 0123456789abcdef0123456789abcdef01234567 "$manifest" + [ "$status" -eq 0 ] || printf '%s\n' "$output" >&3 + [ "$(jq '.files | length' "$manifest")" -eq 17 ] + + rm "$payload/bin/bloom-platform" + run python3 /workspace/tools/dev-deploy-manifest.py network-helpers "$payload" \ + net02-incomplete 0123456789abcdef0123456789abcdef01234567 "$manifest" + [ "$status" -ne 0 ] + [[ "$output" == *"component bundle is incomplete"* ]] +} diff --git a/test/shell/wifi_backend.bats b/test/shell/wifi_backend.bats index 34ef8683c..dca55bc5e 100644 --- a/test/shell/wifi_backend.bats +++ b/test/shell/wifi_backend.bats @@ -12,8 +12,15 @@ setup() { export BLOOM_WIFI_UDHCPC_BIN="$BATS_TEST_TMPDIR/udhcpc" export BLOOM_WIFI_UDHCPC_SCRIPT="$BATS_TEST_TMPDIR/udhcpc.script" export BLOOM_WIFI_IW_BIN="$BATS_TEST_TMPDIR/iw" + export BLOOM_WIFI_IP_BIN="$BATS_TEST_TMPDIR/ip" export BLOOM_WIFI_PKILL_BIN="$BATS_TEST_TMPDIR/pkill" export BLOOM_WIFI_SLEEP_BIN="$BATS_TEST_TMPDIR/sleep" + export BLOOM_WIFI_START_SLEEP_BIN=/bin/sleep + export BLOOM_WIFI_SERVICES_BIN="$BATS_TEST_TMPDIR/network-services" + export BLOOM_WIFI_NOHUP_BIN=/usr/bin/nohup + export BLOOM_WIFI_SELF="$SERVICE" + export BLOOM_WIFI_WATCH_DIR="$BATS_TEST_TMPDIR/watcher" + export BLOOM_WIFI_PROC_ROOT=/proc export WIFI_LOG="$BATS_TEST_TMPDIR/wifi.log" mkdir -p "$BLOOM_WIFI_ROOT/sys/class/net/wlan0" printf down >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" @@ -22,30 +29,158 @@ setup() { printf '#!/bin/sh\nprintf 1\n' >"$BLOOM_WIFI_JSONVAL_BIN" : >"$BLOOM_WIFI_CONFIG" : >"$BLOOM_WIFI_UDHCPC_SCRIPT" - for name in axp_test ifconfig wpa_supplicant udhcpc iw pkill sleep; do + chmod +x "$BLOOM_WIFI_UDHCPC_SCRIPT" + for name in axp_test ifconfig wpa_supplicant udhcpc iw pkill; do cat >"$BATS_TEST_TMPDIR/$name" <<'SH' #!/bin/sh printf '%s %s\n' "$(basename "$0")" "$*" >>"$WIFI_LOG" SH chmod +x "$BATS_TEST_TMPDIR/$name" done + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +: >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" +SH + cat >"$BLOOM_WIFI_SERVICES_BIN" <<'SH' +#!/bin/sh +printf 'services %s\n' "$*" >>"$WIFI_LOG" +SH + cat >"$BLOOM_WIFI_IP_BIN" <<'SH' +#!/bin/sh +[ -e "$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" ] || exit 0 +printf '2: wlan0 inet 192.0.2.8/24 scope global wlan0\n' +SH + chmod +x "$BLOOM_WIFI_IP_BIN" "$BLOOM_WIFI_SLEEP_BIN" "$BLOOM_WIFI_SERVICES_BIN" chmod +x "$BLOOM_PLATFORM_BIN" "$BLOOM_WIFI_JSONVAL_BIN" } @test "associated Plus is preserved without invoking lifecycle commands" { printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + : >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/ipv4" run "$SERVICE" reconcile [ "$status" -eq 0 ] [ "$output" = '{"schema":1,"service":"bloom-wifi","supported":true,"applied":false,"state":"preserved"}' ] [ ! -s "$WIFI_LOG" ] } +@test "associated Plus without an address restarts canonical DHCP" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"dhcp_restarted"'* ]] + grep -Fx 'pkill -TERM udhcpc' "$WIFI_LOG" + grep -Fx 'sleep 1' "$WIFI_LOG" + grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" + ! grep -Fx 'axp_test wifion' "$WIFI_LOG" + for unused in 1 2 3 4 5; do + grep -Fx 'services reconcile' "$WIFI_LOG" >/dev/null && break + sleep 0.1 + done + grep -Fx 'services reconcile' "$WIFI_LOG" + for unused in 1 2 3 4 5 6 7 8 9 10; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] +} + +@test "repeated address recovery reuses one bounded detached watcher" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ "$(cat "$BLOOM_WIFI_WATCH_DIR/pid")" = "$watcher_pid" ] + kill -TERM "$watcher_pid" + for unused in 1 2 3 4 5 6 7 8 9 10; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] +} + +@test "address recovery retries only the fixed DHCP client twice before its bounded deadline" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +/bin/sleep 0.01 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"dhcp_restarted"'* ]] + for unused in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] && break + sleep 0.05 + done + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + [ "$(grep -Fc "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG")" -eq 3 ] + [ "$(grep -Fc 'pkill -TERM udhcpc' "$WIFI_LOG")" -eq 3 ] + ! grep -Fx 'services reconcile' "$WIFI_LOG" +} + +@test "DHCP recovery fails closed when the detached watcher cannot start" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + export BLOOM_WIFI_NOHUP_BIN="$BATS_TEST_TMPDIR/nohup" + printf '#!/bin/sh\nexit 1\n' >"$BLOOM_WIFI_NOHUP_BIN" + chmod +x "$BLOOM_WIFI_NOHUP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 1 ] + [[ "$output" == *'"state":"dhcp_restart_failed"'* ]] + grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" + ! grep -Fx 'services reconcile' "$WIFI_LOG" + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] +} + +@test "a watcher lock without an owner is recovered on the next request" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + mkdir "$BLOOM_WIFI_WATCH_DIR" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + for unused in 1 2 3 4 5; do + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] && break + sleep 0.05 + done + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + kill -TERM "$watcher_pid" + wait "$watcher_pid" 2>/dev/null || true +} + @test "enabled disconnected Plus starts the fixed lifecycle" { run "$SERVICE" reconcile [ "$status" -eq 0 ] [[ "$output" == *'"state":"started"'* ]] grep -Fx 'axp_test wifion' "$WIFI_LOG" + grep -Fx 'pkill -TERM wpa_supplicant' "$WIFI_LOG" grep -Fx 'ifconfig wlan0 up' "$WIFI_LOG" grep -Fx "wpa_supplicant -B -D nl80211 -iwlan0 -c $BLOOM_WIFI_CONFIG" "$WIFI_LOG" grep -Fx "udhcpc -i wlan0 -s $BLOOM_WIFI_UDHCPC_SCRIPT" "$WIFI_LOG" @@ -63,6 +198,54 @@ SH grep -Fx 'axp_test wifioff' "$WIFI_LOG" } +@test "disabling Wi-Fi stops an in-flight address recovery watcher before radio teardown" { + printf up >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/operstate" + printf 1 >"$BLOOM_WIFI_ROOT/sys/class/net/wlan0/carrier" + cat >"$BLOOM_WIFI_SLEEP_BIN" <<'SH' +#!/bin/sh +printf 'sleep %s\n' "$*" >>"$WIFI_LOG" +/bin/sleep 0.2 +SH + chmod +x "$BLOOM_WIFI_SLEEP_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [ -f "$BLOOM_WIFI_WATCH_DIR/pid" ] + watcher_pid=$(cat "$BLOOM_WIFI_WATCH_DIR/pid") + kill -0 "$watcher_pid" + + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_WIFI_JSONVAL_BIN" + chmod +x "$BLOOM_WIFI_JSONVAL_BIN" + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"stopped"'* ]] + ! kill -0 "$watcher_pid" 2>/dev/null + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + grep -Fx 'pkill -TERM wpa_supplicant' "$WIFI_LOG" + grep -Fx 'pkill -TERM udhcpc' "$WIFI_LOG" + grep -Fx 'axp_test wifioff' "$WIFI_LOG" +} + +@test "disabling Wi-Fi force-stops only a verified watcher that ignores TERM" { + mkdir "$BLOOM_WIFI_WATCH_DIR" + sh -c 'trap "" TERM; while :; do sleep 1; done' "$SERVICE watch" & + watcher_pid=$! + printf '%s\n' "$watcher_pid" >"$BLOOM_WIFI_WATCH_DIR/pid" + export BLOOM_WIFI_SELF="$SERVICE" + export BLOOM_WIFI_PROC_ROOT="$BATS_TEST_TMPDIR/proc" + mkdir -p "$BLOOM_WIFI_PROC_ROOT/$watcher_pid" + printf '%s\000%s\000' "$SERVICE" watch >"$BLOOM_WIFI_PROC_ROOT/$watcher_pid/cmdline" + printf '#!/bin/sh\nprintf 0\n' >"$BLOOM_WIFI_JSONVAL_BIN" + chmod +x "$BLOOM_WIFI_JSONVAL_BIN" + + run "$SERVICE" reconcile + [ "$status" -eq 0 ] + [[ "$output" == *'"state":"stopped"'* ]] + ! kill -0 "$watcher_pid" 2>/dev/null + [ ! -e "$BLOOM_WIFI_WATCH_DIR" ] + grep -Fx 'axp_test wifioff' "$WIFI_LOG" +} + @test "unsupported model requests compatibility fallback without mutations" { printf '#!/bin/sh\nprintf mini_flip\n' >"$BLOOM_PLATFORM_BIN" chmod +x "$BLOOM_PLATFORM_BIN" diff --git a/test/test_bloom_settings.cpp b/test/test_bloom_settings.cpp index c8995952b..453576519 100644 --- a/test/test_bloom_settings.cpp +++ b/test/test_bloom_settings.cpp @@ -945,6 +945,23 @@ TEST_F(BloomSettingsTest, MaterializationFailureKeepsCanonicalMutationAuthoritat std::string outside_content((std::istreambuf_iterator(outside_stream)), std::istreambuf_iterator()); EXPECT_EQ("outside", outside_content); + + std::filesystem::remove(config / "keymap.json"); + BloomSettingsMutationResult retry{}; + ASSERT_EQ(0, bloom_settings_set(settings.c_str(), system.c_str(), config.c_str(), + "device.volume", "18", &retry, error, sizeof(error))) + << error; + EXPECT_EQ(0, retry.changed); + EXPECT_EQ(result.generation, retry.generation); + EXPECT_EQ(1, retry.materialized); + std::ifstream materialized_stream(system, std::ios::binary); + std::string materialized_content((std::istreambuf_iterator(materialized_stream)), + std::istreambuf_iterator()); + cJSON *materialized_system = + cJSON_ParseWithLength(materialized_content.c_str(), materialized_content.size()); + ASSERT_TRUE(cJSON_IsObject(materialized_system)); + EXPECT_EQ(18, cJSON_GetObjectItem(materialized_system, "vol")->valueint); + cJSON_Delete(materialized_system); } TEST_F(BloomSettingsTest, ResetDefaultsBacksUpCanonicalStateAndMaterializesDefaults) diff --git a/test/test_bloom_shell_settings.cpp b/test/test_bloom_shell_settings.cpp index 1d1620d5f..41ec1f445 100644 --- a/test/test_bloom_shell_settings.cpp +++ b/test/test_bloom_shell_settings.cpp @@ -294,6 +294,44 @@ TEST(BloomShellSettings, RejectsMalformedOrOutOfRangeValues) EXPECT_FALSE(values.ready); } +TEST(BloomShellSettings, NetworkStatusDistinguishesDesiredWifiFromConnectivity) +{ + BloomShellCapabilities capabilities{}; + BloomShellQuickValues values{}; + ASSERT_EQ(0, bloom_shell_capabilities_from_model(354, 0, &capabilities)); + values.ready = 1; + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":false,"state":"acquiring_address"})", + &values)); + EXPECT_TRUE(values.wifi_enabled); + EXPECT_TRUE(values.wifi_status_ready); + EXPECT_FALSE(values.wifi_associated); + char label[64]; + ASSERT_EQ(0, bloom_shell_settings_row_format(&capabilities, &values, 10, label, + sizeof(label))); + EXPECT_NE(std::string::npos, std::string(label).find("On / No connection")); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: On / No connection", label); + + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":true,"state":"associated"})", + &values)); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: On / Connected", label); + + ASSERT_EQ(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":false,"associated":false,"state":"wifi_disabled"})", + &values)); + ASSERT_EQ(0, bloom_shell_quick_settings_format(&capabilities, &values, 2, label, + sizeof(label))); + EXPECT_STREQ("Wi-Fi: Off", label); + EXPECT_NE(0, bloom_shell_network_status_parse( + R"({"schema":1,"service":"bloom-network","available":true,"wifi_capable":true,"wifi_enabled":true,"associated":false,"state":"associated"})", + &values)); +} + TEST(BloomShellSettings, UnavailableValuesAreExplicit) { BloomShellCapabilities capabilities{}; @@ -361,6 +399,49 @@ TEST(BloomShellSettings, SuccessfulFixedRequestUpdatesValueAndFailureDoesNot) std::filesystem::remove(arguments); } +TEST(BloomShellSettings, FailedWifiApplyReloadsPersistedPreference) +{ + const auto directory = std::filesystem::temp_directory_path() / + ("bloom-shell-wifi-failure-" + std::to_string(getpid())); + std::filesystem::create_directory(directory); + const auto script = directory / "bloom-network"; + { + std::ofstream output(script); + output << "#!/bin/sh\n" + "case \"$*\" in\n" + " 'request disable') exit 1 ;;\n" + " status) printf '%s\\n' '{\"schema\":1,\"service\":\"bloom-network\"," + "\"available\":true,\"wifi_capable\":true," + "\"wifi_enabled\":false,\"associated\":false," + "\"state\":\"wifi_disabled\"}' ;;\n" + " *) exit 2 ;;\n" + "esac\n"; + } + ASSERT_EQ(0, chmod(script.c_str(), 0700)); + BloomShellCapabilities capabilities{}; + ASSERT_EQ(0, bloom_shell_capabilities_from_model(354, 0, &capabilities)); + BloomShellQuickValues values{}; + values.ready = 1; + values.wifi_enabled = 1; + EXPECT_NE(0, bloom_shell_quick_settings_adjust(&capabilities, &values, 2, -1, + script.c_str(), script.c_str())); + EXPECT_FALSE(values.wifi_enabled); + EXPECT_TRUE(values.wifi_status_ready); + std::filesystem::remove_all(directory); +} + +TEST(BloomShellSettings, FailedNetworkStatusLoadClearsStaleConnectivity) +{ + BloomShellQuickValues values{}; + values.wifi_enabled = 1; + values.wifi_status_ready = 1; + values.wifi_associated = 1; + EXPECT_NE(0, bloom_shell_network_status_load("/missing/bloom-network", &values)); + EXPECT_TRUE(values.wifi_enabled); + EXPECT_FALSE(values.wifi_status_ready); + EXPECT_FALSE(values.wifi_associated); +} + TEST(BloomShellSettings, ThemeVariantCyclesThroughCanonicalFixedRequests) { const auto directory = std::filesystem::temp_directory_path() / diff --git a/test/test_ci_scope.sh b/test/test_ci_scope.sh index 344b8c00d..8b66fc959 100755 --- a/test/test_ci_scope.sh +++ b/test/test_ci_scope.sh @@ -121,6 +121,7 @@ tools/validate_save_migration_policy.py" expect "/workspace/test/shell/bloom_device.bats /workspace/test/shell/dev_deploy.bats" shell-tests "test/shell/bloom_device.bats test/shell/dev_deploy.bats" expect "full" shell-tests "static/build/.tmp_update/bin/bloom-dev-deploy" +expect "full" shell-tests "tools/run-shell-tests.sh" expect "none" shell-tests "docs/DEVELOPMENT.md" expect "3" risk "docs/DEVELOPMENT.md static/build/.tmp_update/runtime.sh" @@ -129,10 +130,24 @@ grep -F 'git config --global --add safe.directory "$GITHUB_WORKSPACE"' \ "$root/.github/workflows/build.yml" >/dev/null grep -F 'tools/checkout-submodules.sh third-party/libxml2 third-party/rcheevos third-party/zlib' \ "$root/.github/workflows/test.yml" >/dev/null -grep -F 'GTEST_INCLUDE_DIR=/usr/include make test' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'GTEST_INCLUDE_DIR=/usr/include make -j"$(nproc)" test' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'make static-analysis CPPCHECK_JOBS="$(nproc)"' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'workflow_dispatch:' "$root/.github/workflows/test.yml" >/dev/null +grep -F "if [ '\${{ github.event_name }}' != 'pull_request' ]; then" "$root/.github/workflows/test.yml" >/dev/null +if [ "$(grep -Fc 'CPPCHECK_CONFIG_FLAGS=--max-configs=1' "$root/.github/workflows/test.yml")" -ne 2 ]; then + echo "full-source cppcheck must bound configuration expansion for PR and integration runs" >&2 + exit 1 +fi +grep -F 'CPPCHECK_CONFIG_FLAGS="--max-configs=1 --suppress=unusedFunction"' "$root/.github/workflows/test.yml" >/dev/null +grep -F 'cppcheck --quiet --cppcheck-build-dir=$(CPPCHECK_BUILD_DIR) -j $(CPPCHECK_JOBS)' "$root/Makefile" >/dev/null +if grep -E '^static-analysis:.*external-libs' "$root/Makefile" >/dev/null; then + echo "static-analysis must not build unrelated external libraries" >&2 + exit 1 +fi grep -F "sh tools/ci-scope.sh native-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh visual-tests" "$root/.github/workflows/test.yml" >/dev/null grep -F "sh tools/ci-scope.sh shell-tests" "$root/.github/workflows/shell-test.yml" >/dev/null +grep -F './tools/run-shell-tests.sh "$PWD"' "$root/.github/workflows/shell-test.yml" >/dev/null grep -F "merge_group:" "$root/.github/workflows/test.yml" >/dev/null grep -F 'GTEST_FILTER="$filters"' "$root/.github/workflows/test.yml" >/dev/null grep -F 'GITHUB_STEP_SUMMARY' "$root/.github/workflows/build.yml" >/dev/null diff --git a/tools/bloom-device b/tools/bloom-device index 19225e66a..26e42dcff 100755 --- a/tools/bloom-device +++ b/tools/bloom-device @@ -15,7 +15,7 @@ Usage: bloom-device [output-directory] bloom-device dev-evidence [output-directory] bloom-device network- bloom-device network-begin [rollback-seconds] - bloom-device network-apply [deployment-id] + bloom-device network-apply [deployment-id] bloom-device network-confirm Targets are read from tools/targets.toml or BLOOM_TARGETS_FILE. Copy @@ -332,7 +332,10 @@ command_network_apply() { local nonce="$1" operation="$2" deployment_id="${3:-}" validate_network_nonce "$nonce" case "$operation" in - wifi-enable|wifi-disable|ssh-enable|ssh-disable|ssh-shadow) + wifi-disable) + die "remote Wi-Fi disable is not device-qualified" + ;; + wifi-enable|ssh-enable|ssh-disable|ssh-shadow) [[ -z "$deployment_id" ]] || die "$operation does not accept a deployment id" ;; network-overlay) diff --git a/tools/build-sftp-server.sh b/tools/build-sftp-server.sh index 454ce1342..6c131927f 100755 --- a/tools/build-sftp-server.sh +++ b/tools/build-sftp-server.sh @@ -22,6 +22,7 @@ cleanup() { trap cleanup EXIT git -C "$SOURCE_REPO" archive HEAD | tar -x -C "$SOURCE_DIR" patch -d "$SOURCE_DIR" -p1 <"$ROOT_DIR/tools/patches/openssh-sftp-static-user.patch" +patch -d "$SOURCE_DIR" -p1 <"$ROOT_DIR/tools/patches/openssh-sftp-atomic-replace.patch" touch "$SOURCE_DIR/configure" TOOL_PREFIX="${CROSS_COMPILE:-arm-linux-gnueabihf-}" diff --git a/tools/ci-scope.sh b/tools/ci-scope.sh index 6d181095c..5eb640c9f 100755 --- a/tools/ci-scope.sh +++ b/tools/ci-scope.sh @@ -172,8 +172,9 @@ while IFS= read -r path; do build/dependencies.lock|build/legacy-manifest.json|build/core-selection-policy*.json|\ build/core-inventory-disposition*.json|\ build/save-migration-policy*.json|\ + build/transfer-policy*.json|\ static/*|test/*|docs/*|tools/legacy_manifest.py|\ - tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/ci-scope.sh) + tools/validate_core_selection_policy.py|tools/validate_core_inventory_disposition.py|tools/validate_save_migration_policy.py|tools/validate_transfer_policy.py|tools/ci-scope.sh) ;; *) full=1 @@ -203,6 +204,20 @@ while IFS= read -r path; do add_shell_target /workspace/test/shell/save_migration_policy.bats add_shell_target /workspace/test/shell/save_migration.bats ;; + build/transfer-policy*.json|tools/validate_transfer_policy.py|\ + static/build/.tmp_update/bin/bloom-transfer-policy) + raise_risk 2 + add_shell_target /workspace/test/shell/transfer_policy.bats + add_shell_target /workspace/test/shell/developer_ssh.bats + ;; + tools/build-sftp-server.sh|tools/patches/openssh-sftp-*.patch|\ + static/build/.tmp_update/bin/bloom-dev-ssh|\ + static/build/.tmp_update/bin/bloom-sftp-gate) + raise_risk 3 + add_shell_target /workspace/test/shell/transfer_policy.bats + add_shell_target /workspace/test/shell/developer_ssh.bats + add_shell_target /workspace/test/shell/network_services.bats + ;; test/test_bloom_ui*.cpp) raise_risk 1 add_native_filter 'BloomUi*' diff --git a/tools/dev-deploy-manifest.py b/tools/dev-deploy-manifest.py index a09b8ccf2..f87843ae6 100755 --- a/tools/dev-deploy-manifest.py +++ b/tools/dev-deploy-manifest.py @@ -47,10 +47,14 @@ "bin/bloom-platform", "bin/bloom-dev-ssh", "bin/bloom-sftp-gate", + "bin/bloom-sftp-server", + "bin/bloom-transfer-policy", "bin/bloom-remote-keys", "bin/bloomctl", "bin/bloom-listener-audit", "bin/bloom-wifi", + "config/transfer-policy.json", + "runtime.sh", "script/network/update_networking.sh", "script/network/wpsclient.sh", }, @@ -66,9 +70,13 @@ # Shell, input, GameSwitcher, RA, and session state machines. "launch-session": EXACT_PATHS["launch-session"], "save-migration": EXACT_PATHS["save-migration"], + # Network ownership spans settings, platform capability discovery, Wi-Fi, + # listener/service reconciliation, transfer policy, and the rollback + # transaction. A partial overlay can pair incompatible schema generations + # and falsely report a healthy radio as unavailable. + "network-helpers": EXACT_PATHS["network-helpers"], } - def allowed(component, path): if component == "ui-assets": return path.startswith("res/bloom/") and len(path) > len("res/bloom/") diff --git a/tools/patches/openssh-sftp-atomic-replace.patch b/tools/patches/openssh-sftp-atomic-replace.patch new file mode 100644 index 000000000..6b921d881 --- /dev/null +++ b/tools/patches/openssh-sftp-atomic-replace.patch @@ -0,0 +1,574 @@ +diff --git a/sftp-server.c b/sftp-server.c +index a4abb9f..5c25948 +--- a/sftp-server.c ++++ b/sftp-server.c +@@ -313,6 +313,8 @@ struct Handle { + int fd; + int flags; + char *name; ++ char *atomic_name; ++ int atomic_ready; + u_int64_t bytes_read, bytes_write; + int next_unused; + }; +@@ -355,6 +357,8 @@ handle_new(int use, const char *name, int fd, int flags, DIR *dirp) + handles[i].fd = fd; + handles[i].flags = flags; + handles[i].name = xstrdup(name); ++ handles[i].atomic_name = NULL; ++ handles[i].atomic_ready = 0; + handles[i].bytes_read = handles[i].bytes_write = 0; + + return i; +@@ -424,6 +428,25 @@ handle_to_flags(int handle) + return 0; + } + ++static int ++handle_set_atomic_name(int handle, char *atomic_name) ++{ ++ if (!handle_is_ok(handle, HANDLE_FILE) || atomic_name == NULL) ++ return -1; ++ handles[handle].atomic_name = atomic_name; ++ return 0; ++} ++ ++static int ++handle_set_atomic_ready(int handle) ++{ ++ if (!handle_is_ok(handle, HANDLE_FILE) || ++ handles[handle].atomic_name == NULL) ++ return -1; ++ handles[handle].atomic_ready = 1; ++ return 0; ++} ++ + static void + handle_update_read(int handle, ssize_t bytes) + { +@@ -457,10 +480,36 @@ handle_bytes_write(int handle) + static int + handle_close(int handle) + { +- int ret = -1; ++ int ret = -1, saved_errno = 0; + + if (handle_is_ok(handle, HANDLE_FILE)) { +- ret = close(handles[handle].fd); ++ if (handles[handle].atomic_name == NULL) { ++ ret = close(handles[handle].fd); ++ } else if (!handles[handle].atomic_ready) { ++ saved_errno = ECANCELED; ++ ret = -1; ++ close(handles[handle].fd); ++ unlink(handles[handle].atomic_name); ++ } else if (fsync(handles[handle].fd) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ close(handles[handle].fd); ++ unlink(handles[handle].atomic_name); ++ } else if (close(handles[handle].fd) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ unlink(handles[handle].atomic_name); ++ } else if (rename(handles[handle].atomic_name, ++ handles[handle].name) == -1) { ++ saved_errno = errno; ++ ret = -1; ++ unlink(handles[handle].atomic_name); ++ } else { ++ ret = 0; ++ } ++ if (ret == -1 && saved_errno != 0) ++ errno = saved_errno; ++ free(handles[handle].atomic_name); + free(handles[handle].name); + handle_unused(handle); + } else if (handle_is_ok(handle, HANDLE_DIR)) { +@@ -497,6 +546,11 @@ handle_log_exit(void) + for (i = 0; i < num_handles; i++) + if (handles[i].use != HANDLE_UNUSED) + handle_log_close(i, "forced"); ++ for (i = 0; i < num_handles; i++) { ++ if (handles[i].use == HANDLE_FILE && ++ handles[i].atomic_name != NULL) ++ unlink(handles[i].atomic_name); ++ } + } + + static int +@@ -738,12 +792,218 @@ process_init(void) + sshbuf_free(msg); + } + ++static int ++bloom_allowed_root(const char *name, const char *environment, int allow_root) ++{ ++ const char *allowed, *end, *root_end; ++ const char *cursor; ++ ++ if (name == NULL || *name == '\0' || ++ (allowed = getenv(environment)) == NULL) ++ return 0; ++ cursor = name; ++ while (*cursor == '/') ++ cursor++; ++ if ((*cursor == '\0' || strcmp(cursor, ".") == 0) && allow_root) ++ return 1; ++ root_end = strchr(cursor, '/'); ++ if (root_end == NULL) ++ root_end = cursor + strlen(cursor); ++ if (root_end == cursor) ++ return 0; ++ while (*allowed != '\0') { ++ end = strchr(allowed, ':'); ++ if (end == NULL) ++ end = allowed + strlen(allowed); ++ if ((size_t)(end - allowed) == (size_t)(root_end - cursor) && ++ memcmp(allowed, cursor, end - allowed) == 0) ++ return 1; ++ allowed = *end == ':' ? end + 1 : end; ++ } ++ return 0; ++} ++ ++static int ++bloom_safe_public_path(const char *name, int allow_root) ++{ ++ const char *path_cursor; ++ const unsigned char *scan; ++ char *copy, *cursor, *slash; ++ struct stat st; ++ u_int components = 0; ++ ++ if (name == NULL || *name == '\0' || strlen(name) > 1024 || ++ strstr(name, "//") != NULL) ++ return 0; ++ if (!bloom_allowed_root(name, "BLOOM_TRANSFER_PUBLIC_ROOTS", allow_root)) ++ return 0; ++ if (strstr(name, ".bloom-sftp-") != NULL) ++ return 0; ++ for (scan = (const unsigned char *)name; *scan != '\0'; scan++) { ++ if (*scan < 0x20 || *scan == 0x7f || *scan == '\\') ++ return 0; ++ } ++ path_cursor = name; ++ while (*path_cursor == '/') ++ path_cursor++; ++ if ((*path_cursor == '\0' || strcmp(path_cursor, ".") == 0) && allow_root) ++ return 1; ++ copy = xstrdup(name); ++ cursor = copy; ++ while (*cursor == '/') ++ cursor++; ++ while ((slash = strchr(cursor, '/')) != NULL) { ++ *slash = '\0'; ++ if (*cursor == '\0' || strcmp(cursor, ".") == 0 || ++ strcmp(cursor, "..") == 0 || strlen(cursor) > 255) { ++ free(copy); ++ return 0; ++ } ++ if (strncmp(cursor, ".bloom", 6) == 0) { ++ free(copy); ++ return 0; ++ } ++ if (++components > 32) { ++ free(copy); ++ return 0; ++ } ++ *slash = '/'; ++ if (slash != copy && slash[1] != '\0') { ++ *slash = '\0'; ++ if (lstat(copy, &st) == -1 || !S_ISDIR(st.st_mode) || ++ S_ISLNK(st.st_mode)) { ++ free(copy); ++ return 0; ++ } ++ *slash = '/'; ++ } ++ cursor = slash + 1; ++ } ++ if (*cursor == '\0' || strcmp(cursor, ".") == 0 || ++ strcmp(cursor, "..") == 0 || strlen(cursor) > 255 || ++ ++components > 32) { ++ free(copy); ++ return 0; ++ } ++ if (strncmp(cursor, ".bloom", 6) == 0) { ++ free(copy); ++ return 0; ++ } ++ if (lstat(name, &st) == 0 && S_ISLNK(st.st_mode)) { ++ free(copy); ++ return 0; ++ } ++ free(copy); ++ return 1; ++} ++ ++static int ++bloom_safe_write_path(const char *name) ++{ ++ return bloom_allowed_root(name, "BLOOM_TRANSFER_WRITE_ROOTS", 0) && ++ bloom_safe_public_path(name, 0); ++} ++ ++static int ++bloom_write_within_limit(u_int64_t offset, size_t length) ++{ ++ const char *value = getenv("BLOOM_TRANSFER_MAX_UPLOAD_BYTES"); ++ char *end = NULL; ++ unsigned long long maximum; ++ ++ if (value == NULL || *value == '\0') ++ return 0; ++ errno = 0; ++ maximum = strtoull(value, &end, 10); ++ if (errno != 0 || end == value || *end != '\0') ++ return 0; ++ return offset <= maximum && length <= maximum - offset; ++} ++ ++static int ++bloom_copy_existing(int source, int destination) ++{ ++ char buffer[32768]; ++ ssize_t got, offset, written; ++ ++ while ((got = read(source, buffer, sizeof(buffer))) > 0) { ++ for (offset = 0; offset < got; offset += written) { ++ written = write(destination, buffer + offset, got - offset); ++ if (written <= 0) ++ return -1; ++ } ++ } ++ return got == 0 ? 0 : -1; ++} ++ ++static int ++bloom_atomic_open(const char *name, int flags, mode_t mode, char **atomic_name) ++{ ++ static u_int counter; ++ struct stat st; ++ int fd = -1, source = -1, attempt, exists, saved_errno; ++ ++ *atomic_name = NULL; ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ return -1; ++ } ++ if (lstat(name, &st) == 0) ++ exists = 1; ++ else if (errno == ENOENT) ++ exists = 0; ++ else ++ return -1; ++ if (!exists && (flags & O_CREAT) == 0) { ++ errno = ENOENT; ++ return -1; ++ } ++ if (exists && (flags & O_CREAT) != 0 && ++ (flags & O_EXCL) != 0) { ++ errno = EEXIST; ++ return -1; ++ } ++ for (attempt = 0; attempt < 32; attempt++) { ++ xasprintf(atomic_name, "%s.bloom-sftp-%ld-%u", name, ++ (long)getpid(), counter++); ++ fd = open(*atomic_name, (flags & ~(O_TRUNC|O_CREAT)) | ++ O_CREAT | O_EXCL, mode); ++ if (fd != -1) ++ break; ++ free(*atomic_name); ++ *atomic_name = NULL; ++ if (errno != EEXIST) ++ return -1; ++ } ++ if (fd == -1) ++ return -1; ++ if (exists && fchmod(fd, st.st_mode & 0777) == -1) { ++ saved_errno = errno; ++ close(fd); unlink(*atomic_name); free(*atomic_name); ++ *atomic_name = NULL; errno = saved_errno; return -1; ++ } ++ if (exists && (flags & O_TRUNC) == 0) { ++ source = open(name, O_RDONLY); ++ if (source == -1 || bloom_copy_existing(source, fd) == -1) { ++ saved_errno = errno; ++ if (source != -1) ++ close(source); ++ close(fd); unlink(*atomic_name); free(*atomic_name); ++ *atomic_name = NULL; errno = saved_errno; return -1; ++ } ++ } ++ if (source != -1) ++ close(source); ++ return fd; ++} ++ + static void + process_open(u_int32_t id) + { + u_int32_t pflags; + Attrib a; + char *name; ++ char *atomic_name = NULL; + int r, handle, fd, flags, mode, status = SSH2_FX_FAILURE; + + if ((r = sshbuf_get_cstring(iqueue, &name, NULL)) != 0 || +@@ -762,14 +1022,28 @@ process_open(u_int32_t id) + verbose("Refusing open request in read-only mode"); + status = SSH2_FX_PERMISSION_DENIED; + } else { +- fd = open(name, flags, mode); ++ if ((flags & O_ACCMODE) == O_RDONLY) { ++ if (!bloom_safe_public_path(name, 0)) { ++ errno = EACCES; ++ fd = -1; ++ } else ++ fd = open(name, flags, mode); ++ } ++ else ++ fd = bloom_atomic_open(name, flags, mode, &atomic_name); + if (fd == -1) { + status = errno_to_portable(errno); + } else { + handle = handle_new(HANDLE_FILE, name, fd, flags, NULL); + if (handle < 0) { + close(fd); ++ if (atomic_name != NULL) { ++ unlink(atomic_name); ++ free(atomic_name); ++ } + } else { ++ if (atomic_name != NULL) ++ handle_set_atomic_name(handle, atomic_name); + send_handle(id, handle); + status = SSH2_FX_OK; + } +@@ -869,7 +1143,9 @@ process_write(u_int32_t id) + + if (fd < 0) + status = SSH2_FX_FAILURE; +- else { ++ else if (!bloom_write_within_limit(off, len)) { ++ status = SSH2_FX_FAILURE; ++ } else { + if (!(handle_to_flags(handle) & O_APPEND) && + lseek(fd, off, SEEK_SET) == -1) { + status = errno_to_portable(errno); +@@ -908,7 +1184,11 @@ process_do_stat(u_int32_t id, int do_lstat) + + debug3("request %u: %sstat", id, do_lstat ? "l" : ""); + verbose("%sstat name \"%s\"", do_lstat ? "l" : "", name); +- r = do_lstat ? lstat(name, &st) : stat(name, &st); ++ if (!bloom_safe_public_path(name, 1)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = do_lstat ? lstat(name, &st) : stat(name, &st); + if (r == -1) { + status = errno_to_portable(errno); + } else { +@@ -995,6 +1275,11 @@ process_setstat(u_int32_t id) + fatal_fr(r, "parse"); + + debug("request %u: setstat name \"%s\"", id, name); ++ if (!bloom_safe_write_path(name)) { ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ free(name); ++ return; ++ } + if (a.flags & SSH2_FILEXFER_ATTR_SIZE) { + logit("set \"%s\" size %llu", + name, (unsigned long long)a.size); +@@ -1107,7 +1392,11 @@ process_opendir(u_int32_t id) + + debug3("request %u: opendir", id); + logit("opendir \"%s\"", path); +- dirp = opendir(path); ++ if (!bloom_safe_public_path(path, 1)) { ++ errno = EACCES; ++ dirp = NULL; ++ } else ++ dirp = opendir(path); + if (dirp == NULL) { + status = errno_to_portable(errno); + } else { +@@ -1150,6 +1439,12 @@ process_readdir(u_int32_t id) + + stats = xcalloc(nstats, sizeof(Stat)); + while ((dp = readdir(dirp)) != NULL) { ++ if ((strcmp(path, "/") == 0 || strcmp(path, ".") == 0) && ++ (strcmp(dp->d_name, ".bloom-bin") == 0 || ++ strcmp(dp->d_name, "dev") == 0)) ++ continue; ++ if (strstr(dp->d_name, ".bloom-sftp-") != NULL) ++ continue; + if (count >= nstats) { + nstats *= 2; + stats = xreallocarray(stats, nstats, sizeof(Stat)); +@@ -1193,7 +1488,11 @@ process_remove(u_int32_t id) + + debug3("request %u: remove", id); + logit("remove name \"%s\"", name); +- r = unlink(name); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = unlink(name); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1214,7 +1513,11 @@ process_mkdir(u_int32_t id) + a.perm & 07777 : 0777; + debug3("request %u: mkdir", id); + logit("mkdir name \"%s\" mode 0%o", name, mode); +- r = mkdir(name, mode); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = mkdir(name, mode); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1231,7 +1534,11 @@ process_rmdir(u_int32_t id) + + debug3("request %u: rmdir", id); + logit("rmdir name \"%s\"", name); +- r = rmdir(name); ++ if (!bloom_safe_write_path(name)) { ++ errno = EACCES; ++ r = -1; ++ } else ++ r = rmdir(name); + status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; + send_status(id, status); + free(name); +@@ -1253,7 +1560,9 @@ process_realpath(u_int32_t id) + } + debug3("request %u: realpath", id); + verbose("realpath \"%s\"", path); +- if (sftp_realpath(path, resolvedname) == NULL) { ++ if (!bloom_safe_public_path(path, 1)) { ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ } else if (sftp_realpath(path, resolvedname) == NULL) { + send_status(id, errno_to_portable(errno)); + } else { + Stat s; +@@ -1278,7 +1587,10 @@ process_rename(u_int32_t id) + debug3("request %u: rename", id); + logit("rename old \"%s\" new \"%s\"", oldpath, newpath); + status = SSH2_FX_FAILURE; +- if (lstat(oldpath, &sb) == -1) ++ if (!bloom_safe_write_path(oldpath) || ++ !bloom_safe_write_path(newpath)) ++ status = SSH2_FX_PERMISSION_DENIED; ++ else if (lstat(oldpath, &sb) == -1) + status = errno_to_portable(errno); + else if (S_ISREG(sb.st_mode)) { + /* Race-free rename of regular files */ +@@ -1336,7 +1648,9 @@ process_readlink(u_int32_t id) + + debug3("request %u: readlink", id); + verbose("readlink \"%s\"", path); +- if ((len = readlink(path, buf, sizeof(buf) - 1)) == -1) ++ if (!bloom_safe_public_path(path, 0)) ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ else if ((len = readlink(path, buf, sizeof(buf) - 1)) == -1) + send_status(id, errno_to_portable(errno)); + else { + Stat s; +@@ -1361,9 +1675,8 @@ process_symlink(u_int32_t id) + + debug3("request %u: symlink", id); + logit("symlink old \"%s\" new \"%s\"", oldpath, newpath); +- /* this will fail if 'newpath' exists */ +- r = symlink(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ /* Symlinks are intentionally outside the bounded transfer policy. */ ++ status = SSH2_FX_PERMISSION_DENIED; + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1381,8 +1694,13 @@ process_extended_posix_rename(u_int32_t id) + + debug3("request %u: posix-rename", id); + logit("posix-rename old \"%s\" new \"%s\"", oldpath, newpath); +- r = rename(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ if (!bloom_safe_write_path(oldpath) || ++ !bloom_safe_write_path(newpath)) ++ status = SSH2_FX_PERMISSION_DENIED; ++ else { ++ r = rename(oldpath, newpath); ++ status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ } + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1400,7 +1718,9 @@ process_extended_statvfs(u_int32_t id) + debug3("request %u: statvfs", id); + logit("statvfs \"%s\"", path); + +- if (statvfs(path, &st) != 0) ++ if (!bloom_safe_public_path(path, 1)) ++ send_status(id, SSH2_FX_PERMISSION_DENIED); ++ else if (statvfs(path, &st) != 0) + send_status(id, errno_to_portable(errno)); + else + send_statvfs(id, &st); +@@ -1439,8 +1759,8 @@ process_extended_hardlink(u_int32_t id) + + debug3("request %u: hardlink", id); + logit("hardlink old \"%s\" new \"%s\"", oldpath, newpath); +- r = link(oldpath, newpath); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ /* Hardlinks are intentionally outside the bounded transfer policy. */ ++ status = SSH2_FX_PERMISSION_DENIED; + send_status(id, status); + free(oldpath); + free(newpath); +@@ -1459,7 +1779,12 @@ process_extended_fsync(u_int32_t id) + status = SSH2_FX_NO_SUCH_FILE; + else if (handle_is_ok(handle, HANDLE_FILE)) { + r = fsync(fd); +- status = (r == -1) ? errno_to_portable(errno) : SSH2_FX_OK; ++ if (r == -1) ++ status = errno_to_portable(errno); ++ else if (handle_set_atomic_ready(handle) == -1) ++ status = SSH2_FX_OP_UNSUPPORTED; ++ else ++ status = SSH2_FX_OK; + } + send_status(id, status); + } +@@ -1476,6 +1801,10 @@ process_extended_lsetstat(u_int32_t id) + fatal_fr(r, "parse"); + + debug("request %u: lsetstat name \"%s\"", id, name); ++ if (!bloom_safe_write_path(name)) { ++ status = SSH2_FX_PERMISSION_DENIED; ++ goto out; ++ } + if (a.flags & SSH2_FILEXFER_ATTR_SIZE) { + /* nonsensical for links */ + status = SSH2_FX_BAD_MESSAGE; +diff --git a/sftp-server.8 b/sftp-server.8 +index 5311bf9..becff58 +--- a/sftp-server.8 ++++ b/sftp-server.8 +@@ -47,6 +47,11 @@ is a program that speaks the server side of SFTP protocol + to stdout and expects client requests from stdin. + .Nm + is not intended to be called directly, but from ++.Pp ++The BloomOS build stages writable file handles beside their destination. ++An explicit fsync extension request marks the staged bytes complete, and close ++then atomically renames them into place. Close without fsync and interrupted ++writes discard staging without truncating the previous final file. + .Xr sshd 8 + using the + .Cm Subsystem diff --git a/tools/run-shell-tests.sh b/tools/run-shell-tests.sh new file mode 100755 index 000000000..2afdaa5df --- /dev/null +++ b/tools/run-shell-tests.sh @@ -0,0 +1,208 @@ +#!/bin/sh +set -eu + +[ "$#" -ge 3 ] || { + printf 'usage: run-shell-tests.sh ROOT IMAGE JOBS [TEST ...]\n' >&2 + exit 2 +} +ROOT=$1 +IMAGE=$2 +JOBS=$3 + +[ -n "$ROOT" ] && [ -d "$ROOT/test/shell" ] || { + printf 'shell-test: repository root is unavailable\n' >&2 + exit 2 +} +[ -n "$IMAGE" ] || { + printf 'shell-test: pinned test image is required\n' >&2 + exit 2 +} +case "$JOBS" in + ''|*[!0-9]*|0) printf 'shell-test: job count must be a positive integer\n' >&2; exit 2 ;; +esac +[ "$JOBS" -le 8 ] || { + printf 'shell-test: job count exceeds the bounded maximum of 8\n' >&2 + exit 2 +} +shift 3 + +python3 "$ROOT/tools/legacy_manifest.py" validate \ + --repository "$ROOT" \ + --manifest "$ROOT/build/legacy-manifest.json" +PROVENANCE_MODE=$(python3 - "$ROOT" <<'PY' +import configparser +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) +policy = json.loads((root / "build/provenance-policy.json").read_text(encoding="utf-8")) +missing = [] +for component in policy["components"]: + for relative in component["paths"]: + if not (root / relative).exists(): + missing.append(relative) + for field in ("license_file", "inventory", "build_recipe"): + relative = component.get(field) + if relative and not (root / relative).is_file(): + missing.append(relative) +if not missing: + print("complete") + raise SystemExit +config = configparser.ConfigParser() +config.read(root / ".gitmodules", encoding="utf-8") +uninitialized = [] +for section in config.sections(): + relative = config.get(section, "path", fallback="") + if relative and not (root / relative / ".git").exists(): + uninitialized.append(relative.rstrip("/")) +if uninitialized and all( + any(relative == submodule or relative.startswith(submodule + "/") for submodule in uninitialized) + for relative in missing +): + print("deferred") +else: + print("complete") +PY +) +if [ "$PROVENANCE_MODE" = deferred ]; then + python3 "$ROOT/tools/provenance_policy.py" validate \ + --policy "$ROOT/build/provenance-policy.json" \ + --repository "$ROOT" + printf '%s\n' 'provenance file closure deferred: uninitialized submodules' +else + python3 "$ROOT/tools/provenance_policy.py" validate \ + --policy "$ROOT/build/provenance-policy.json" \ + --repository "$ROOT" \ + --require-files +fi +python3 "$ROOT/tools/validate_transfer_policy.py" \ + --policy "$ROOT/build/transfer-policy.json" \ + --schema "$ROOT/build/transfer-policy.schema.json" +python3 - "$ROOT" <<'PY' +import json +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +manifest = json.loads((root / "build/legacy-manifest.json").read_text(encoding="utf-8")) +lock = (root / "build/dependencies.lock").read_text(encoding="utf-8") +components = manifest["components"] +expected = { + "component_count": len(components), + "file_count": sum(item["file_count"] for item in components), + "byte_count": sum(item["byte_count"] for item in components), + "source_build_components": sum(item["resolution"] == "source-build" for item in components), + "unresolved_components": sum( + item["resolution"] == "replace-source-build-or-exclude" for item in components + ), +} +for key, value in expected.items(): + match = re.search(r"^{} = (\d+)$".format(re.escape(key)), lock, re.MULTILINE) + if match is None or int(match.group(1)) != value: + raise SystemExit("dependency inventory mismatch: {} expected {}".format(key, value)) +print("dependency inventory valid: {} components, {} files, {} bytes".format( + expected["component_count"], expected["file_count"], expected["byte_count"] +)) +PY + +if [ "$#" -gt 0 ]; then + exec docker run --rm --name "bloomos-shell-$$-focused" \ + --volume "$ROOT:/workspace:ro" \ + "$IMAGE" "$@" +fi + +TEMP_BASE=${TMPDIR:-/tmp} +OUTPUT_DIR=$(mktemp -d "$TEMP_BASE/bloomos-shell-test.XXXXXX") +RUN_ID=$$ +CONTAINER_PREFIX=bloomos-shell-$RUN_ID +CONTAINERS= + +cleanup() { + for container in $CONTAINERS; do + docker rm -f "$container" >/dev/null 2>&1 || true + done + case "$OUTPUT_DIR" in + "$TEMP_BASE"/bloomos-shell-test.*) rm -rf -- "$OUTPUT_DIR" ;; + esac +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM + +find "$ROOT/test/shell" -maxdepth 1 -type f -name '*.bats' -print | + LC_ALL=C sort >"$OUTPUT_DIR/files" +[ -s "$OUTPUT_DIR/files" ] || { + printf 'shell-test: no Bats files found\n' >&2 + exit 1 +} + +python3 - "$OUTPUT_DIR" "$JOBS" <<'PY' +import pathlib +import re +import sys + +output = pathlib.Path(sys.argv[1]) +jobs = int(sys.argv[2]) +test_pattern = re.compile(r'^\s*@test\s+"((?:[^"\\]|\\.)*)"') +names = [] +for filename in output.joinpath("files").read_text(encoding="utf-8").splitlines(): + for line in pathlib.Path(filename).read_text(encoding="utf-8").splitlines(): + match = test_pattern.match(line) + if match: + names.append(match.group(1)) +if not names: + raise SystemExit("shell-test: no parseable Bats tests found") +if len(names) != len(set(names)): + raise SystemExit("shell-test: test names must be unique for exact parallel filters") +for name in names: + if any(other != name and other.endswith(name) for other in names): + raise SystemExit("shell-test: test names must have unique suffixes for exact parallel filters") +def ere_escape(name): + return "".join("\\" + char if char in r"\.^$|?*+()[]{}" else char for char in name) +for shard in range(jobs): + selected = names[shard::jobs] + expression = "({})$".format("|".join(ere_escape(name) for name in selected)) + output.joinpath("filter.{}".format(shard)).write_text(expression, encoding="utf-8") +output.joinpath("expected-count").write_text(str(len(names)), encoding="ascii") +print("shell-test inventory valid: {} unique tests across {} shards".format(len(names), jobs)) +PY + +PIDS= +shard=0 +while [ "$shard" -lt "$JOBS" ]; do + container="$CONTAINER_PREFIX-$shard" + CONTAINERS="$CONTAINERS $container" + ( + filter=$(cat "$OUTPUT_DIR/filter.$shard") + docker run --rm --name "$container" \ + --volume "$ROOT:/workspace:ro" \ + "$IMAGE" --filter "$filter" /workspace/test/shell + ) >"$OUTPUT_DIR/shard.$shard.log" 2>&1 & + PIDS="$PIDS $!" + shard=$((shard + 1)) +done + +STATUS=0 +for pid in $PIDS; do + if ! wait "$pid"; then + STATUS=1 + fi +done + +ACTUAL_COUNT=$(awk '/^ok [0-9]+ / { count += 1 } END { print count + 0 }' "$OUTPUT_DIR"/shard.*.log) +EXPECTED_COUNT=$(cat "$OUTPUT_DIR/expected-count") +if [ "$ACTUAL_COUNT" -ne "$EXPECTED_COUNT" ]; then + printf 'shell-test: expected %s passing tests but observed %s\n' \ + "$EXPECTED_COUNT" "$ACTUAL_COUNT" >&2 + STATUS=1 +fi + +shard=0 +while [ "$shard" -lt "$JOBS" ]; do + printf '\n# shell-test shard %s/%s\n' "$((shard + 1))" "$JOBS" + cat "$OUTPUT_DIR/shard.$shard.log" + shard=$((shard + 1)) +done +exit "$STATUS" diff --git a/tools/validate_transfer_policy.py b/tools/validate_transfer_policy.py new file mode 100755 index 000000000..0e52265d1 --- /dev/null +++ b/tools/validate_transfer_policy.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Validate BloomOS's canonical transfer/share policy.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath + + +EXPECTED_ROOTS = { + "roms": ("/Roms", "Roms", "read_write"), + "bios": ("/BIOS", "BIOS", "read_write"), + "artwork": ("/Artwork", "Imgs", "read_write"), + "screenshots": ("/Screenshots", "Screenshots", "read_write"), + "themes": ("/Themes", "Themes", "read_write"), + "saves": ("/Saves", "Saves", "read_only"), +} + + +def load_json(path: Path) -> dict: + with path.open("r", encoding="utf-8") as source: + value = json.load(source) + if not isinstance(value, dict): + raise ValueError(f"{path}: root must be an object") + return value + + +def validate(policy: dict, schema: dict) -> list[str]: + errors: list[str] = [] + expected_keys = { + "schema", "policy_id", "publication", "limits", "roots", "private_device_roots" + } + if set(policy) != expected_keys: + errors.append("policy keys do not match the version-1 contract") + if policy.get("schema") != 1 or policy.get("policy_id") != "bloomos-transfer-policy-v1": + errors.append("unsupported transfer policy identity") + if policy.get("publication") != "same_filesystem_atomic_replace": + errors.append("writes must use same-filesystem atomic replacement") + if schema.get("$id") != "https://bloomos.dev/schemas/transfer-policy-v1.json": + errors.append("unsupported transfer policy schema identity") + + limits = policy.get("limits") + expected_limits = { + "max_path_bytes": 1024, + "max_components": 32, + "max_component_bytes": 255, + "max_upload_bytes": 4294967295, + } + if limits != expected_limits: + errors.append("transfer limits do not match the bounded version-1 contract") + + roots = policy.get("roots") + if not isinstance(roots, list): + errors.append("roots must be an array") + roots = [] + seen_ids: set[str] = set() + seen_virtual: set[str] = set() + seen_device: set[str] = set() + actual: dict[str, tuple[str, str, str]] = {} + for index, root in enumerate(roots): + if not isinstance(root, dict) or set(root) != {"id", "virtual_path", "device_path", "access"}: + errors.append(f"roots[{index}] does not match the root contract") + continue + identifier = root.get("id") + virtual = root.get("virtual_path") + device = root.get("device_path") + access = root.get("access") + if not all(isinstance(value, str) for value in (identifier, virtual, device, access)): + errors.append(f"roots[{index}] fields must be strings") + continue + if identifier in seen_ids or virtual in seen_virtual or device in seen_device: + errors.append(f"roots[{index}] duplicates an id or path") + seen_ids.add(identifier) + seen_virtual.add(virtual) + seen_device.add(device) + path = PurePosixPath(virtual) + if not virtual.startswith("/") or len(path.parts) != 2 or any(part in {".", ".."} for part in path.parts): + errors.append(f"roots[{index}] virtual path is not a single bounded component") + if "/" in device or "\\" in device or device in {"", ".", ".."}: + errors.append(f"roots[{index}] device path is unsafe") + actual[identifier] = (virtual, device, access) + if actual != EXPECTED_ROOTS: + errors.append("root dispositions must exactly cover ROMs, BIOS, artwork, screenshots, themes, and saves") + + private = policy.get("private_device_roots") + if private != [".bloom", ".tmp_update"]: + errors.append("private device roots must explicitly deny .bloom and .tmp_update") + if isinstance(private, list) and seen_device.intersection(private): + errors.append("a private root is exposed by the transfer policy") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--policy", type=Path, default=Path("build/transfer-policy.json")) + parser.add_argument("--schema", type=Path, default=Path("build/transfer-policy.schema.json")) + args = parser.parse_args() + try: + errors = validate(load_json(args.policy), load_json(args.schema)) + except (OSError, json.JSONDecodeError, ValueError) as error: + errors = [str(error)] + if errors: + for error in errors: + print(f"transfer policy invalid: {error}") + return 1 + print("transfer policy valid: 6 roots, 5 read-write, 1 read-only") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/website/docs/07-apps/03-network-features/03-ssh-sftp.md b/website/docs/07-apps/03-network-features/03-ssh-sftp.md index ebe2d481e..9cc575792 100644 --- a/website/docs/07-apps/03-network-features/03-ssh-sftp.md +++ b/website/docs/07-apps/03-network-features/03-ssh-sftp.md @@ -9,14 +9,22 @@ slug: /network/ssh SSH provides a secure command line method to communicate with your Miyoo Mini+. -SFTP provides a secure file transfer method +SFTP provides an encrypted, public-key-only file transfer method. + +BloomOS exposes only `Roms`, `BIOS`, `Artwork`, `Screenshots`, `Themes`, and +read-only `Saves`. System, credential, update, and other private directories are +not visible through SFTP. Enable it separately from shell access in Bloom +Settings. Uploads are staged beside their destination and publish only after +the client requests the SFTP `fsync` extension. With OpenSSH, use durable mode +(`put -f local-file /Roms/...`) so a completed upload commits atomically; +canceling or losing the transport preserves the previous final file. ## Features *![](https://github.com/OnionUI/Onion/assets/47260768/64e1bf60-3670-4e84-b0f8-89f3575cc378)* -- Virtual password auth. +- Public-key-only authentication - Command-Line Interface @@ -27,65 +35,31 @@ SFTP provides a secure file transfer method - Encrypted -## Enabling SSH - -You'll find it in **Apps** › **Tweaks** › **Network** › **SSH: Secure shell** - -You can either use the master toggle to turn the SSH server on with your Right/Left arrows, or press A to enter the submenu. - -In the submenu you'll get 2 options: - -1. **Enable** - Activate SSH access - -2. **Enable authentication** - Activate authentication (username/password) +## Enabling access -![](https://github.com/OnionUI/Onion/assets/47260768/f309e712-2027-4356-b6d2-7e43ace312f5) +Open **Settings** › **Network** and enable SSH shell access, SFTP file access, +or both. The controls are independent and appear only on a device with the +required network capability. +At least one valid public key must already be installed through Bloom's bounded +Authorized Keys workflow. BloomOS does not support password or blank-password +login. Your client must pin the device host key and use the corresponding +private identity. ## Logging in -:::note Default credentials -**Username:** `onion` -**Password:** `onion` -*We're using a new auth system, user defined passwords will come in a future update.* -::: - -Once you've activated your SSH server in Tweaks you'll now be able to connect using the integrated terminal emulator in Windows, Mac or Linux or with separate software such as PuTTY, SolarPuTTY or Hyperterm. You'll also be able to use an SFTP client such as FileZilla to transfer files securely. - -As an example, in most terminal emulators you can connect with the following: +Use the device address shown in Network Settings with your existing key-only +client configuration. For example: ``` -ssh onion@ipaddress -``` -You'll be prompted to enter the password, `onion`, and then you'll be greeted with a shell prompt: - -![](./assets/terminal-ssh.png) - -You'll need the IP of the device, found below (**Tweaks** › **Network**). - -![](https://github.com/OnionUI/Onion/assets/47260768/b7d5bc34-4032-4f38-81a4-79e069cfd2ac) - - -### For developers - -The ultimate `ssh` command for developers (replace `[IP_ADDRESS]` with your device's IP): - -```sh -ssh -o MACs=hmac-sha1 onion@[IP_ADDRESS] -t "cd /mnt/SDCARD; source .tmp_update/script/init_env.sh; ash" +ssh -i /path/to/bloom_key root@[IP_ADDRESS] +sftp -i /path/to/bloom_key root@[IP_ADDRESS] ``` -This will initialize the environment, giving direct access to binaries located in `.tmp_update/bin` (see [init_env.sh](https://github.com/OnionUI/Onion/blob/main/static/build/.tmp_update/script/init_env.sh)). - - -## Disabling authentication - -Although this is not recommended on other peoples Wi-Fi, when you're at home on your own Wi-Fi this is absolutely fine (or any other secure Wi-Fi!) - -Head over to **Tweaks** › **Network** › **SSH** and toggle: - -![](https://github.com/OnionUI/Onion/assets/47260768/4ccc836b-08d6-44cb-8e5d-9f795be0c85f) +SFTP-only mode does not grant a command shell: every authorized key is forced +through the confined file-transfer subsystem and cannot request a PTY, +forwarding, an agent, or arbitrary commands. -Now you can connect with the username `root` and no password. ## Security :::caution Network security