From 119283b18c5e8c2b62f3ee13c5596f87dcafb2e9 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 14 Oct 2026 22:35:35 -0400 Subject: [PATCH] Stage exact tested ICU/Arena/PFD sources as narrow additive main integration --- .github/workflows/ib-c67-icu-arena.yml | 133 ++++++ .github/workflows/ib-c67-reader-pfd.yml | 144 ++++++ bin/ib_android_arena_read.grease | 133 ++++++ bin/ib_android_icu_arena.grease | 178 ++++++++ docs/c67-icu-arena.md | 112 +++++ docs/c67-reader-pfd.md | 139 ++++++ lib/android_binder_icu.grease | 5 + lib/android_shizuku_host.grease | 232 ++++++++++ lib/android_shizuku_icu_provider.grease | 129 ++++++ native/android/ib_arena_pfd_provider.c | 163 +++++++ native/android/ib_arena_pfd_provider.h | 36 ++ native/android/ib_arena_slice.c | 148 ++++++ native/android/ib_arena_source_owner.c | 427 ++++++++++++++++++ native/android/ib_arena_source_owner.h | 43 ++ native/android/ib_reader_host_assembly.c | 37 ++ native/android/ib_reader_host_assembly.h | 33 ++ native/android/ib_reader_pfd.c | 376 +++++++++++++++ native/android/ib_reader_pfd.h | 68 +++ tests/icu_native_transport_probe.c | 46 ++ tests/test_android_arena_source_owner.c | 196 ++++++++ tests/test_android_arena_source_owner.sh | 92 ++++ ...st_android_arena_source_owner_synthetic.sh | 77 ++++ tests/test_android_icu_arena.sh | 215 +++++++++ tests/test_android_icu_native_bridge.sh | 232 ++++++++++ tests/test_android_reader_pfd.c | 218 +++++++++ 25 files changed, 3612 insertions(+) create mode 100644 .github/workflows/ib-c67-icu-arena.yml create mode 100644 .github/workflows/ib-c67-reader-pfd.yml create mode 100644 bin/ib_android_arena_read.grease create mode 100644 bin/ib_android_icu_arena.grease create mode 100644 docs/c67-icu-arena.md create mode 100644 docs/c67-reader-pfd.md create mode 100644 lib/android_binder_icu.grease create mode 100644 lib/android_shizuku_host.grease create mode 100644 lib/android_shizuku_icu_provider.grease create mode 100644 native/android/ib_arena_pfd_provider.c create mode 100644 native/android/ib_arena_pfd_provider.h create mode 100644 native/android/ib_arena_slice.c create mode 100644 native/android/ib_arena_source_owner.c create mode 100644 native/android/ib_arena_source_owner.h create mode 100644 native/android/ib_reader_host_assembly.c create mode 100644 native/android/ib_reader_host_assembly.h create mode 100644 native/android/ib_reader_pfd.c create mode 100644 native/android/ib_reader_pfd.h create mode 100644 tests/icu_native_transport_probe.c create mode 100644 tests/test_android_arena_source_owner.c create mode 100644 tests/test_android_arena_source_owner.sh create mode 100644 tests/test_android_arena_source_owner_synthetic.sh create mode 100644 tests/test_android_icu_arena.sh create mode 100644 tests/test_android_icu_native_bridge.sh create mode 100644 tests/test_android_reader_pfd.c diff --git a/.github/workflows/ib-c67-icu-arena.yml b/.github/workflows/ib-c67-icu-arena.yml new file mode 100644 index 00000000..3bb62aa2 --- /dev/null +++ b/.github/workflows/ib-c67-icu-arena.yml @@ -0,0 +1,133 @@ +name: IB C67 ICU to Arena slice + +on: + pull_request: + paths: + - 'bin/ib_android_icu_arena.grease' + - 'bin/ib_android_arena_read.grease' + - 'lib/android_binder_icu.grease' + - 'lib/android_shizuku_icu_provider.grease' + - 'native/android/ib_arena_slice.c' + - 'tests/test_android_icu_arena.sh' + - 'tests/test_android_icu_native_bridge.sh' + - 'tests/icu_native_transport_probe.c' + - '.github/workflows/ib-c67-icu-arena.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + checked-arena: + runs-on: ubuntu-24.04 + steps: + - name: Check out IB + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check out pinned canonical appendfat arena + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/sd-card-append-fat + ref: 05a0cb7246c5a9f46abfbe92925812e7b607f454 + path: .deps/appendfat + persist-credentials: false + - name: Check out exact ICU #35 capture source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: dilapidated-shed/icu + ref: 0da041fbe906657f4f705da45a842d96832e5ee1 + path: .deps/icu + persist-credentials: false + - name: Check out independent AI-CI admitted-source verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/ai-ci + ref: 634626a14834e14b776616440e0a9071708f81b7 + path: .deps/ai-ci + persist-credentials: false + - id: ick + uses: isomorphisms/ai-ci/ick-host@015cc7901ae0b3ad262b476f24e129b53c56db95 + - name: Compile storage and checked slice reader + env: + ICK: ${{ steps.ick.outputs.compiler }} + run: | + test "$(git -C .deps/appendfat rev-parse HEAD)" = 05a0cb7246c5a9f46abfbe92925812e7b607f454 + mkdir -p build/c67-icu-arena + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + .deps/appendfat/lib/appendfat_arena.c \ + .deps/appendfat/tools/appendfat_arena.c -o build/c67-icu-arena/appendfat_arena + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + native/android/ib_arena_slice.c -o build/c67-icu-arena/ib-arena-slice + sh -n bin/ib_android_icu_arena.grease + sh -n bin/ib_android_arena_read.grease + sh -n lib/android_binder_icu.grease + sh -n lib/android_shizuku_icu_provider.grease + sh -n tests/test_android_icu_arena.sh + - name: Run source-bound Arena and Binder refusal tests + env: + IB_APPEND_ARENA: /home/runner/work/ib/ib/build/c67-icu-arena/appendfat_arena + IB_ARENA_SLICE: /home/runner/work/ib/ib/build/c67-icu-arena/ib-arena-slice + run: | + export IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/c67-icu-arena/appendfat_arena" + export IB_ARENA_SLICE="$GITHUB_WORKSPACE/build/c67-icu-arena/ib-arena-slice" + sh tests/test_android_icu_arena.sh + echo 'real_ICU_HTTPS=NOT_RUN physical_C67=NOT_RUN browser_UI=NOT_RUN' + - name: Compile and test actual pinned ICU #35 HTTP/TLS against IB Arena host contract + env: + ICK: ${{ steps.ick.outputs.compiler }} + run: | + set -eu + test "$(git -C .deps/icu rev-parse HEAD)" = 0da041fbe906657f4f705da45a842d96832e5ee1 + sh -n tests/test_android_icu_native_bridge.sh + test "$(git -C .deps/ai-ci rev-parse HEAD)" = 634626a14834e14b776616440e0a9071708f81b7 + sh -n .deps/ai-ci/ib-source/verify.sh + sh .deps/ai-ci/ib-source/self-test.sh > build/c67-icu-arena/aici-source-self-test.txt + grep -F 'PASS: independent IB v2 oracle' build/c67-icu-arena/aici-source-self-test.txt + test -x "$ICK" + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIC -shared .deps/icu/native/transport.c \ + -lssl -lcrypto -o build/c67-icu-arena/libicu_transport.so + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + tests/icu_native_transport_probe.c .deps/icu/native/transport.c \ + -lssl -lcrypto -o build/c67-icu-arena/icu-native-probe + export IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/c67-icu-arena/appendfat_arena" + export IB_ARENA_SLICE="$GITHUB_WORKSPACE/build/c67-icu-arena/ib-arena-slice" + export IB_ICU_NATIVE_PROBE="$GITHUB_WORKSPACE/build/c67-icu-arena/icu-native-probe" + export IB_ICU_NATIVE_SO="$GITHUB_WORKSPACE/build/c67-icu-arena/libicu_transport.so" + export IB_AICI_SOURCE_VERIFY="$GITHUB_WORKSPACE/.deps/ai-ci/ib-source/verify.sh" + sh tests/test_android_icu_native_bridge.sh | tee build/c67-icu-arena/native-icu-bridge-receipt.txt + grep -F 'real compiled pinned ICU #35 native HTTP and HTTPS' \ + build/c67-icu-arena/native-icu-bridge-receipt.txt + sha256sum .deps/icu/native/transport.c \ + build/c67-icu-arena/icu-native-probe \ + build/c67-icu-arena/libicu_transport.so \ + > build/c67-icu-arena/native-icu-sha256.txt + - name: Cross-build ARM32 and ARM64 native range readers + run: | + ndk_version=27.3.13750724 + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$ndk_version" > /dev/null + toolchain="$ANDROID_HOME/ndk/$ndk_version/toolchains/llvm/prebuilt/linux-x86_64/bin" + for entry in 'armeabi-v7a armv7a-linux-androideabi24 ARM' 'arm64-v8a aarch64-linux-android24 AArch64'; do + set -- $entry + abi=$1; target=$2; machine=$3 + mkdir -p "build/c67-icu-arena/$abi" + "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIE -pie native/android/ib_arena_slice.c \ + -o "build/c67-icu-arena/$abi/ib-arena-slice" + "$toolchain/llvm-readelf" -h "build/c67-icu-arena/$abi/ib-arena-slice" \ + > "build/c67-icu-arena/$abi/elf.txt" + grep -F "Machine:" "build/c67-icu-arena/$abi/elf.txt" | grep -F "$machine" + sha256sum "build/c67-icu-arena/$abi/ib-arena-slice" \ + > "build/c67-icu-arena/$abi/sha256.txt" + printf 'abi\t%s\nphysical_device\tNOT_RUN\n' "$abi" \ + > "build/c67-icu-arena/$abi/receipt.tsv" + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ib-c67-arena-slice-native + path: | + build/c67-icu-arena/armeabi-v7a/ + build/c67-icu-arena/arm64-v8a/ + build/c67-icu-arena/native-icu-bridge-receipt.txt + build/c67-icu-arena/native-icu-sha256.txt + build/c67-icu-arena/aici-source-self-test.txt + if-no-files-found: error diff --git a/.github/workflows/ib-c67-reader-pfd.yml b/.github/workflows/ib-c67-reader-pfd.yml new file mode 100644 index 00000000..89a7c690 --- /dev/null +++ b/.github/workflows/ib-c67-reader-pfd.yml @@ -0,0 +1,144 @@ +name: IB checked reader Binder PFD admission + +on: + pull_request: + paths: + - 'native/android/ib_reader_pfd.c' + - 'native/android/ib_reader_pfd.h' + - 'native/android/ib_arena_pfd_provider.c' + - 'native/android/ib_arena_pfd_provider.h' + - 'native/android/ib_arena_source_owner.c' + - 'native/android/ib_arena_source_owner.h' + - 'native/android/ib_reader_host_assembly.c' + - 'native/android/ib_reader_host_assembly.h' + - 'tests/test_android_arena_source_owner.c' + - 'tests/test_android_arena_source_owner.sh' + - 'tests/test_android_arena_source_owner_synthetic.sh' + - 'tests/test_android_reader_pfd.c' + - '.github/workflows/ib-c67-reader-pfd.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + pfd-host-fast: + runs-on: ubuntu-24.04 + steps: + - name: Checkout candidate source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check out pinned independent AI-CI admitted-source verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/ai-ci + ref: 634626a14834e14b776616440e0a9071708f81b7 + path: .deps/ai-ci + persist-credentials: false + - name: Compile native receiver and fake-boundary source owner + run: | + set -eu + test "$(git -C .deps/ai-ci rev-parse HEAD)" = 634626a14834e14b776616440e0a9071708f81b7 + sh .deps/ai-ci/ib-source/self-test.sh > build-source-self-test.txt + grep -F 'PASS: independent IB v2 oracle' build-source-self-test.txt + export IB_AICI_SOURCE_VERIFY="$GITHUB_WORKSPACE/.deps/ai-ci/ib-source/verify.sh" + mkdir -p build/c67-pfd/fast + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + tests/test_android_arena_source_owner.c \ + -o build/c67-pfd/fast/test-arena-pfd + env IB_TEST_VERIFIED_ARENA_PIPE="$GITHUB_WORKSPACE/build/c67-pfd/fast/test-arena-pfd" \ + sh tests/test_android_arena_source_owner_synthetic.sh \ + | tee build/c67-pfd/fast/receipt.txt + grep -F 'fixture' build/c67-pfd/fast/receipt.txt | grep -F 'synthetic-pointer-not-canonical-appendfat' + sha256sum build/c67-pfd/fast/test-arena-pfd > build/c67-pfd/fast/sha256.txt + + pfd-source-admission: + runs-on: ubuntu-24.04 + steps: + - name: Checkout pinned PR source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check out pinned independent AI-CI admitted-source verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/ai-ci + ref: 634626a14834e14b776616440e0a9071708f81b7 + path: .deps/ai-ci + persist-credentials: false + - name: Checkout immutable canonical appendfat Arena + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/sd-card-append-fat + ref: 05a0cb7246c5a9f46abfbe92925812e7b607f454 + path: .deps/appendfat + persist-credentials: false + - name: Verify canonical Arena source pin + run: | + test "$(git -C .deps/appendfat rev-parse HEAD)" = 05a0cb7246c5a9f46abfbe92925812e7b607f454 + - name: Host C11 two-process descriptor tests + run: | + set -eu + mkdir -p build/c67-pfd/host + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + native/android/ib_reader_pfd.c tests/test_android_reader_pfd.c \ + -o build/c67-pfd/host/test-reader-pfd + ./build/c67-pfd/host/test-reader-pfd | tee build/c67-pfd/host/receipt.txt + grep -F 'NOT_RUN: Android Binder service' build/c67-pfd/host/receipt.txt + sha256sum build/c67-pfd/host/test-reader-pfd \ + > build/c67-pfd/host/sha256.txt + - id: ick + uses: isomorphisms/ai-ci/ick-host@015cc7901ae0b3ad262b476f24e129b53c56db95 + - name: Read and serve actual canonical Arena bytes as a scoped pipe + env: + ICK: ${{ steps.ick.outputs.compiler }} + run: | + set -eu + test -x "$ICK" + test "$(git -C .deps/ai-ci rev-parse HEAD)" = 634626a14834e14b776616440e0a9071708f81b7 + export IB_AICI_SOURCE_VERIFY="$GITHUB_WORKSPACE/.deps/ai-ci/ib-source/verify.sh" + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + .deps/appendfat/lib/appendfat_arena.c \ + .deps/appendfat/tools/appendfat_arena.c -o build/c67-pfd/host/appendfat_arena + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + native/android/ib_arena_slice.c -o build/c67-pfd/host/ib-arena-slice + cc -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + tests/test_android_arena_source_owner.c \ + -o build/c67-pfd/host/test-arena-pfd + env IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/c67-pfd/host/appendfat_arena" \ + IB_ARENA_SLICE="$GITHUB_WORKSPACE/build/c67-pfd/host/ib-arena-slice" \ + IB_TEST_VERIFIED_ARENA_PIPE="$GITHUB_WORKSPACE/build/c67-pfd/host/test-arena-pfd" \ + sh tests/test_android_arena_source_owner.sh \ + | tee build/c67-pfd/host/arena-source-receipt.txt + grep -F 'checked_arena=actual_appends' build/c67-pfd/host/arena-source-receipt.txt + sha256sum build/c67-pfd/host/test-arena-pfd \ + > build/c67-pfd/host/arena-test-sha256.txt + - name: Cross-build real Android libbinder_ndk PFD reader on both ABIs + run: | + set -eu + ndk_version=27.3.13750724 + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;$ndk_version" >/dev/null + toolchain="$ANDROID_HOME/ndk/$ndk_version/toolchains/llvm/prebuilt/linux-x86_64/bin" + for entry in 'armeabi-v7a armv7a-linux-androideabi29 ARM' 'arm64-v8a aarch64-linux-android29 AArch64'; do + set -- $entry + abi=$1 target=$2 machine=$3 + mkdir -p "build/c67-pfd/$abi" + "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra \ + -Wpedantic -Werror -fPIC -shared native/android/ib_reader_pfd.c \ + native/android/ib_arena_pfd_provider.c \ + native/android/ib_arena_source_owner.c \ + native/android/ib_reader_host_assembly.c \ + -pthread -lbinder_ndk -o "build/c67-pfd/$abi/libib_reader_pfd.so" + "$toolchain/llvm-readelf" -h "build/c67-pfd/$abi/libib_reader_pfd.so" \ + > "build/c67-pfd/$abi/elf.txt" + grep -F "Machine:" "build/c67-pfd/$abi/elf.txt" | grep -F "$machine" + sha256sum "build/c67-pfd/$abi/libib_reader_pfd.so" \ + > "build/c67-pfd/$abi/sha256.txt" + printf 'abi\t%s\nandroid_link\tlibbinder_ndk\nphysical_device\tNOT_RUN\nidric_screen\tNOT_CONNECTED\n' \ + "$abi" > "build/c67-pfd/$abi/receipt.tsv" + done + - name: Publish ABI artifacts, not an APK + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ib-c67-reader-pfd-native + path: build/c67-pfd/ + if-no-files-found: error diff --git a/bin/ib_android_arena_read.grease b/bin/ib_android_arena_read.grease new file mode 100644 index 00000000..20c387b7 --- /dev/null +++ b/bin/ib_android_arena_read.grease @@ -0,0 +1,133 @@ +#!/usr/bin/env grease +# Reopen the exact committed canonical Arena range, verify before output, and +# check admission semantics of the versioned ICU receipt. No networking. +set -eu +umask 077 +export LC_ALL=C +fail() { printf 'ib Arena read: %s\n' "$1" >&2; exit 1; } +usage() { printf 'usage: %s REQUEST_ID PRIVATE_ARENA_DIRECTORY\n' "$0" >&2; exit 2; } +test "$#" -eq 2 || usage +request_id=$1 +root=$2 +case "$request_id" in ''|.|..|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-]*) usage ;; esac +case "$root" in /*) ;; *) usage ;; esac +test -d "$root" && test ! -L "$root" || fail 'Arena directory is absent or a symlink' +root=$(CDPATH='' cd -P -- "$root" && pwd -P) +case "$(stat -c '%a' "$root")" in 700|0700) ;; *) fail 'Arena root must be 0700' ;; esac +manifest="$root/receipts/$request_id.tsv" +arena="$root/responses.arena" +reader=${IB_ARENA_SLICE:-} +test -n "$reader" && test -x "$reader" || fail 'checked native reader is missing' +test -f "$manifest" && test ! -L "$manifest" || fail 'receipt absent' +for entry in "$arena" "$arena.used" "$arena.lock"; do + test -f "$entry" && test ! -L "$entry" || fail 'invalid canonical Arena file' +done +test "$(wc -c < "$manifest" | tr -d '[:space:]')" -le 8192 || fail 'oversized receipt' +tr -cd '\011\012\040-\176' < "$manifest" | cmp -s - "$manifest" || + fail 'receipt contains nonprintable bytes' +field() { awk -F '\t' -v key="$1" '$1==key {print $2}' "$manifest"; } +schema=$(field schema) +case "$schema" in + ib-icu-arena-v1) count=14 + names='schema request_id requested_url resolved_url http_status mime_type transport arena_name arena_offset arena_end byte_count sha256 reopen_check navigation_admitted' ;; + ib-icu-arena-v2) count=23 + names='schema request_id source_key source_generation requested_url resolved_url http_status mime_type charset content_encoding utf8 redirect_count declared_length received_length transport arena_name arena_offset arena_end byte_count sha256 reopen_check acquisition_complete navigation_admitted' ;; + *) fail 'unsupported receipt schema' ;; +esac +awk -F '\t' -v expected="$count" -v names="$names" ' + BEGIN { + count=split(names, elements, " ") + for (i=1; i<=count; i++) allowed[elements[i]]=1 + } + NF!=2 || !allowed[$1] || $2=="" { bad=1 } + { seen[$1]++ } + END { + for (key in allowed) if (seen[key]!=1) bad=1 + if (bad || NR!=expected) exit 1 + } +' "$manifest" || fail 'invalid receipt structure' +test "$(field request_id)" = "$request_id" || fail 'receipt identity mismatch' +test "$(field arena_name)" = responses.arena || fail 'wrong Arena' +test "$(field reopen_check)" = MATCH || fail 'producer readback absent' +test "$(field transport)" = icu-app-process-via-binder-shizuku || fail 'wrong producer' + +# Legacy receipts can be read as verified bytes but never authorize navigation. +# No upgrade may infer missing HTTP observations from a v1 body. +if test "$schema" = ib-icu-arena-v1; then + test "$(field navigation_admitted)" = NO || fail 'legacy navigation must be refused' + test "$(field resolved_url)" = UNOBSERVED || fail 'legacy resolved URL must be unobserved' + test "$(field http_status)" = UNOBSERVED || fail 'legacy status must be unobserved' + test "$(field mime_type)" = UNOBSERVED || fail 'legacy MIME must be unobserved' +else + test "$(field source_key)" = "arena:$request_id" || fail 'untrusted source key' + source_generation=$(field source_generation) + case "$source_generation" in ''|0|0*|*[!0123456789]*) fail 'invalid source generation' ;; esac + test "${#source_generation}" -le 18 || fail 'source generation exceeds bound' + test "$(field acquisition_complete)" = YES || fail 'incomplete ICU acquisition' + status=$(field http_status) + case "$status" in 2[0-9][0-9]) ;; *) fail 'invalid observed HTTP status' ;; esac + mime=$(field mime_type) + case "$mime" in + unknown) ;; + */*) case "$mime" in *[!abcdefghijklmnopqrstuvwxyz0123456789.+/_-]*) fail 'invalid MIME token' ;; esac ;; + *) fail 'invalid MIME type' ;; + esac + charset=$(field charset) + case "$charset" in utf-8|UNOBSERVED) ;; *) fail 'invalid observed charset' ;; esac + encoding=$(field content_encoding) + utf8=$(field utf8) + case "$utf8" in valid|invalid) ;; *) fail 'invalid UTF-8 observation' ;; esac + requested=$(field requested_url) + resolved=$(field resolved_url) + for link in "$requested" "$resolved"; do + test "${#link}" -gt 8 && test "${#link}" -le 2048 || fail 'invalid URL length' + case "$link" in http://*|https://*) ;; *) fail 'invalid URL scheme' ;; esac + case "$link" in *"'"*) fail 'unsafe URL delimiter' ;; esac + printf '%s' "$link" | grep -Eq '^[!-&(-~]+$' || fail 'unsafe URL byte' + done + redirects=$(field redirect_count) + case "$redirects" in ''|*[!0123456789]*) fail 'invalid redirect count' ;; esac + test "${#redirects}" -le 2 && test "$redirects" -le 8 || + fail 'invalid redirect observation' + reported=$(field received_length) + declared=$(field declared_length) + case "$reported" in ''|*[!0123456789]*) fail 'invalid received length' ;; esac + case "$declared" in -1) ;; ''|*[!0123456789]*) fail 'invalid declared length' ;; esac + admission=$(field navigation_admitted) + case "$admission" in YES|NO) ;; *) fail 'invalid admission state' ;; esac + if test "$admission" = YES; then + test "$status" = 200 && test "$mime" = text/html && + test "$charset" = utf-8 && test "$encoding" = identity && + test "$utf8" = valid || fail 'forged navigation admission' + fi +fi + +begin=$(field arena_offset) +end=$(field arena_end) +length=$(field byte_count) +expected=$(field sha256) +for number in "$begin" "$end" "$length"; do + case "$number" in ''|*[!0123456789]*) fail 'invalid source range' ;; esac + test "${#number}" -le 18 || fail 'source range exceeds implemented bound' +done +test "$length" -gt 0 && test "$length" -le 4194304 || fail 'invalid source length' +test "$((begin + length))" -eq "$end" || fail 'receipt range mismatch' +test "${#expected}" -eq 64 || fail 'bad digest length' +case "$expected" in *[!0123456789abcdef]*) fail 'bad digest characters' ;; esac +if test "$schema" = ib-icu-arena-v2; then + test "$reported" -eq "$length" || fail 'observed received length mismatch' + if test "$declared" != -1; then + test "$declared" -eq "$length" || fail 'declared length mismatch' + fi +fi + +test -d "$root/staging" && test ! -L "$root/staging" || fail 'staging absent' +work=$(mktemp -d "$root/staging/reader.$request_id.XXXXXXXX") || fail 'private read stage failed' +cleanup() { rm -rf "$work"; } +trap cleanup EXIT HUP INT TERM +"$reader" slice "$arena" "$begin" "$length" > "$work/body" || fail 'committed range unreadable' +test "$(wc -c < "$work/body" | tr -d '[:space:]')" -eq "$length" || fail 'short source range' +observed=$(sha256sum "$work/body" | cut -d ' ' -f 1) +test "$observed" = "$expected" || fail 'source digest mismatch' +# Never emit even a prefix before the entire committed range is validated. +cat "$work/body" diff --git a/bin/ib_android_icu_arena.grease b/bin/ib_android_icu_arena.grease new file mode 100644 index 00000000..7be67465 --- /dev/null +++ b/bin/ib_android_icu_arena.grease @@ -0,0 +1,178 @@ +#!/usr/bin/env grease +# ICU #35 observed response metadata and exact body -> canonical userspace +# appendfat Arena. A receipt is published only after ICU exits successfully, +# metadata passes structural checks, and committed bytes are reopened. +set -eu +umask 077 +export LC_ALL=C + +case "$0" in */*) script_directory=${0%/*} ;; *) script_directory=. ;; esac +repository_root=$(CDPATH='' cd -- "$script_directory/.." && pwd) +. "$repository_root/lib/android_shizuku_host.grease" +. "$repository_root/lib/android_shizuku_icu_provider.grease" +. "$repository_root/lib/android_binder_icu.grease" + +fail() { printf 'ib ICU/Arena: %s\n' "$1" >&2; exit 1; } +usage() { printf 'usage: %s REQUEST_ID HTTP_URL PRIVATE_ARENA_DIRECTORY SOURCE_GENERATION\n' "$0" >&2; exit 2; } + +test "$#" -eq 4 || usage +request_id=$1 +url=$2 +root=$3 +source_generation=$4 +case "$request_id" in ''|.|..|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-]*) usage ;; esac +ib_shizuku_icu_url "$url" || usage +case "$root" in /*) ;; *) usage ;; esac +case "$source_generation" in ''|0|0*|*[!0123456789]*) usage ;; esac +test "${#source_generation}" -le 18 || usage +test -d "$root" && test ! -L "$root" || fail 'private Arena directory absent or a symlink' +root=$(CDPATH='' cd -P -- "$root" && pwd -P) +case "$(stat -c '%a' "$root")" in 700|0700) ;; *) fail 'Arena root must be owner-only mode 0700' ;; esac +for directory in "$root/staging" "$root/receipts" "$root/locks"; do + test ! -L "$directory" || fail 'symlink below Arena root' + mkdir -p "$directory" + test -d "$directory" && test ! -L "$directory" || fail 'bad Arena subdirectory' +done +arena=$root/responses.arena +for path in "$arena" "$arena.used" "$arena.lock"; do + test -f "$path" && test ! -L "$path" || fail 'Arena is not preinitialized by its owner' +done +arena_tool=${IB_APPEND_ARENA:-} +slice_tool=${IB_ARENA_SLICE:-} +test -n "$arena_tool" && test -x "$arena_tool" || fail 'canonical appendfat_arena executable required' +test -n "$slice_tool" && test -x "$slice_tool" || fail 'checked ib-arena-slice executable required' + +receipt=$root/receipts/$request_id.tsv +test ! -e "$receipt" && test ! -L "$receipt" || fail 'request identity already published' +lock=$root/locks/$request_id +mkdir "$lock" || fail 'request identity is already running' +# Lock survives failure; a failed request ID may not be silently replayed. +work=$(mktemp -d "$root/staging/$request_id.XXXXXXXX") || fail 'cannot create private staging' +cleanup() { rm -rf "$work"; } +trap cleanup EXIT HUP INT TERM +capture=$work/icu.body +metadata=$work/icu.metadata + +authority=$(ib_shizuku_authority) || fail 'Shizuku unavailable or unauthorized' +case "$authority" in shell|root) ;; *) fail 'Shizuku lacks authorized shell/root authority' ;; esac +if ! ib_binder_icu_get "$url" "$capture" "$metadata"; then + fail 'ICU capture or Shizuku failed; no Arena bytes were committed' +fi +for file in "$capture" "$metadata"; do + test -f "$file" && test ! -L "$file" || fail 'missing or linked ICU capture' +done +bytes=$(wc -c < "$capture" | tr -d '[:space:]') +case "$bytes" in ''|*[!0123456789]*) fail 'bad ICU body size' ;; esac +test "$bytes" -gt 0 && test "$bytes" -le 4194304 || fail 'ICU body empty or exceeds 4 MiB' +metadata_bytes=$(wc -c < "$metadata" | tr -d '[:space:]') +test "$metadata_bytes" -gt 0 && test "$metadata_bytes" -le 8192 || fail 'bad ICU metadata length' + +# PR #35's metadata has exactly eight named fields. Reject injected lines, +# duplicate/missing keys, control bytes, CR and arbitrary extension fields. +tr -cd '\011\012\040-\176' < "$metadata" | cmp -s - "$metadata" || + fail 'ICU metadata includes non-printable bytes' +awk -F '\t' ' + BEGIN { + count=split("http_status content_type content_encoding declared_length received_length utf8 resolved_url redirect_count", names, " ") + for (i=1;i<=count;i++) allowed[names[i]]=1 + } + NF!=2 || !allowed[$1] || $2=="" { bad=1 } + { seen[$1]++ } + END { + for (key in allowed) if (seen[key]!=1) bad=1 + if (bad || NR!=8) exit 1 + } +' "$metadata" || fail 'invalid ICU metadata structure' +field() { awk -F '\t' -v name="$1" '$1==name {print $2}' "$metadata"; } + +http_status=$(field http_status) +content_type=$(field content_type) +content_encoding=$(field content_encoding) +declared_length=$(field declared_length) +received_length=$(field received_length) +utf8=$(field utf8) +resolved_url=$(field resolved_url) +redirect_count=$(field redirect_count) + +# ICU #35 returns success only for complete 2xx responses; check its observed +# fields independently before making any body bytes reader-eligible. +case "$http_status" in 2[0-9][0-9]) ;; *) fail 'unobserved or unsuccessful HTTP status' ;; esac +ib_shizuku_icu_url "$resolved_url" || fail 'unsupported or unsafe resolved URL' +case "$received_length" in ''|*[!0123456789]*) fail 'bad observed received length' ;; esac +test "$received_length" -eq "$bytes" || fail 'ICU byte count disagrees with capture' +case "$declared_length" in + -1) ;; + ''|*[!0123456789]*) fail 'invalid declared HTTP length' ;; + *) test "$declared_length" -eq "$bytes" || fail 'incomplete HTTP body' ;; +esac +case "$redirect_count" in ''|*[!0123456789]*) fail 'invalid redirect count' ;; esac +test "${#redirect_count}" -le 2 && test "$redirect_count" -le 8 || + fail 'redirect count exceeds ICU bound' +case "$utf8" in valid|invalid) ;; *) fail 'invalid ICU UTF-8 observation' ;; esac + +# Preserve MIME and explicit charset separately. Byte-level UTF-8 validity is +# not a substitute for an observed/declared charset. +lower_type=$(printf '%s' "$content_type" | tr 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz') +mime_type=$(printf '%s' "${lower_type%%;*}" | tr -d ' ') +case "$mime_type" in + unknown) ;; + */*) case "$mime_type" in *[!abcdefghijklmnopqrstuvwxyz0123456789.+/_-]*) fail 'invalid MIME token' ;; esac ;; + *) fail 'invalid MIME type' ;; +esac +charset=UNOBSERVED +case "$lower_type" in + *';'*) + parameters=$(printf '%s' "${lower_type#*;}" | tr -d ' ') + case "$parameters" in charset=utf-8|charset=\"utf-8\") charset=utf-8 ;; esac + ;; +esac +navigation_admitted=NO +if test "$http_status" = 200 && test "$mime_type" = text/html && + test "$charset" = utf-8 && test "$content_encoding" = identity && + test "$utf8" = valid; then + navigation_admitted=YES +fi +digest=$(sha256sum "$capture" | cut -d ' ' -f 1) + +# Canonical appendfat_arena owns locking, fsync and the commit pointer. Never +# publish metadata based only on an append acknowledgment. +append_result=$("$arena_tool" append "$arena" "$capture") || fail 'Arena append failed' +old=$(printf '%s\n' "$append_result" | sed -n 's/^APPENDED old_used=\([0-9][0-9]*\) new_used=[0-9][0-9]* capacity_bytes=[0-9][0-9]*$/\1/p') +new=$(printf '%s\n' "$append_result" | sed -n 's/^APPENDED old_used=[0-9][0-9]* new_used=\([0-9][0-9]*\) capacity_bytes=[0-9][0-9]*$/\1/p') +test -n "$old" && test -n "$new" || fail 'unrecognized Arena commit receipt' +test "$new" -ge "$old" && test "$((new - old))" -eq "$bytes" || + fail 'Arena appended different byte count' +"$slice_tool" slice "$arena" "$old" "$bytes" > "$work/reopened" || + fail 'Arena committed range unreadable' +cmp -s "$capture" "$work/reopened" || fail 'Arena reopened bytes mismatch' + +{ + printf 'schema\tib-icu-arena-v2\n' + printf 'request_id\t%s\n' "$request_id" + printf 'source_key\tarena:%s\n' "$request_id" + printf 'source_generation\t%s\n' "$source_generation" + printf 'requested_url\t%s\n' "$url" + printf 'resolved_url\t%s\n' "$resolved_url" + printf 'http_status\t%s\n' "$http_status" + printf 'mime_type\t%s\n' "$mime_type" + printf 'charset\t%s\n' "$charset" + printf 'content_encoding\t%s\n' "$content_encoding" + printf 'utf8\t%s\n' "$utf8" + printf 'redirect_count\t%s\n' "$redirect_count" + printf 'declared_length\t%s\n' "$declared_length" + printf 'received_length\t%s\n' "$received_length" + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$old" + printf 'arena_end\t%s\n' "$new" + printf 'byte_count\t%s\n' "$bytes" + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'acquisition_complete\tYES\n' + printf 'navigation_admitted\t%s\n' "$navigation_admitted" +} > "$work/receipt.tsv" +"$slice_tool" sync "$work/receipt.tsv" || fail 'could not sync staged receipt' +test ! -e "$receipt" || fail 'receipt already exists before publication' +mv "$work/receipt.tsv" "$receipt" || fail 'cannot publish immutable receipt' +"$slice_tool" sync "$receipt" || fail 'could not sync published receipt' +printf 'committed\t%s\t%s\t%s\t%s\n' "$request_id" "$old" "$bytes" "$navigation_admitted" diff --git a/docs/c67-icu-arena.md b/docs/c67-icu-arena.md new file mode 100644 index 00000000..291becc0 --- /dev/null +++ b/docs/c67-icu-arena.md @@ -0,0 +1,112 @@ +# C67 ICU #35 → Shizuku → Arena integration candidate + +Status: **host-contract implementation; physical ICU/Shizuku/reader acceptance NOT_RUN**. +This is not a C67 URL-capable release or a claim that ICU #35 is merged. + +## Acquisition and provenance + +The pinned candidate API is [dilapidated-shed/icu PR #35](https://github.com/dilapidated-shed/icu/pull/35), +head commit 0da041fbe906657f4f705da45a842d96832e5ee1 at integration. +Do **not** substitute older ICU DEX/native objects: old versions write only +body stdout, not the paired ICU_CAPTURE_BODY_PATH and +ICU_CAPTURE_METADATA_PATH files required here. + + browser-owned request (future scoped Binder/PFD handoff) + → IB Binder-shaped ICU capability (current Shizuku rish lowering) + → verify deployed ICU DEX + native TLS .so against explicit SHA-256 pins + → one Idric.Generated get via Android app_process + → ICU #35 creates private paired body + metadata under the shell UID + → single bounded, length-framed rish stdout transfer + → independently validate metadata shape and captured byte count + → canonical preinitialized appendfat userspace Arena commit + → native C reopen + compare exact range with staged captured body + → immutable v2 Arena receipt; admit only qualified HTML + → Idriç HTML extraction/prepaint in IB PR #154 [NOT WIRED] + → native Android reader [NOT WIRED] + +This stays in the existing userspace sd-card-append-fat Arena, not a new +browser store, app-side HTTP fetch, WebView transport, or Java service. + +### Setup and development invocation + +The owner supplies a non-symlink private root (mode 0700), creates the +canonical responses.arena and selects checked executable paths: + + appendfat_arena create PRIVATE_DIR/responses.arena 16777216 + +Set IB_APPEND_ARENA and IB_ARENA_SLICE to the pinned compiled executables. +Set IB_C67_ICU_DEX, IB_C67_ICU_SO, IB_C67_ICU_DEX_SHA256, +IB_C67_ICU_SO_SHA256 to **actual qualified** Android app_process DEX and +ARM native ICU #35 artifacts in /data/local/tmp/ib-icu/. This PR does not +produce these artifacts or invent their SHA-256 values. Shizuku must be +started, and the caller must be authorized as shell/root. + + sh bin/ib_android_icu_arena.grease REQUEST_ID https://example.com/ PRIVATE_DIR SOURCE_GENERATION + +SOURCE_GENERATION is a nonzero canonical decimal integer (max 18 digits) +allocated by the browser/source owner, **not inferred from HTTP**. The +caller remains responsible for ordering generations and binding user intent; +this development command is not an app IPC contract. + +The Shizuku UID cannot write directly into the application's private Arena. +Its one trusted shell invocation makes a random mode-0700 staging directory +inside the pinned /data/local/tmp/ib-icu/, passes both ICU capture paths +through environment variables, then transports a single byte-counted packet: + + IBICU01 METADATA_BYTES BODY_BYTES\n + [exact eight-field ICU metadata] + [exact binary response body] + +The packet header contains two eight-digit decimal byte lengths and a +newline. Metadata is limited to 8192 bytes; the body to 4 MiB, stricter +than ICU's 8 MiB native limit. The untrusted packet and staging files are +discarded on failures. The reader never sees a partial packet. + +## Receipt versions and navigation policy + +ib-icu-arena-v1 is still safely readable as verified **body-only** data +with UNOBSERVED status, MIME and final URL. It **cannot** be upgraded into +navigation by guessing those fields. + +ib-icu-arena-v2 records exactly 23 named fields including: + +- Request identity, source_key=arena:REQUEST_ID, source_generation. +- Requested URL and **ICU-observed** final resolved_url plus redirect_count. +- HTTP status, MIME type, explicit charset, content_encoding, ICU-reported + utf8, declared_length and received_length. +- Canonical arena_offset, arena_end, byte_count and sha256. +- reopen_check=MATCH, acquisition_complete=YES, navigation_admitted. + +The producer checks the exact eight-field upstream ICU metadata schema, +received byte count, declared Content-Length (if present), redirect limit, +URL syntax, nonzero 2xx status, and independently reopened Arena range. + +navigation_admitted=YES currently requires **all** of: HTTP 200, +text/html, explicitly declared charset=utf-8, identity content +encoding, valid ICU UTF-8 observation, complete acquisition, intact +reopened bytes, source key/generation and correct SHA-256. Unsupported +text encodings, unknown MIME, missing charset, compressed undecoded bodies +and non-HTML responses remain NO; they are not converted to prepaint. +The reader independently rechecks the v2 admission predicate before +emitting even a byte. The external IB #154 adapter should still check +the receipt and call the verified reader, not bypass it. + +The receipt is published only after append, native reopen and body compare. +Failure after append but before receipt leaves only an unreferenced tail; +no browser history is updated. Each request ID is single-use with a retained +per-request lock even on failure. + +## Evidence and unfinished qualification + +The workflow uses the **real pinned canonical appendfat_arena** and native +range reader. It mocks exactly the Shizuku command's framed stdout, +including redirect metadata, verifies v1/v2 reading, original binary bytes, +and deliberate missing/duplicate/forged metadata, corrupt digest and +commit-pointer refusals. ICU #35 has its own **separate** real native +redirect integration tests; passing IB host mocks is not passing that test. + +Still **NOT_RUN** here: pinned ICU #35 Android DEX/ARM32 build and installed +artifact digests, actual TLS HTTPS and Shizuku invocation on C67, cross-UID +scoped Binder/PFD into the app, end-to-end #154 Idriç conversion, Material +reader URL entry and clicked links, C67 durable history/restart. +Coordinate IB #151, #142, #153, #154, #129 and ICU #35 before merge/release. diff --git a/docs/c67-reader-pfd.md b/docs/c67-reader-pfd.md new file mode 100644 index 00000000..44b897f0 --- /dev/null +++ b/docs/c67-reader-pfd.md @@ -0,0 +1,139 @@ +# IB C67: private Binder/PFD reader handoff (bounded receiver) + +**Status:** native Binder provider/receiver, verified Arena source-owner callback and app-host assembly, with hosted fixture tests. No phone APK. +This branch is stacked on IB PR #152, which stores checked ICU body bytes in +the existing userspace appendfat Arena. **No URL-capable APK, provider service, +Idriç screen connection, or physical C67 Binder execution is claimed.** + +## Required connection + + Idriç browser-owned navigation epoch + source identity + → Shizuku-authorized ICU worker (no Android-reader HTTP) + → exact captured bytes → checked appendfat Arena source + → source-owner checks admitted v2 receipt + matching navigation epoch + → independently reopens and hashes committed bytes + → private app-scoped Binder provider: OPEN_COMMITTED_SOURCE(request,epoch) + → one read-only, per-open ParcelFileDescriptor (not Arena file FD) + → native reader verifies exact stream length + SHA-256 + epoch + → source-linked extraction / Idriç Prepaint → existing native views + +The eventual app-scoped source owner must first consume the **real** committed source using +bin/ib_android_arena_read.grease (which does not emit bytes until its +digest check passes). Materialize the verified bounded source into a +fresh **read-only anonymous pipe** whose descriptor, rather than a path, is transferred. A backing Arena file descriptor, writable descriptor or socket is refused. +Never hand out the appendfat Arena's descriptor or rely on a shared +/data/local/tmp response. Never give the reader a network client. + +Private Binder implementation contract, transaction code **1**: + +- Request parcel: validated UTF-8 request ID and signed 64-bit navigation + generation. A real provider authorizes Binder calling UID against an + explicit same-signer/allowed-reader identity before opening a source. + Missing/denied authorization returns a nonzero status and no descriptor. +- Reply parcel: int32 status **0**, source byte_count (int32), 64-character + lowercase source SHA-256, then a ParcelFileDescriptor with **only** + the already checked committed source's bytes. Provider closes its write + side after the declared byte count. A matching, admitted v2 receipt is necessary but does not + replace the browser's navigation authorization. +- Browser-held admission ticket: request ID, generation, byte count + (1–4194304) and lowercase SHA-256 copied from the previously checked + immutable source receipt, **never from untrusted page content**. The + ticket must be authenticated at the broker/reader boundary. The client + also rejects any length or SHA difference between its browser-held ticket + and the provider's receipt-derived reply metadata before accepting the FD. + For a + matching ticket, the receiver verifies the entire stream, checks for EOF, + and compares SHA-256 before replacing the last displayed source. +- A newer browser navigation updates the expected generation. Late results, + corrupt/replayed metadata, malformed IDs, extra/short input, over-limit + bodies, reader/provider death and stalled streams are refused. The + existing last-good snapshot remains owned by the reader. + +native/android/ib_arena_pfd_provider.{h,c} implement a narrowly UID-gated +**serving** Binder object (the host supplies its verified-source callback). +The object must be handed privately to the checked reader by the app host: +no global Binder service registration is used here. The callback is now implemented in native/android/ib_arena_source_owner.{h,c}: +it opens the app's existing 0700 Arena root, reads an immutable source receipt, +checks canonical .used and locked range, independently rehashes the complete +body, and emits an anonymous read-only pipe from a bounded writer thread. +It never returns the Arena's file descriptor. Host assembly wiring in +native/android/ib_reader_host_assembly.{h,c} binds that callback to the +private Binder provider object. **The app lifecycle has not yet instantiated +this assembly, and no Binder reference has been delivered to the Idriç DEX.** + +native/android/ib_reader_pfd.{h,c} implement the **receiving** C11 +boundary. On Android API 29+ they use NDK Binder parcel transactions and +AParcel_readParcelFileDescriptor. The consumer does not discover a global +service, request Shizuku permission, decide URLs, parse HTML, or publish +history. It requires an already authorized provider Binder handle supplied +by the host adapter. This is compatible with Android 14/SDK 34 on A1/C67; +older Android API 28 is not yet supported by this native Binder adapter. + +This is **not** a second canonical store. The C receiver holds one +replaceable in-memory candidate. The browser-owned committed source and +navigation journal continue to reside in existing IB/Cauldron/Arena. +Raw bytes remain binary until source verification and the existing Idriç +extraction/Prepaint decision. It is not correct to render raw HTML as if it +were already the semantic prepaint projection. + +## Source and validation + +test_android_reader_pfd.c uses two real Linux processes and transfers an +anonymous pipe descriptor via SCM_RIGHTS. This checks the exact native +consumer's binary fidelity, known SHA-256 vectors, good-source retention, +and fail-closed refusal on corruption, short/extra data, bad IDs, +over-limit tickets and stale navigation generations. **SCM_RIGHTS is a +host stand-in for descriptor ownership, not a claim of tested Android +Binder delivery or tested cross-UID Android authority.** + +The second host test constructs a real pinned sd-card-append-fat Arena, +appends an admitted UTF-8 HTML fixture using its canonical append command, +publishes a complete v2 receipt and a legacy v1 refusal fixture, then drives the actual C source owner and reader +verifiers end-to-end. It mutates receipts, source bytes, commit pointer, +generation, root permissions and path identity, requiring refusal and +last-good preservation. This is still Linux-hosted source admission. + +The independent Android CI lane cross-compiles actual NDK Binder calls, +source-owner assembly and pipe writer for ARM32 and ARM64 and retains digests. Cross-compilation does +**not** prove the provider exists, the Idriç DEX can call this library, +or that an Android Binder transaction has happened. + +## Required next integration + +1. Instantiate the newly connected native host assembly in the existing + Android application lifecycle, resolve the real reader UID from trusted + package identity and privately supply its Binder handle to the checked + Idriç DEX. The assembly cannot use root paths owned by Termux or shell: + add the separate authorized Shizuku-worker-to-app-private-Arena ingress + through scoped IPC. Shell UID 2000 cannot read the app's private Arena. +2. Bind the native C receiver to the **existing checked Idriç Material + reader** (#129/#147), with qualified DEX/native FFI and lifecycle. + Do not introduce Java/Kotlin application logic or a replacement UI. +3. The native source owner now requires v2 admission, observed HTTP status, + resolved URL, MIME, charset and a source-generation/epoch match. + IB #152 host tests passed, but real C67 ICU/TLS remains NOT_RUN. + Navigation ordering and link events remain owned by Idriç. +4. Device-check denied/missing Shizuku, private reader UID, real ICU + HTTPS, committed Arena and readback digest, actual Binder PFD + transaction, page presentation, copying text, replacement/restart + recovery and late/out-of-order completions on the physical C67. + Mark each absent test NOT_RUN. + +Existing source owners: IB #151, #142, #141, #97; IB PR #112 +(Binder-first host), #129/#147 (checked Android reader), #152 +(ICU/Arena). ICU PR #35 owns real resolved-URL/redirect metadata. + +## ICU #35 / IB #152 v2 admission invariant + +Native PFD publication requires exactly 23 receipt fields, schema +`ib-icu-arena-v2`, `source_key=arena:REQUEST_ID`, source_generation +matching the requested epoch, observed HTTP 200, `text/html`, explicit +`utf-8` charset, identity content encoding, ICU UTF-8 validity, +`acquisition_complete=YES`, `reopen_check=MATCH` and +`navigation_admitted=YES`. Reopened canonical Arena body length and SHA-256 +must match. v1 UNOBSERVED receipts are always refused by this native source +owner, including when their body digests are correct. + +These changes are host candidates until exact-head CI and independent AI-CI +acceptance. The host fixture is not live ICU on C67; physical Shizuku, +cross-UID worker ingress, Binder/Idriç DEX registration and UI remain NOT_RUN. diff --git a/lib/android_binder_icu.grease b/lib/android_binder_icu.grease new file mode 100644 index 00000000..bb1f819a --- /dev/null +++ b/lib/android_binder_icu.grease @@ -0,0 +1,5 @@ +# Selected Binder capability: ICU acquisition. Shizuku currently supplies this +# capability, but a browser/reader never invokes rish or opens network sockets. +ib_binder_icu_get() { + ib_shizuku_icu_get "$@" +} diff --git a/lib/android_shizuku_host.grease b/lib/android_shizuku_host.grease new file mode 100644 index 00000000..7a735f4b --- /dev/null +++ b/lib/android_shizuku_host.grease @@ -0,0 +1,232 @@ +# Grease source. Android privilege acquisition belongs to Shizuku; this file +# adapts its rish shell to IB's operating-system work without making browser +# semantics depend on Shizuku. +# +# ib_shizuku_run accepts trusted IB/Grease command text. It is not a quoting or +# authorization boundary for arbitrary user-supplied strings. + +ib_shizuku_fail() { + printf '%s\n' "$*" >&2 + return 1 +} + +ib_shizuku_rish_command() { + if test -n "${IB_RISH_COMMAND:-}"; then + if ! test -x "$IB_RISH_COMMAND"; then + ib_shizuku_fail "rish is not executable: $IB_RISH_COMMAND" + return 1 + fi + printf '%s\n' "$IB_RISH_COMMAND" + return 0 + fi + + rish_command=$(command -v rish) || { + ib_shizuku_fail 'rish is not available; start Shizuku and put rish on PATH or set IB_RISH_COMMAND' + return 1 + } + printf '%s\n' "$rish_command" +} + +ib_shizuku_run() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_run expects exactly one trusted command string' + return 2 + fi + + command_text=$1 + rish_command=$(ib_shizuku_rish_command) || return 1 + + # rish passes -c directly to the remote Android shell. Its exit status is the + # remote shell/command status and must remain visible to IB. + "$rish_command" -c "$command_text" +} + +ib_shizuku_run_bounded() { + if test "$#" -ne 2; then + ib_shizuku_fail 'ib_shizuku_run_bounded expects SECONDS and one trusted command string' + return 2 + fi + + seconds=$1 + command_text=$2 + case "$seconds" in + ''|*[!0-9]*|0) + ib_shizuku_fail "invalid timeout seconds: $seconds" + return 2 + ;; + esac + + timeout_command=$(command -v timeout) || { + ib_shizuku_fail 'timeout is not available; refusing an unbounded Shizuku operation' + return 1 + } + rish_command=$(ib_shizuku_rish_command) || return 1 + + # The caller is bounded even if an Android service stalls. Preserve timeout's + # status (normally 124) rather than translating it into success. + "$timeout_command" "$seconds" "$rish_command" -c "$command_text" +} + +ib_shizuku_effective_uid() { + ib_shizuku_run 'id -u' +} + +ib_shizuku_authority() { + uid=$(ib_shizuku_effective_uid) || return 1 + case "$uid" in + 0) + printf 'root\n' + ;; + 2000) + printf 'shell\n' + ;; + *) + printf 'uid:%s\n' "$uid" + ;; + esac +} + +ib_shizuku_identity() { + ib_shizuku_run 'printf "identity\\t"; id; printf "selinux\\t"; id -Z 2>/dev/null || printf "unavailable\\n"' +} + +ib_shizuku_process_snapshot() { + # Keep this first process view deliberately close to Android's own shell + # surface. Typed process records can be added after physical-device evidence + # establishes the stable fields on the target. + ib_shizuku_run 'ps -A' +} + +ib_shizuku_validate_pid() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_validate_pid expects PID' + return 2 + fi + case "$1" in + ''|*[!0-9]*|0|1) + ib_shizuku_fail "invalid pid: $1" + return 2 + ;; + esac +} + +ib_shizuku_validate_start_ticks() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_validate_start_ticks expects one value' + return 2 + fi + case "$1" in + ''|*[!0-9]*) + ib_shizuku_fail "invalid process start identity: $1" + return 2 + ;; + esac +} + +ib_shizuku_validate_process_name() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_validate_process_name expects one value' + return 2 + fi + case "$1" in + ''|*[!A-Za-z0-9._:+-]*) + ib_shizuku_fail "invalid process name: $1" + return 2 + ;; + esac +} + +ib_shizuku_process_identity() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_process_identity expects PID' + return 2 + fi + pid=$1 + ib_shizuku_validate_pid "$pid" || return 2 + + ib_shizuku_run "pid=$pid; stat=/proc/\$pid/stat; comm=/proc/\$pid/comm; test -r \"\$stat\" && test -r \"\$comm\" || exit 44; tail=\$(sed 's/^[0-9][0-9]* (.*) //' \"\$stat\") || exit 44; start=\$(printf '%s\\n' \"\$tail\" | awk '{print \$20}'); name=\$(tr -d '\\r\\n' <\"\$comm\"); test -n \"\$start\" && test -n \"\$name\" || exit 44; printf '%s\\t%s\\t%s\\n' \"\$pid\" \"\$start\" \"\$name\"" +} + +# A stale PID alone is never authority to signal a process. The caller must +# supply the process start identity and comm value captured when that owned +# process was launched/observed. The remote check and kill happen in one shell +# invocation so PID reuse between a separate check and signal cannot silently +# target an unrelated process. +ib_shizuku_signal_owned() { + if test "$#" -lt 3 || test "$#" -gt 4; then + ib_shizuku_fail 'ib_shizuku_signal_owned expects PID START_TICKS PROCESS_NAME and optional SIGNAL' + return 2 + fi + + pid=$1 + expected_start=$2 + expected_name=$3 + signal=${4:-TERM} + + ib_shizuku_validate_pid "$pid" || return 2 + ib_shizuku_validate_start_ticks "$expected_start" || return 2 + ib_shizuku_validate_process_name "$expected_name" || return 2 + + case "$signal" in + ''|*[!A-Za-z0-9]*) + ib_shizuku_fail "invalid signal: $signal" + return 2 + ;; + esac + + ib_shizuku_run "pid=$pid; expected_start=$expected_start; expected_name=$expected_name; stat=/proc/\$pid/stat; comm=/proc/\$pid/comm; test -r \"\$stat\" && test -r \"\$comm\" || exit 44; tail=\$(sed 's/^[0-9][0-9]* (.*) //' \"\$stat\") || exit 44; observed_start=\$(printf '%s\\n' \"\$tail\" | awk '{print \$20}'); observed_name=\$(tr -d '\\r\\n' <\"\$comm\"); test \"\$observed_start\" = \"\$expected_start\" || exit 45; test \"\$observed_name\" = \"\$expected_name\" || exit 46; kill -$signal \"\$pid\"" +} + +# Deliberately reject the old PID-only operation. Supervisor and test code must +# use ib_shizuku_signal_owned so unrelated processes are not valid targets. +ib_shizuku_signal() { + ib_shizuku_fail 'PID-only signaling is refused; use ib_shizuku_signal_owned with a recorded start identity' + return 2 +} + +ib_shizuku_validate_package_name() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_validate_package_name expects one package name' + return 2 + fi + + package_name=$1 + case "$package_name" in + ''|*[!A-Za-z0-9._]*) + ib_shizuku_fail "invalid Android package name: $package_name" + return 2 + ;; + esac +} + +ib_shizuku_package_pids() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_package_pids expects one package name' + return 2 + fi + + package_name=$1 + ib_shizuku_validate_package_name "$package_name" || return 2 + ib_shizuku_run "pidof $package_name" +} + +ib_shizuku_force_stop_package() { + if test "$#" -ne 1; then + ib_shizuku_fail 'ib_shizuku_force_stop_package expects one package name' + return 2 + fi + + package_name=$1 + ib_shizuku_validate_package_name "$package_name" || return 2 + + fault_target=${IB_SHIZUKU_FAULT_TARGET_PACKAGE:-} + if test -z "$fault_target" || test "$fault_target" != "$package_name"; then + ib_shizuku_fail 'package force-stop is restricted to the explicit fault-injection target' + return 2 + fi + + # am is ActivityManager's shell-facing control tool. This operation exists + # for explicit physical acceptance fault injection, not arbitrary package + # control from browser/model/page data. + ib_shizuku_run "am force-stop $package_name" +} diff --git a/lib/android_shizuku_icu_provider.grease b/lib/android_shizuku_icu_provider.grease new file mode 100644 index 00000000..e3292c84 --- /dev/null +++ b/lib/android_shizuku_icu_provider.grease @@ -0,0 +1,129 @@ +# Shizuku-specific ICU Android app_process lowering. +# PR dilapidated-shed/icu#35: one checked request captures binary body and +# observed HTTP metadata together. Both originate under the shell UID; this +# adapter transports one bounded, explicitly framed stream back to the owner. +# IB never treats unverified rish stdout as a page or metadata as a guess. + +ib_shizuku_icu_atom_path() { + case "$1" in + /data/local/tmp/ib-icu/*) ;; + *) return 2 ;; + esac + case "$1" in + *..*|*[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._/-]*) return 2 ;; + esac +} + +ib_shizuku_icu_sha256() { + test "${#1}" -eq 64 || return 2 + case "$1" in ''|*[!0123456789abcdef]*) return 2 ;; esac +} + +ib_shizuku_icu_url() { + test "${#1}" -gt 8 && test "${#1}" -le 2048 || return 2 + case "$1" in http://*|https://*) ;; *) return 2 ;; esac + case "$1" in *"'"*) return 2 ;; esac + printf '%s' "$1" | LC_ALL=C grep -Eq '^[!-&(-~]+$' +} + +# The frame is "IBICU01 %08d %08d\n" followed by exact metadata bytes and +# exact body bytes. A single rish invocation owns ICU's temporary output files. +# No consumer is allowed to parse raw body bytes as lines or infer HTTP fields. +ib_shizuku_icu_get() { + if test "$#" -ne 3; then + printf 'ib_shizuku_icu_get requires URL BODY_OUTPUT METADATA_OUTPUT\n' >&2 + return 2 + fi + icu_url=$1 + icu_output=$2 + icu_metadata=$3 + icu_dex=${IB_C67_ICU_DEX:-} + icu_so=${IB_C67_ICU_SO:-} + icu_dex_sha=${IB_C67_ICU_DEX_SHA256:-} + icu_so_sha=${IB_C67_ICU_SO_SHA256:-} + ib_shizuku_icu_url "$icu_url" || { printf 'unsupported or unsafe ICU URL\n' >&2; return 2; } + ib_shizuku_icu_atom_path "$icu_dex" || { printf 'unapproved ICU DEX path\n' >&2; return 2; } + ib_shizuku_icu_atom_path "$icu_so" || { printf 'unapproved ICU native path\n' >&2; return 2; } + ib_shizuku_icu_sha256 "$icu_dex_sha" || { printf 'missing pinned ICU DEX hash\n' >&2; return 2; } + ib_shizuku_icu_sha256 "$icu_so_sha" || { printf 'missing pinned ICU native hash\n' >&2; return 2; } + test "$icu_output" != "$icu_metadata" || return 2 + test ! -e "$icu_output" && test ! -L "$icu_output" || { printf 'reused ICU body path\n' >&2; return 2; } + test ! -e "$icu_metadata" && test ! -L "$icu_metadata" || { printf 'reused ICU metadata path\n' >&2; return 2; } + + # Validate all interpolated atoms before assembling a shell command. The + # response URL is the only untrusted interpolation and cannot contain '. + command_text="set -eu + umask 077 + test -d /data/local/tmp/ib-icu && test ! -L /data/local/tmp/ib-icu || exit 78 + test \"\$(sha256sum '$icu_dex' | cut -d ' ' -f 1)\" = '$icu_dex_sha' || exit 78 + test \"\$(sha256sum '$icu_so' | cut -d ' ' -f 1)\" = '$icu_so_sha' || exit 78 + icu_work=\$(mktemp -d /data/local/tmp/ib-icu/capture.XXXXXXXX) || exit 79 + trap 'rm -rf \"\$icu_work\"' EXIT HUP INT TERM + export ICU_CAPTURE_BODY_PATH=\"\$icu_work/body\" + export ICU_CAPTURE_METADATA_PATH=\"\$icu_work/metadata\" + export CLASSPATH='$icu_dex' + app_process -Dicu.library='$icu_so' /system/bin Idric.Generated get '$icu_url' >/dev/null + test -f \"\$ICU_CAPTURE_BODY_PATH\" && test ! -L \"\$ICU_CAPTURE_BODY_PATH\" || exit 80 + test -f \"\$ICU_CAPTURE_METADATA_PATH\" && test ! -L \"\$ICU_CAPTURE_METADATA_PATH\" || exit 80 + metadata_bytes=\$(wc -c < \"\$ICU_CAPTURE_METADATA_PATH\" | tr -d '[:space:]') + body_bytes=\$(wc -c < \"\$ICU_CAPTURE_BODY_PATH\" | tr -d '[:space:]') + test \"\$metadata_bytes\" -gt 0 && test \"\$metadata_bytes\" -le 8192 || exit 81 + test \"\$body_bytes\" -gt 0 && test \"\$body_bytes\" -le 4194304 || exit 81 + printf 'IBICU01 %08d %08d\n' \"\$metadata_bytes\" \"\$body_bytes\" + cat \"\$ICU_CAPTURE_METADATA_PATH\" + cat \"\$ICU_CAPTURE_BODY_PATH\"" + + # Staged packet never enters Arena. App stdout cannot be used as metadata; + # it is discarded by the remote command. A failed rish exits before publish. + local_packet_directory=$(mktemp -d "$icu_output.packet.XXXXXXXX") || return 1 + local_packet="$local_packet_directory/packet" + if ! (umask 077; ulimit -f 8192; ib_shizuku_run_bounded 30 "$command_text" > "$local_packet"); then + rm -rf "$local_packet_directory" + return 1 + fi + packet_header=$(head -c 26 "$local_packet") || { + rm -rf "$local_packet_directory" + return 1 + } + case "$packet_header" in + 'IBICU01 '????????' '????????) ;; + *) rm -rf "$local_packet_directory"; return 1 ;; + esac + packet_sizes=${packet_header#IBICU01 } + metadata_padded=${packet_sizes%% *} + body_padded=${packet_sizes#* } + case "$metadata_padded$body_padded" in *[!0123456789]*) rm -rf "$local_packet_directory"; return 1 ;; esac + metadata_bytes=$(printf '%s' "$metadata_padded" | sed 's/^0*//') + body_bytes=$(printf '%s' "$body_padded" | sed 's/^0*//') + test -n "$metadata_bytes" || metadata_bytes=0 + test -n "$body_bytes" || body_bytes=0 + if test "$metadata_bytes" -le 0 || test "$metadata_bytes" -gt 8192 || + test "$body_bytes" -le 0 || test "$body_bytes" -gt 4194304 || + test "$(wc -c < "$local_packet" | tr -d '[:space:]')" -ne "$((26 + metadata_bytes + body_bytes))"; then + rm -rf "$local_packet_directory" + return 1 + fi + + # Use byte offsets, not line splitting: ICU's body can contain arbitrary + # binary data, including NUL, CRLF and non-UTF-8 bytes. + tail -c +27 "$local_packet" | head -c "$metadata_bytes" > "$local_packet_directory/metadata" || { + rm -rf "$local_packet_directory"; return 1; + } + tail -c +"$((27 + metadata_bytes))" "$local_packet" > "$local_packet_directory/body" || { + rm -rf "$local_packet_directory"; return 1; + } + if test "$(wc -c < "$local_packet_directory/metadata" | tr -d '[:space:]')" -ne "$metadata_bytes" || + test "$(wc -c < "$local_packet_directory/body" | tr -d '[:space:]')" -ne "$body_bytes"; then + rm -rf "$local_packet_directory" + return 1 + fi + mv "$local_packet_directory/metadata" "$icu_metadata" || { + rm -rf "$local_packet_directory"; return 1; + } + mv "$local_packet_directory/body" "$icu_output" || { + rm -f "$icu_metadata" + rm -rf "$local_packet_directory" + return 1 + } + rm -rf "$local_packet_directory" +} diff --git a/native/android/ib_arena_pfd_provider.c b/native/android/ib_arena_pfd_provider.c new file mode 100644 index 00000000..0f9a26d7 --- /dev/null +++ b/native/android/ib_arena_pfd_provider.c @@ -0,0 +1,163 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_arena_pfd_provider.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define REQUEST_MAX_BYTES 64 +#define OPEN_COMMITTED_SOURCE_TRANSACTION 1U + +typedef struct { + char request_id[REQUEST_MAX_BYTES + 1U]; + int32_t provided_capacity; +} parsed_request; + +static bool allocate_request(void *userdata, int32_t length, char **buffer) { + parsed_request *request = (parsed_request *)userdata; + if (!request || !buffer || length <= 0 || + length > (int32_t)sizeof(request->request_id)) + return false; + request->provided_capacity = length; + *buffer = request->request_id; + return true; +} + +static bool valid_id(const parsed_request *request) { + if (!request || request->provided_capacity <= 1) return false; + size_t length = strnlen(request->request_id, + sizeof(request->request_id)); + if (length == 0 || length >= sizeof(request->request_id) || + length + 1U != (size_t)request->provided_capacity) + return false; + for (size_t index = 0; index < length; ++index) { + unsigned char c = (unsigned char)request->request_id[index]; + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + c == '.' || c == '_' || c == '-')) + return false; + } + return true; +} + +static void *created(void *userdata) { + return userdata; +} + +static void destroyed(void *userdata) { + /* The trusted application host owns this configuration throughout the + * lifetime of the bound private Binder service. */ + (void)userdata; +} + +static binder_status_t respond(AParcel *out, int32_t error) { + return AParcel_writeInt32(out, error); +} + +static binder_status_t transact(AIBinder *binder, transaction_code_t code, + const AParcel *in, AParcel *out) { + if (code != OPEN_COMMITTED_SOURCE_TRANSACTION) + return STATUS_UNKNOWN_TRANSACTION; + ib_arena_pfd_provider *provider = + (ib_arena_pfd_provider *)AIBinder_getUserData(binder); + if (!provider || !provider->open_verified_fd) + return respond(out, 4); + + /* Read access is explicitly app-scoped; UID 2000 running ICU is not a + * reader and has no implicit authority over private retained sources. */ + if (AIBinder_getCallingUid() != provider->allowed_reader_uid) + return respond(out, 1); + + parsed_request request = {{0}, 0}; + int64_t generation = 0; + binder_status_t status = AParcel_readString(in, &request, + allocate_request); + if (status != STATUS_OK) + return respond(out, 2); + status = AParcel_readInt64(in, &generation); + if (status != STATUS_OK || generation <= 0 || !valid_id(&request)) + return respond(out, 2); + + int descriptor = -1; + ib_reader_source_ticket ticket = {0}; + int result = provider->open_verified_fd(provider->source_owner, + request.request_id, + (uint64_t)generation, + &ticket, + &descriptor); + if (result != 0 || descriptor < 0) { + if (descriptor >= 0) close(descriptor); + return respond(out, 3); + } + /* The callback must return metadata from the exact verified receipt. + * Never trust a caller's URL or claim navigation based on source bytes. */ + if (strcmp(ticket.request_id, request.request_id) != 0 || + ticket.navigation_generation != (uint64_t)generation || + ticket.byte_count == 0 || + ticket.byte_count > IB_READER_SOURCE_MAX_BYTES || + strnlen(ticket.sha256_hex, sizeof(ticket.sha256_hex)) != 64U) { + close(descriptor); + return respond(out, 3); + } + for (size_t index = 0; index < 64U; ++index) { + char digit = ticket.sha256_hex[index]; + if (!((digit >= '0' && digit <= '9') || + (digit >= 'a' && digit <= 'f'))) { + close(descriptor); + return respond(out, 3); + } + } + /* Never parcel a file-backed Arena descriptor or a writable capability. + * A fresh read-only anonymous pipe is the only admitted publication. */ + struct stat info; + int flags = fcntl(descriptor, F_GETFL); + if (flags < 0 || fstat(descriptor, &info) != 0 || + !S_ISFIFO(info.st_mode) || (flags & O_ACCMODE) != O_RDONLY) { + close(descriptor); + return respond(out, 3); + } + status = respond(out, 0); + if (status == STATUS_OK) + status = AParcel_writeInt32(out, (int32_t)ticket.byte_count); + if (status == STATUS_OK) + status = AParcel_writeString(out, ticket.sha256_hex, 64); + if (status == STATUS_OK) + status = AParcel_writeParcelFileDescriptor(out, descriptor); + close(descriptor); + return status; +} + +static pthread_once_t binder_class_once = PTHREAD_ONCE_INIT; +static AIBinder_Class *binder_class = NULL; + +static void initialize_binder_class(void) { + binder_class = AIBinder_Class_define( + "org.isomorphisms.ib.ArenaCommittedSource.v1", + created, destroyed, transact + ); +} + +const AIBinder_Class *ib_arena_pfd_provider_class(void) { + (void)pthread_once(&binder_class_once, initialize_binder_class); + return binder_class; +} + +AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider) { + if (!provider || !provider->open_verified_fd || + provider->allowed_reader_uid < (uid_t)10000) + return NULL; + const AIBinder_Class *binder_class = ib_arena_pfd_provider_class(); + if (!binder_class) return NULL; + return AIBinder_new(binder_class, provider); +} +#endif diff --git a/native/android/ib_arena_pfd_provider.h b/native/android/ib_arena_pfd_provider.h new file mode 100644 index 00000000..5037c4fa --- /dev/null +++ b/native/android/ib_arena_pfd_provider.h @@ -0,0 +1,36 @@ +#ifndef IB_ANDROID_ARENA_PFD_PROVIDER_H +#define IB_ANDROID_ARENA_PFD_PROVIDER_H + +#include +#include "ib_reader_pfd.h" +#include + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* Source owner, not the shell process, supplies an already checked fresh FD + * containing exactly one committed source. open_verified_fd may NOT return a + * raw descriptor of the entire canonical Arena, a path or a network socket. */ +typedef int (*ib_open_committed_source)(void *source_owner, + const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, + int *owned_read_fd); + +typedef struct { + uid_t allowed_reader_uid; /* checked application UID, not shell */ + ib_open_committed_source open_verified_fd; + void *source_owner; +} ib_arena_pfd_provider; + +/* Construct a private app-owned Binder object. The app's trusted host must + * deliver this capability to its allowed reader; it is NOT registered with + * the global Android service manager. Provider configuration must outlive + * the returned Binder object. Caller releases returned Binder reference. */ +AIBinder *ib_arena_pfd_provider_new(ib_arena_pfd_provider *provider); + +/* Local service and remote proxy must associate the same Binder interface + * class before sending typed transactions. */ +const AIBinder_Class *ib_arena_pfd_provider_class(void); +#endif +#endif diff --git a/native/android/ib_arena_slice.c b/native/android/ib_arena_slice.c new file mode 100644 index 00000000..7341504f --- /dev/null +++ b/native/android/ib_arena_slice.c @@ -0,0 +1,148 @@ +#define _POSIX_C_SOURCE 200809L +#define _FILE_OFFSET_BITS 64 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef O_NOFOLLOW +#error "This reader requires O_NOFOLLOW" +#endif +#ifndef O_DIRECTORY +#error "This reader requires O_DIRECTORY" +#endif + +#define MAX_SOURCE_BYTES (4U * 1024U * 1024U) +#define BUFFER_BYTES 65536U + +static int error(const char *what) { + fprintf(stderr, "ib-arena-slice: %s: %s\n", what, strerror(errno)); + return 1; +} + +static int number(const char *text, uint64_t *out) { + char *end; + uintmax_t parsed; + if (*text == '\0' || *text == '-' || *text == '+') return -1; + errno = 0; + parsed = strtoumax(text, &end, 10); + if (errno != 0 || *end != '\0' || parsed > INT64_MAX) return -1; + *out = (uint64_t)parsed; + return 0; +} + +static char *suffix(const char *path, const char *extension) { + size_t a = strlen(path), b = strlen(extension); + if (a > SIZE_MAX - b - 1) return NULL; + char *name = malloc(a + b + 1); + if (!name) return NULL; + memcpy(name, path, a); + memcpy(name + a, extension, b + 1); + return name; +} + +static int read_used(int fd, uint64_t *used) { + char buffer[64]; + ssize_t n; + do { n = read(fd, buffer, sizeof(buffer) - 1); } + while (n < 0 && errno == EINTR); + if (n <= 0 || n >= (ssize_t)sizeof(buffer) - 1) return -1; + buffer[n] = '\0'; + if (buffer[n - 1] != '\n') return -1; + buffer[n - 1] = '\0'; + return number(buffer, used); +} + +static int write_all(const unsigned char *bytes, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t n = write(STDOUT_FILENO, bytes + offset, length - offset); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return -1; + offset += (size_t)n; + } + return 0; +} + +static int dump_slice(const char *arena, uint64_t offset, uint64_t length) { + char *lock_path = suffix(arena, ".lock"); + char *used_path = suffix(arena, ".used"); + int lock = -1, descriptor = -1, used_fd = -1, result = 1; + unsigned char buffer[BUFFER_BYTES]; + struct stat info; + uint64_t committed; + if (!lock_path || !used_path) goto end; + lock = open(lock_path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (lock < 0 || flock(lock, LOCK_SH) != 0) goto end; + descriptor = open(arena, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + used_fd = open(used_path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (descriptor < 0 || used_fd < 0) goto end; + if (fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) || + read_used(used_fd, &committed) != 0 || + info.st_size < 0 || committed > (uint64_t)info.st_size || + length > MAX_SOURCE_BYTES || offset > committed || length > committed - offset) { + errno = EINVAL; + goto end; + } + while (length) { + size_t take = length < BUFFER_BYTES ? (size_t)length : BUFFER_BYTES; + ssize_t n = pread(descriptor, buffer, take, (off_t)offset); + if (n < 0 && errno == EINTR) continue; + if (n <= 0 || write_all(buffer, (size_t)n) != 0) { + errno = EIO; + goto end; + } + offset += (uint64_t)n; + length -= (uint64_t)n; + } + result = 0; +end: + if (result) error("invalid or unreadable committed arena slice"); + if (used_fd >= 0) close(used_fd); + if (descriptor >= 0) close(descriptor); + if (lock >= 0) close(lock); + free(lock_path); + free(used_path); + return result; +} + +static int sync_path_and_directory(const char *path) { + int fd = -1, directory_fd = -1, result = 1; + char *parent = strdup(path); + if (!parent) return error("could not allocate parent pathname"); + char *slash = strrchr(parent, '/'); + if (slash == parent) parent[1] = '\0'; + else if (slash) *slash = '\0'; + else { free(parent); parent = strdup("."); } + if (!parent) return error("could not allocate current directory"); + fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0 || fsync(fd) != 0) goto done; + directory_fd = open(parent, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory_fd < 0 || fsync(directory_fd) != 0) goto done; + result = 0; +done: + if (result) error("cannot sync file and its parent directory"); + if (fd >= 0) close(fd); + if (directory_fd >= 0) close(directory_fd); + free(parent); + return result; +} + +int main(int argc, char **argv) { + uint64_t offset, length; + if (argc == 3 && strcmp(argv[1], "sync") == 0) + return sync_path_and_directory(argv[2]); + if (argc == 5 && strcmp(argv[1], "slice") == 0 && + number(argv[3], &offset) == 0 && number(argv[4], &length) == 0) + return dump_slice(argv[2], offset, length); + fprintf(stderr, "usage: ib-arena-slice slice ARENA OFFSET LENGTH | sync PATH\n"); + return 2; +} diff --git a/native/android/ib_arena_source_owner.c b/native/android/ib_arena_source_owner.c new file mode 100644 index 00000000..77b15fe0 --- /dev/null +++ b/native/android/ib_arena_source_owner.c @@ -0,0 +1,427 @@ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include "ib_arena_source_owner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ICU #35/IB #152 v2 receipts carry both committed byte provenance and + * explicit HTML admission. No v1 UNOBSERVED receipt can be delivered to + * a visible reader. Source generation must match the requested navigation. */ +enum receipt_key { + RK_SCHEMA, RK_REQUEST, RK_SOURCE_KEY, RK_SOURCE_GENERATION, + RK_REQUESTED_URL, RK_RESOLVED_URL, RK_STATUS, RK_MIME, RK_CHARSET, + RK_CONTENT_ENCODING, RK_UTF8, RK_REDIRECT_COUNT, + RK_DECLARED_LENGTH, RK_RECEIVED_LENGTH, RK_TRANSPORT, + RK_ARENA, RK_OFFSET, RK_END, RK_LENGTH, RK_SHA256, + RK_REOPEN, RK_ACQUISITION, RK_NAVIGATION, RK_COUNT +}; +static const char *const receipt_names[RK_COUNT] = { + "schema", "request_id", "source_key", "source_generation", + "requested_url", "resolved_url", "http_status", "mime_type", + "charset", "content_encoding", "utf8", "redirect_count", + "declared_length", "received_length", "transport", + "arena_name", "arena_offset", "arena_end", "byte_count", + "sha256", "reopen_check", "acquisition_complete", + "navigation_admitted" +}; +#define RECEIPT_MAX_BYTES 8192U +#define SOURCE_WRITER_DEADLINE_MS 5000 +#define SOURCE_MAX_CONCURRENT_WRITERS 2U +static atomic_uint source_writers = 0; + +typedef struct { + unsigned char *bytes; + uint32_t length; + int writer_fd; +} source_writer; + +static int64_t time_milliseconds(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return -1; + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static int safe_request_id(const char *id) { + if (!id) return 0; + size_t length = strnlen(id, IB_READER_REQUEST_ID_CAP + 1U); + if (length == 0 || length > IB_READER_REQUEST_ID_CAP) return 0; + for (size_t i = 0; i < length; ++i) { + unsigned char ch = (unsigned char)id[i]; + if (!((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '.' || ch == '_' || ch == '-')) return 0; + } + return strcmp(id, ".") != 0 && strcmp(id, "..") != 0; +} + +static int parse_uint(const char *value, uint64_t *out) { + if (!value || !*value || !out) return -1; + for (const char *ch = value; *ch; ++ch) { + if (*ch < '0' || *ch > '9') return -1; + } + errno = 0; + char *end = NULL; + uintmax_t parsed = strtoumax(value, &end, 10); + if (errno != 0 || !end || *end != '\0' || parsed > INT64_MAX) + return -1; + *out = (uint64_t)parsed; + return 0; +} + +static int allowed_url(const char *value) { + if (!value) return 0; + size_t length = strnlen(value, 2049U); + if (length < 9U || length > 2048U) return 0; + if (strncmp(value, "https://", 8) != 0 && + strncmp(value, "http://", 7) != 0) return 0; + for (size_t i = 0; i < length; ++i) { + const unsigned char octet = (unsigned char)value[i]; + if (octet <= 32U || octet >= 127U || octet == '\'') return 0; + } + return 1; +} + +static int parse_receipt(char *text, size_t size, const char *request_id, + uint64_t wanted_generation, uint64_t *offset, + uint32_t *length, char sha256[65]) { + char *fields[RK_COUNT] = {0}; + size_t found = 0; + char *position = text, *end = text + size; + while (position < end) { + char *newline = memchr(position, '\n', (size_t)(end - position)); + if (!newline) return -1; + char *separator = memchr(position, '\t', + (size_t)(newline - position)); + if (!separator || separator == position || + separator == newline - 1) return -1; + if (memchr(separator + 1, '\t', + (size_t)(newline - separator - 1)) != NULL) + return -1; + if (memchr(position, '\r', (size_t)(newline - position)) || + memchr(position, '\0', (size_t)(newline - position))) + return -1; + *separator = '\0'; + *newline = '\0'; + int matched = 0; + for (size_t i = 0; i < RK_COUNT; ++i) { + if (strcmp(receipt_names[i], position) == 0) { + if (fields[i]) return -1; + fields[i] = separator + 1; + matched = 1; + break; + } + } + if (!matched || ++found > RK_COUNT) return -1; + position = newline + 1; + } + + /* A body-only v1 receipt is not a proof of response identity/charset, + * and cannot authorize reader publication even with a valid byte hash. */ + if (found != RK_COUNT || + strcmp(fields[RK_SCHEMA], "ib-icu-arena-v2") != 0 || + strcmp(fields[RK_REQUEST], request_id) != 0 || + strcmp(fields[RK_TRANSPORT], "icu-app-process-via-binder-shizuku") != 0 || + strcmp(fields[RK_ARENA], "responses.arena") != 0 || + strcmp(fields[RK_REOPEN], "MATCH") != 0 || + strcmp(fields[RK_ACQUISITION], "YES") != 0 || + strcmp(fields[RK_NAVIGATION], "YES") != 0 || + strcmp(fields[RK_STATUS], "200") != 0 || + strcmp(fields[RK_MIME], "text/html") != 0 || + strcmp(fields[RK_CHARSET], "utf-8") != 0 || + strcmp(fields[RK_CONTENT_ENCODING], "identity") != 0 || + strcmp(fields[RK_UTF8], "valid") != 0 || + !allowed_url(fields[RK_REQUESTED_URL]) || + !allowed_url(fields[RK_RESOLVED_URL])) + return -1; + + char expected_key[IB_READER_REQUEST_ID_CAP + 7U]; + int printed = snprintf(expected_key, sizeof(expected_key), + "arena:%s", request_id); + if (printed < 0 || (size_t)printed >= sizeof(expected_key) || + strcmp(fields[RK_SOURCE_KEY], expected_key) != 0) + return -1; + + uint64_t generation = 0, redirects = 0; + if (parse_uint(fields[RK_SOURCE_GENERATION], &generation) != 0 || + generation == 0 || generation != wanted_generation || + parse_uint(fields[RK_REDIRECT_COUNT], &redirects) != 0 || + redirects > 8U) + return -1; + + uint64_t begin = 0, finish = 0, amount = 0, received = 0; + if (parse_uint(fields[RK_OFFSET], &begin) != 0 || + parse_uint(fields[RK_END], &finish) != 0 || + parse_uint(fields[RK_LENGTH], &amount) != 0 || + parse_uint(fields[RK_RECEIVED_LENGTH], &received) != 0 || + amount == 0 || amount > IB_READER_SOURCE_MAX_BYTES || + received != amount || begin > finish || + finish - begin != amount) + return -1; + + if (strcmp(fields[RK_DECLARED_LENGTH], "-1") != 0) { + uint64_t declared = 0; + if (parse_uint(fields[RK_DECLARED_LENGTH], &declared) != 0 || + declared != amount) return -1; + } + + const char *hash = fields[RK_SHA256]; + if (strlen(hash) != 64U) return -1; + for (size_t i = 0; i < 64; ++i) + if (!((hash[i] >= '0' && hash[i] <= '9') || + (hash[i] >= 'a' && hash[i] <= 'f'))) return -1; + + *offset = begin; + *length = (uint32_t)amount; + memcpy(sha256, hash, 65U); + return 0; +} + +static int read_manifest(int root_fd, const char *request_id, + uint64_t navigation_generation, uint64_t *offset, + uint32_t *length, char sha256[65]) { + int dir = -1, manifest = -1, result = -1; + char filename[IB_READER_REQUEST_ID_CAP + 5U]; + struct stat info; + char content[RECEIPT_MAX_BYTES + 1U]; + int n = snprintf(filename, sizeof(filename), "%s.tsv", request_id); + if (n < 1 || (size_t)n >= sizeof(filename)) return -1; + dir = openat(root_fd, "receipts", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (dir < 0) goto done; + manifest = openat(dir, filename, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (manifest < 0 || fstat(manifest, &info) != 0 || + !S_ISREG(info.st_mode) || info.st_size <= 0 || + info.st_size > RECEIPT_MAX_BYTES || + info.st_uid != geteuid() || info.st_nlink != 1) + goto done; + size_t size = (size_t)info.st_size; + size_t got = 0; + while (got < size) { + ssize_t amount = pread(manifest, content + got, size - got, (off_t)got); + if (amount < 0 && errno == EINTR) continue; + if (amount <= 0) goto done; + got += (size_t)amount; + } + content[size] = '\0'; + result = parse_receipt(content, size, request_id, navigation_generation, + offset, length, sha256); +done: + if (manifest >= 0) close(manifest); + if (dir >= 0) close(dir); + return result; +} + +static int read_committed_bytes(int root_fd, uint64_t offset, uint32_t length, + unsigned char *bytes) { + int lock = -1, used = -1, arena = -1, result = -1; + struct stat info; + char pointer[64]; + lock = openat(root_fd, "responses.arena.lock", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (lock < 0 || flock(lock, LOCK_SH | LOCK_NB) != 0) goto done; + used = openat(root_fd, "responses.arena.used", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + arena = openat(root_fd, "responses.arena", + O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (used < 0 || arena < 0 || + fstat(used, &info) != 0 || !S_ISREG(info.st_mode)) + goto done; + ssize_t pointer_bytes = pread(used, pointer, sizeof(pointer) - 1U, 0); + if (pointer_bytes <= 1 || pointer_bytes >= (ssize_t)sizeof(pointer) - 1 || + pointer[pointer_bytes - 1] != '\n') + goto done; + pointer[pointer_bytes - 1] = '\0'; + uint64_t committed = 0; + if (parse_uint(pointer, &committed) != 0 || + fstat(arena, &info) != 0 || !S_ISREG(info.st_mode) || + info.st_uid != geteuid() || info.st_size < 0 || + committed > (uint64_t)info.st_size || + offset > committed || length > committed - offset) + goto done; + + size_t filled = 0; + while (filled < length) { + size_t remaining = (size_t)length - filled; + if (remaining > 65536U) remaining = 65536U; + ssize_t amount = pread(arena, bytes + filled, remaining, + (off_t)(offset + filled)); + if (amount < 0 && errno == EINTR) continue; + if (amount <= 0) goto done; + filled += (size_t)amount; + } + result = 0; +done: + if (arena >= 0) close(arena); + if (used >= 0) close(used); + if (lock >= 0) close(lock); + return result; +} + +static void *write_source(void *argument) { + source_writer *writer = argument; + sigset_t blocked; + sigemptyset(&blocked); + sigaddset(&blocked, SIGPIPE); + (void)pthread_sigmask(SIG_BLOCK, &blocked, NULL); + + int64_t now = time_milliseconds(); + int64_t deadline = now < 0 ? 0 : now + SOURCE_WRITER_DEADLINE_MS; + size_t sent = 0; + while (sent < writer->length) { + now = time_milliseconds(); + if (now < 0 || now >= deadline) break; + struct pollfd poll_fd = { writer->writer_fd, POLLOUT, 0 }; + int remaining = (int)(deadline - now); + int status = poll(&poll_fd, 1, remaining); + if (status < 0 && errno == EINTR) continue; + if (status <= 0 || (poll_fd.revents & (POLLERR | POLLNVAL | POLLHUP))) + break; + size_t chunk = writer->length - sent; + if (chunk > 65536U) chunk = 65536U; + ssize_t count = write(writer->writer_fd, writer->bytes + sent, chunk); + if (count < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (count <= 0) break; + sent += (size_t)count; + } + close(writer->writer_fd); + free(writer->bytes); + free(writer); + atomic_fetch_sub(&source_writers, 1U); + return NULL; +} + +int ib_arena_source_owner_init(ib_arena_source_owner *owner, + const char *trusted_private_root) { + if (!owner || !trusted_private_root || trusted_private_root[0] != '/') + return -1; + size_t length = strnlen(trusted_private_root, + IB_ARENA_PRIVATE_ROOT_CAP); + if (length == 0 || length >= IB_ARENA_PRIVATE_ROOT_CAP) + return -1; + memcpy(owner->private_root, trusted_private_root, length + 1U); + atomic_init(&owner->wanted_navigation_generation, 0); + return 0; +} + +void ib_arena_source_owner_want(ib_arena_source_owner *owner, + uint64_t navigation_generation) { + if (owner) + atomic_store(&owner->wanted_navigation_generation, + navigation_generation); +} + +int ib_arena_source_owner_open(ib_arena_source_owner *owner, + const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *ticket, + int *read_fd) { + if (read_fd) *read_fd = -1; + if (!owner || !read_fd || !safe_request_id(request_id) || + navigation_generation == 0 || + navigation_generation != + atomic_load(&owner->wanted_navigation_generation)) + return -1; + + int root = -1, result = -1, descriptor[2] = {-1, -1}; + uint64_t offset = 0; + uint32_t length = 0; + char expected[65], observed[65]; + unsigned char *bytes = NULL; + source_writer *writer = NULL; + pthread_t thread; + struct stat info; + + root = open(owner->private_root, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (root < 0 || fstat(root, &info) != 0 || + !S_ISDIR(info.st_mode) || info.st_uid != geteuid() || + (info.st_mode & 077U) != 0) goto done; + if (read_manifest(root, request_id, navigation_generation, + &offset, &length, expected) != 0) + goto done; + bytes = malloc(length); + if (!bytes || read_committed_bytes(root, offset, length, bytes) != 0) + goto done; + ib_reader_sha256_hex(bytes, length, observed); + if (strcmp(expected, observed) != 0) goto done; + if (navigation_generation != + atomic_load(&owner->wanted_navigation_generation)) goto done; + + if (atomic_fetch_add(&source_writers, 1U) >= + SOURCE_MAX_CONCURRENT_WRITERS) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + if (pipe2(descriptor, O_CLOEXEC | O_NONBLOCK) != 0) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + /* The consumer receives a blocking read descriptor but the writer + * stays nonblocking so unresponsive readers cannot accumulate threads. */ + int flags = fcntl(descriptor[0], F_GETFL); + if (flags < 0 || + fcntl(descriptor[0], F_SETFL, flags & ~O_NONBLOCK) != 0) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + writer = calloc(1, sizeof(*writer)); + if (!writer) { + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + writer->bytes = bytes; + writer->length = length; + writer->writer_fd = descriptor[1]; + if (pthread_create(&thread, NULL, write_source, writer) != 0) { + writer->bytes = NULL; /* caller still owns bytes */ + free(writer); + writer = NULL; + atomic_fetch_sub(&source_writers, 1U); + goto done; + } + (void)pthread_detach(thread); + bytes = NULL; + writer = NULL; + descriptor[1] = -1; + if (ticket) { + memset(ticket, 0, sizeof(*ticket)); + memcpy(ticket->request_id, request_id, strlen(request_id) + 1U); + ticket->navigation_generation = navigation_generation; + ticket->byte_count = length; + memcpy(ticket->sha256_hex, expected, sizeof(expected)); + } + *read_fd = descriptor[0]; + descriptor[0] = -1; + result = 0; +done: + if (descriptor[0] >= 0) close(descriptor[0]); + if (descriptor[1] >= 0) close(descriptor[1]); + if (root >= 0) close(root); + free(bytes); + return result; +} + +int ib_arena_source_owner_open_binder(void *owner, const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, + int *read_fd) { + return ib_arena_source_owner_open((ib_arena_source_owner *)owner, + request_id, navigation_generation, + verified_ticket, read_fd); +} diff --git a/native/android/ib_arena_source_owner.h b/native/android/ib_arena_source_owner.h new file mode 100644 index 00000000..7fe88cc6 --- /dev/null +++ b/native/android/ib_arena_source_owner.h @@ -0,0 +1,43 @@ +#ifndef IB_ANDROID_VERIFIED_ARENA_SOURCE_H +#define IB_ANDROID_VERIFIED_ARENA_SOURCE_H + +#include "ib_reader_pfd.h" +#include +#include +#include + +#define IB_ARENA_PRIVATE_ROOT_CAP 4096U + +typedef struct { + char private_root[IB_ARENA_PRIVATE_ROOT_CAP]; + atomic_uint_fast64_t wanted_navigation_generation; +} ib_arena_source_owner; + +/* The trusted application host passes its existing app-private Arena root + * (e.g. Context.getFilesDir()/arena). This does not choose/create a store. */ +int ib_arena_source_owner_init(ib_arena_source_owner *owner, + const char *trusted_private_root); +void ib_arena_source_owner_want(ib_arena_source_owner *owner, + uint64_t navigation_generation); + +/* All-or-nothing admitted-source open. Requires IB ICU/Arena v2 with + * observed HTTP 200, HTML/UTF-8, completed acquisition, positive admission + * and a receipt source_generation matching navigation_generation. Legacy + * body-only v1 receipts never reach the reader. Independently reopen the + * canonical committed byte range and check SHA-256 before returning a + * read-only anonymous pipe. The browser still controls navigation ordering + * and the reader checks the ticket, stream and digest again. + * Caller owns *read_fd. */ +int ib_arena_source_owner_open(ib_arena_source_owner *owner, + const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *ticket, + int *read_fd); + +/* Adapter for ib_arena_pfd_provider.open_verified_fd. */ +int ib_arena_source_owner_open_binder(void *owner, const char *request_id, + uint64_t navigation_generation, + ib_reader_source_ticket *verified_ticket, + int *read_fd); + +#endif diff --git a/native/android/ib_reader_host_assembly.c b/native/android/ib_reader_host_assembly.c new file mode 100644 index 00000000..5e01f349 --- /dev/null +++ b/native/android/ib_reader_host_assembly.c @@ -0,0 +1,37 @@ +#include "ib_reader_host_assembly.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +#include + +int ib_reader_host_assembly_init(ib_reader_host_assembly *assembly, + const char *trusted_private_arena_root, + uid_t allowed_reader_uid) { + if (!assembly || allowed_reader_uid < (uid_t)10000) + return -1; + memset(assembly, 0, sizeof(*assembly)); + if (ib_arena_source_owner_init(&assembly->source, + trusted_private_arena_root) != 0) + return -1; + assembly->descriptor_provider.allowed_reader_uid = allowed_reader_uid; + assembly->descriptor_provider.open_verified_fd = + ib_arena_source_owner_open_binder; + assembly->descriptor_provider.source_owner = &assembly->source; + assembly->binder = + ib_arena_pfd_provider_new(&assembly->descriptor_provider); + return assembly->binder ? 0 : -1; +} + +void ib_reader_host_assembly_want(ib_reader_host_assembly *assembly, + uint64_t navigation_generation) { + if (assembly) + ib_arena_source_owner_want(&assembly->source, navigation_generation); +} + +void ib_reader_host_assembly_release_local_reference( + ib_reader_host_assembly *assembly) { + if (!assembly || !assembly->binder) return; + AIBinder_decStrong(assembly->binder); + assembly->binder = NULL; +} +#endif diff --git a/native/android/ib_reader_host_assembly.h b/native/android/ib_reader_host_assembly.h new file mode 100644 index 00000000..dd101d03 --- /dev/null +++ b/native/android/ib_reader_host_assembly.h @@ -0,0 +1,33 @@ +#ifndef IB_ANDROID_READER_HOST_ASSEMBLY_H +#define IB_ANDROID_READER_HOST_ASSEMBLY_H + +#include "ib_arena_source_owner.h" +#include "ib_arena_pfd_provider.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +/* App host owns the memory and must keep it stable until the local Binder + * object and all in-flight calls are gone. Browser event dispatch, not the + * renderer or Shizuku, advances the expected source generation. */ +typedef struct { + ib_arena_source_owner source; + ib_arena_pfd_provider descriptor_provider; + AIBinder *binder; +} ib_reader_host_assembly; + +/* The source owner runs at the app UID and uses its existing private Arena. + * allowed_reader_uid must be obtained from trusted Android package identity. + * Neither shell UID 2000 nor a caller-controlled UID is accepted. */ +int ib_reader_host_assembly_init(ib_reader_host_assembly *assembly, + const char *trusted_private_arena_root, + uid_t allowed_reader_uid); +void ib_reader_host_assembly_want(ib_reader_host_assembly *assembly, + uint64_t navigation_generation); + +/* Host must first stop Binder calls and release its external references. + * Caller must not free assembly until the Binder object's final destruction. */ +void ib_reader_host_assembly_release_local_reference( + ib_reader_host_assembly *assembly); +#endif + +#endif diff --git a/native/android/ib_reader_pfd.c b/native/android/ib_reader_pfd.c new file mode 100644 index 00000000..9705bd25 --- /dev/null +++ b/native/android/ib_reader_pfd.c @@ -0,0 +1,376 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_reader_pfd.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define IB_PFD_READ_DEADLINE_MS 5000 + +typedef struct { + uint32_t word[8]; + unsigned char block[64]; + size_t used; + uint64_t processed_bits; +} sha256_state; + +static uint32_t rotate(uint32_t value, unsigned int count) { + return (value >> count) | (value << (32U - count)); +} + +static const uint32_t sha256_constants[64] = { + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, + 0x3956c25bU, 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, + 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, + 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, + 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, + 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, + 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, + 0xc6e00bf3U, 0xd5a79147U, 0x06ca6351U, 0x14292967U, + 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, + 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, + 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, + 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, + 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, 0x682e6ff3U, + 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, + 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U +}; + +static void sha256_transform(sha256_state *state, + const unsigned char input[64]) { + uint32_t words[64], registers[8]; + for (size_t index = 0; index < 16; ++index) { + size_t at = index * 4U; + words[index] = ((uint32_t)input[at] << 24U) | + ((uint32_t)input[at + 1U] << 16U) | + ((uint32_t)input[at + 2U] << 8U) | + (uint32_t)input[at + 3U]; + } + for (size_t index = 16; index < 64; ++index) { + uint32_t a = words[index - 15U], b = words[index - 2U]; + uint32_t s0 = rotate(a, 7U) ^ rotate(a, 18U) ^ (a >> 3U); + uint32_t s1 = rotate(b, 17U) ^ rotate(b, 19U) ^ (b >> 10U); + words[index] = words[index - 16U] + s0 + + words[index - 7U] + s1; + } + memcpy(registers, state->word, sizeof(registers)); + for (size_t index = 0; index < 64; ++index) { + uint32_t s1 = rotate(registers[4], 6U) ^ + rotate(registers[4], 11U) ^ + rotate(registers[4], 25U); + uint32_t choose = (registers[4] & registers[5]) ^ + (~registers[4] & registers[6]); + uint32_t first = registers[7] + s1 + choose + + sha256_constants[index] + words[index]; + uint32_t s0 = rotate(registers[0], 2U) ^ + rotate(registers[0], 13U) ^ + rotate(registers[0], 22U); + uint32_t majority = (registers[0] & registers[1]) ^ + (registers[0] & registers[2]) ^ + (registers[1] & registers[2]); + uint32_t second = s0 + majority; + for (size_t at = 7; at > 0; --at) registers[at] = registers[at - 1U]; + registers[4] += first; + registers[0] = first + second; + } + for (size_t index = 0; index < 8; ++index) + state->word[index] += registers[index]; +} + +static void sha256_init(sha256_state *state) { + static const uint32_t initial[8] = { + 0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU, + 0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U + }; + memcpy(state->word, initial, sizeof(initial)); + state->used = 0; + state->processed_bits = 0; +} + +static void sha256_update(sha256_state *state, const unsigned char *body, + size_t count) { + for (size_t index = 0; index < count; ++index) { + state->block[state->used++] = body[index]; + if (state->used == sizeof(state->block)) { + sha256_transform(state, state->block); + state->processed_bits += 512U; + state->used = 0; + } + } +} + +static void sha256_finish(sha256_state *state, unsigned char digest[32]) { + uint64_t bits = state->processed_bits + (uint64_t)state->used * 8U; + state->block[state->used++] = 0x80U; + if (state->used > 56U) { + memset(state->block + state->used, 0, 64U - state->used); + sha256_transform(state, state->block); + state->used = 0; + } + memset(state->block + state->used, 0, 56U - state->used); + for (size_t index = 0; index < 8; ++index) + state->block[56U + index] = + (unsigned char)(bits >> (56U - 8U * index)); + sha256_transform(state, state->block); + for (size_t index = 0; index < 32; ++index) + digest[index] = (unsigned char) + (state->word[index / 4U] >> (24U - 8U * (index % 4U))); +} + +static int valid_ticket(const ib_reader_source_ticket *ticket) { + if (!ticket || ticket->navigation_generation == 0 || + ticket->byte_count == 0 || + ticket->byte_count > IB_READER_SOURCE_MAX_BYTES) + return 0; + size_t length = strnlen(ticket->request_id, + sizeof(ticket->request_id)); + if (length == 0 || length > IB_READER_REQUEST_ID_CAP) + return 0; + for (size_t index = 0; index < length; ++index) { + unsigned char ch = (unsigned char)ticket->request_id[index]; + if (!((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '.' || ch == '_' || ch == '-')) + return 0; + } + if (strnlen(ticket->sha256_hex, sizeof(ticket->sha256_hex)) != 64U) + return 0; + for (size_t index = 0; index < 64; ++index) { + char ch = ticket->sha256_hex[index]; + if (!((ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f'))) + return 0; + } + return 1; +} + +void ib_reader_sha256_hex(const unsigned char *bytes, size_t count, + char hex[65]) { + static const char alphabet[] = "0123456789abcdef"; + sha256_state hash; + unsigned char digest[32]; + sha256_init(&hash); + if (count != 0) sha256_update(&hash, bytes, count); + sha256_finish(&hash, digest); + for (size_t index = 0; index < 32; ++index) { + hex[index * 2U] = alphabet[digest[index] >> 4U]; + hex[index * 2U + 1U] = alphabet[digest[index] & 15U]; + } + hex[64] = '\0'; +} + +static int matching_digest(const unsigned char digest[32], + const char expected[65]) { + static const char alphabet[] = "0123456789abcdef"; + unsigned int differences = 0; + for (size_t index = 0; index < 32; ++index) { + differences |= (unsigned int) + (alphabet[digest[index] >> 4U] != expected[index * 2U]); + differences |= (unsigned int) + (alphabet[digest[index] & 15U] != expected[index * 2U + 1U]); + } + return differences == 0; +} + +static int64_t monotonic_milliseconds(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) return -1; + return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000; +} + +static ssize_t read_bounded(int fd, void *buffer, size_t capacity, + int64_t deadline) { + for (;;) { + int64_t now = monotonic_milliseconds(); + if (now < 0 || now >= deadline) return -1; + int64_t remaining = deadline - now; + struct pollfd descriptor = { fd, POLLIN | POLLHUP, 0 }; + int status = poll(&descriptor, 1, remaining > INT_MAX ? + INT_MAX : (int)remaining); + if (status < 0 && errno == EINTR) continue; + if (status <= 0 || (descriptor.revents & (POLLERR | POLLNVAL))) + return -1; + ssize_t count = read(fd, buffer, capacity); + if (count < 0 && (errno == EINTR || errno == EAGAIN)) continue; + return count; + } +} + +void ib_reader_source_init(ib_reader_source *source) { + if (!source) return; + atomic_init(&source->wanted_navigation_generation, 0); + source->bytes = NULL; + source->byte_count = 0; + source->published_navigation_generation = 0; + source->request_id[0] = '\0'; +} + +void ib_reader_source_clear(ib_reader_source *source) { + if (!source) return; + free(source->bytes); + source->bytes = NULL; + source->byte_count = 0; + source->published_navigation_generation = 0; + source->request_id[0] = '\0'; + atomic_store(&source->wanted_navigation_generation, 0); +} + +void ib_reader_source_want(ib_reader_source *source, uint64_t generation) { + if (source) atomic_store(&source->wanted_navigation_generation, generation); +} + +int ib_reader_source_accept_fd(ib_reader_source *source, + const ib_reader_source_ticket *ticket, int fd) { + if (!source || !valid_ticket(ticket) || fd < 0) + return IB_READER_PFD_BAD_TICKET; + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) + return IB_READER_PFD_STALE; + /* A bounded source pipe, never the backing Arena file or a writable FD. */ + struct stat info; + int flags = fcntl(fd, F_GETFL); + if (flags < 0 || fstat(fd, &info) != 0 || + !S_ISFIFO(info.st_mode) || (flags & O_ACCMODE) != O_RDONLY) + return IB_READER_PFD_BAD_TICKET; + + unsigned char *candidate = malloc((size_t)ticket->byte_count + 1U); + if (!candidate) return IB_READER_PFD_IO; + sha256_state hash; + sha256_init(&hash); + size_t received = 0; + int result = IB_READER_PFD_IO; + int64_t start = monotonic_milliseconds(); + if (start < 0 || start > INT64_MAX - IB_PFD_READ_DEADLINE_MS) + goto failed; + int64_t deadline = start + IB_PFD_READ_DEADLINE_MS; + while (received < ticket->byte_count) { + size_t wanted = ticket->byte_count - received; + if (wanted > 65536U) wanted = 65536U; + ssize_t count = read_bounded(fd, candidate + received, wanted, deadline); + if (count <= 0) goto failed; + sha256_update(&hash, candidate + received, (size_t)count); + received += (size_t)count; + } + /* A source capability must end here: never silently accept a prefix + * from a larger or still-live stream. */ + unsigned char extra; + if (read_bounded(fd, &extra, 1, deadline) != 0) goto failed; + unsigned char digest[32]; + sha256_finish(&hash, digest); + if (!matching_digest(digest, ticket->sha256_hex)) { + result = IB_READER_PFD_DIGEST; + goto failed; + } + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) { + result = IB_READER_PFD_STALE; + goto failed; + } + candidate[received] = '\0'; /* sentinel; body remains binary */ + unsigned char *previous = source->bytes; + source->bytes = candidate; + source->byte_count = received; + source->published_navigation_generation = ticket->navigation_generation; + memcpy(source->request_id, ticket->request_id, + strlen(ticket->request_id) + 1U); + free(previous); + return IB_READER_PFD_ACCEPTED; + +failed: + free(candidate); + return result; +} + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include +#include +#include "ib_arena_pfd_provider.h" + +typedef struct { + char digest[65]; + int32_t allocated_bytes; +} ib_digest_reply; + +static bool digest_reply_allocate(void *opaque, int32_t length, char **buffer) { + ib_digest_reply *reply = opaque; + if (!reply || !buffer || length != 65) return false; + reply->allocated_bytes = length; + *buffer = reply->digest; + return true; +} + +/* The private provider owns authorization and obtains the descriptor from a + * committed, receipt-verified source. The Binder transaction contains only a + * request identity and epoch; the reply contains a PFD, never a path. */ +int ib_reader_source_fetch_binder(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + AIBinder *provider) { + if (!source || !valid_ticket(ticket) || !provider) + return IB_READER_PFD_BAD_TICKET; + if (ticket->navigation_generation != + atomic_load(&source->wanted_navigation_generation)) + return IB_READER_PFD_STALE; + + /* A binder proxy requires typed class association before transaction. + * Check the exact private interface descriptor; never send to an + * unassociated or unrelated Binder endpoint. */ + const AIBinder_Class *clazz = ib_arena_pfd_provider_class(); + if (!clazz || !AIBinder_associateClass(provider, clazz)) + return IB_READER_PFD_BINDER; + + AParcel *request = NULL, *reply = NULL; + binder_status_t status = AIBinder_prepareTransaction(provider, &request); + if (status != STATUS_OK || !request) + return IB_READER_PFD_BINDER; + status = AParcel_writeString(request, ticket->request_id, + (int32_t)strlen(ticket->request_id)); + if (status == STATUS_OK) + status = AParcel_writeInt64(request, + (int64_t)ticket->navigation_generation); + if (status != STATUS_OK) { + AParcel_delete(request); + return IB_READER_PFD_BINDER; + } + /* request ownership transfers to AIBinder_transact. */ + status = AIBinder_transact(provider, 1U, &request, &reply, 0); + if (status != STATUS_OK || !reply) { + if (reply) AParcel_delete(reply); + return IB_READER_PFD_BINDER; + } + int32_t provider_status = -1; + int32_t provider_length = 0; + ib_digest_reply supplied = {{0}, 0}; + int descriptor = -1; + status = AParcel_readInt32(reply, &provider_status); + if (status == STATUS_OK && provider_status == 0) + status = AParcel_readInt32(reply, &provider_length); + if (status == STATUS_OK && provider_status == 0) { + status = AParcel_readString(reply, &supplied, digest_reply_allocate); + } + if (status != STATUS_OK || provider_status != 0 || + provider_length <= 0 || + (uint32_t)provider_length != ticket->byte_count || + supplied.allocated_bytes != 65 || + memcmp(supplied.digest, ticket->sha256_hex, 65) != 0) { + AParcel_delete(reply); + return IB_READER_PFD_BINDER; + } + status = AParcel_readParcelFileDescriptor(reply, &descriptor); + AParcel_delete(reply); + if (status != STATUS_OK || descriptor < 0) { + if (descriptor >= 0) close(descriptor); + return IB_READER_PFD_BINDER; + } + int result = ib_reader_source_accept_fd(source, ticket, descriptor); + close(descriptor); + return result; +} +#endif diff --git a/native/android/ib_reader_pfd.h b/native/android/ib_reader_pfd.h new file mode 100644 index 00000000..2fab3c46 --- /dev/null +++ b/native/android/ib_reader_pfd.h @@ -0,0 +1,68 @@ +#ifndef IB_ANDROID_READER_PFD_H +#define IB_ANDROID_READER_PFD_H + +#include +#include +#include + +/* Reader accepts one complete bounded snapshot, never an Arena pathname. + * This C boundary consumes a descriptor received through Android Binder/PFD. + * Browser/navigation ownership remains with the checked Idric core. */ +#define IB_READER_SOURCE_MAX_BYTES (4U * 1024U * 1024U) +#define IB_READER_REQUEST_ID_CAP 64U + +typedef struct { + char request_id[IB_READER_REQUEST_ID_CAP + 1U]; + uint64_t navigation_generation; + uint32_t byte_count; + char sha256_hex[65]; +} ib_reader_source_ticket; + +typedef struct { + atomic_uint_fast64_t wanted_navigation_generation; + unsigned char *bytes; + size_t byte_count; + uint64_t published_navigation_generation; + char request_id[IB_READER_REQUEST_ID_CAP + 1U]; +} ib_reader_source; + +enum { + IB_READER_PFD_ACCEPTED = 0, + IB_READER_PFD_BAD_TICKET = 1, + IB_READER_PFD_STALE = 2, + IB_READER_PFD_IO = 3, + IB_READER_PFD_DIGEST = 4, + IB_READER_PFD_BINDER = 5 +}; + +/* Initialize once, use in a serialized reader-owned publication queue. + * Navigation may invalidate an in-flight read from another thread. + * Published bytes remain owned by the reader until a successful replacement. */ +void ib_reader_source_init(ib_reader_source *source); +void ib_reader_source_clear(ib_reader_source *source); +void ib_reader_source_want(ib_reader_source *source, uint64_t generation); + +/* Same checked SHA-256 implementation for reader and source owner; never + * substitute textual CLI output for the captured binary response. */ +void ib_reader_sha256_hex(const unsigned char *bytes, size_t count, + char hex[65]); + +/* Does NOT close fd. Requires a finite stream ending at exactly byte_count. + * Checks deadline, length, SHA-256 and generation before exposing any byte. */ +int ib_reader_source_accept_fd(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + int fd); + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* provider is an already authenticated private Binder capability, NOT an + * Android service-manager name or a Shizuku command. On transaction 1 it + * receives request_id and navigation_generation, then returns status=0 and + * a ParcelFileDescriptor of the already committed immutable source bytes. + * No untrusted URL, pathname, or body travels through a Parcel. */ +int ib_reader_source_fetch_binder(ib_reader_source *source, + const ib_reader_source_ticket *ticket, + AIBinder *provider); +#endif +#endif diff --git a/tests/icu_native_transport_probe.c b/tests/icu_native_transport_probe.c new file mode 100644 index 00000000..a1cabc1d --- /dev/null +++ b/tests/icu_native_transport_probe.c @@ -0,0 +1,46 @@ +/* CI-only narrow native harness for *actual* ICU #35 transport.c. It is not + * an Android app_process/Idriç replacement or a browser URL parser. */ +#include +#include +#include +#include +#include + +int icu_send_http(const char *, int, const char *, const char *, int); +int icu_send_https(const char *, int, const char *, const char *, int); + +int main(int argc, char **argv) { + if (argc != 2) return 2; + const char *url = argv[1]; + int tls = 0; + if (strncmp(url, "http://", 7) == 0) url += 7; + else if (strncmp(url, "https://", 8) == 0) { + url += 8; + tls = 1; + } else return 2; + const char *colon = strchr(url, ':'); + const char *slash = strchr(url, '/'); + if (!colon || !slash || colon >= slash || colon == url) return 2; + if ((size_t)(colon - url) >= 128U) return 2; + char host[128]; + memcpy(host, url, (size_t)(colon - url)); + host[colon - url] = '\0'; + if (strcmp(host, "localhost") != 0 && + strcmp(host, "127.0.0.1") != 0) return 2; + errno = 0; + char *end = NULL; + long port_long = strtol(colon + 1, &end, 10); + if (errno || end != slash || port_long < 1 || port_long > 65535) + return 2; + int port = (int)port_long; + for (const unsigned char *p = (const unsigned char *)slash; *p; ++p) + if (*p <= 32U || *p >= 127U) return 2; + char request[4096]; + int count = snprintf(request, sizeof(request), + "GET %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", + slash, host, port); + if (count <= 0 || (size_t)count >= sizeof(request)) return 2; + return tls + ? icu_send_https(host, port, request, NULL, 0) + : icu_send_http(host, port, request, NULL, 0); +} diff --git a/tests/test_android_arena_source_owner.c b/tests/test_android_arena_source_owner.c new file mode 100644 index 00000000..210fc92a --- /dev/null +++ b/tests/test_android_arena_source_owner.c @@ -0,0 +1,196 @@ +#define _POSIX_C_SOURCE 200809L +#include "../native/android/ib_arena_source_owner.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static unsigned char expected_source[] = {'<','p','>','I','B',' ','C','6','7','<','/','p','>','\n'}; +static const char *request_id = "verified"; +static const char *name = "receipts/verified.tsv"; + +static void full_file(const char *path, unsigned char **bytes, size_t *length) { + FILE *file = fopen(path, "rb"); + assert(file != NULL); + assert(fseek(file, 0, SEEK_END) == 0); + long size = ftell(file); + assert(size > 0 && size < 8192); + assert(fseek(file, 0, SEEK_SET) == 0); + *bytes = malloc((size_t)size + 1U); + assert(*bytes != NULL); + assert(fread(*bytes, 1, (size_t)size, file) == (size_t)size); + (*bytes)[size] = '\0'; + assert(fclose(file) == 0); + *length = (size_t)size; +} + +static void overwrite(const char *path, const unsigned char *bytes, size_t count) { + FILE *file = fopen(path, "wb"); + assert(file != NULL); + assert(fwrite(bytes, 1, count, file) == count); + assert(fflush(file) == 0); + assert(fclose(file) == 0); +} + +static void preserved(const ib_reader_source *reader, + const char *id, uint64_t generation) { + assert(reader->bytes != NULL); + assert(reader->byte_count == sizeof(expected_source)); + assert(memcmp(reader->bytes, expected_source, sizeof(expected_source)) == 0); + assert(reader->published_navigation_generation == generation); + assert(strcmp(reader->request_id, id) == 0); +} + +static void accept(ib_arena_source_owner *owner, ib_reader_source *reader, + const char *id, uint64_t generation) { + ib_reader_source_ticket ticket = {0}; + int fd = -1; + assert(ib_arena_source_owner_open(owner, id, generation, + &ticket, &fd) == 0); + assert(fd >= 0); + assert(ticket.byte_count == sizeof(expected_source)); + assert(ticket.navigation_generation == generation); + assert(strcmp(ticket.request_id, id) == 0); + char local_digest[65]; + ib_reader_sha256_hex(expected_source, sizeof(expected_source), local_digest); + assert(strcmp(ticket.sha256_hex, local_digest) == 0); + assert(ib_reader_source_accept_fd(reader, &ticket, fd) + == IB_READER_PFD_ACCEPTED); + close(fd); + preserved(reader, id, generation); +} + +int main(int argc, char **argv) { + assert(argc == 2); + char private_root[4096], receipt[4096], arena[4096], pointer[4096]; + assert(snprintf(private_root, sizeof(private_root), "%s", argv[1]) + < (int)sizeof(private_root)); + assert(snprintf(receipt, sizeof(receipt), "%s/%s", private_root, name) + < (int)sizeof(receipt)); + assert(snprintf(arena, sizeof(arena), "%s/responses.arena", private_root) + < (int)sizeof(arena)); + assert(snprintf(pointer, sizeof(pointer), "%s/responses.arena.used", private_root) + < (int)sizeof(pointer)); + + ib_arena_source_owner owner; + assert(ib_arena_source_owner_init(&owner, private_root) == 0); + ib_reader_source reader; + ib_reader_source_init(&reader); + ib_arena_source_owner_want(&owner, 1); + ib_reader_source_want(&reader, 1); + accept(&owner, &reader, request_id, 1); + int fd = -1; + /* A legacy v1 receipt has matching bytes but no observed admission. */ + assert(ib_arena_source_owner_open(&owner, "legacy", 1, NULL, &fd) != 0); + assert(fd == -1); + + ib_reader_source_ticket binder_ticket = {0}; + assert(ib_arena_source_owner_open_binder(&owner, request_id, 2, &binder_ticket, &fd) != 0); + assert(fd == -1); + assert(ib_arena_source_owner_open(&owner, "../bad", 1, NULL, &fd) != 0); + preserved(&reader, request_id, 1); + + unsigned char *original = NULL; + size_t count = 0; + full_file(receipt, &original, &count); + unsigned char *damaged = malloc(count + 1U); + assert(damaged != NULL); + memcpy(damaged, original, count + 1U); + char *digest = strstr((char *)damaged, "sha256\t"); + assert(digest != NULL); + digest += strlen("sha256\t"); + *digest = *digest == '0' ? '1' : '0'; + overwrite(receipt, damaged, count); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + overwrite(receipt, original, count); + + /* Every observed source/admission field participates in PFD authority: + * the byte hash alone is insufficient for an admitted navigation. */ + const char *mutations[][2] = { + {"source_generation\t1", "source_generation\t2"}, + {"source_key\tarena:verified", "source_key\tarena:verifieX"}, + {"http_status\t200", "http_status\t404"}, + {"mime_type\ttext/html", "mime_type\ttext/junk"}, + {"charset\tutf-8", "charset\tutf-7"}, + {"content_encoding\tidentity", "content_encoding\tidentitx"}, + {"utf8\tvalid", "utf8\twrong"}, + {"received_length\t14", "received_length\t13"}, + {"declared_length\t14", "declared_length\t13"}, + {"acquisition_complete\tYES", "acquisition_complete\tNAY"}, + {"navigation_admitted\tYES", "navigation_admitted\tNAY"} + }; + for (size_t i = 0; i < sizeof(mutations) / sizeof(mutations[0]); ++i) { + const char *before = mutations[i][0], *after = mutations[i][1]; + assert(strlen(before) == strlen(after)); + memcpy(damaged, original, count + 1U); + char *location = strstr((char *)damaged, before); + assert(location != NULL); + memcpy(location, after, strlen(after)); + overwrite(receipt, damaged, count); + assert(ib_arena_source_owner_open(&owner, request_id, 1, + NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + } + overwrite(receipt, original, count); + + /* Last-good retained when Arena source bytes no longer match receipt. */ + int file = open(arena, O_RDWR); + assert(file >= 0); + unsigned char first = 0; + assert(pread(file, &first, 1, 0) == 1); + unsigned char changed = (unsigned char)(first ^ 0xffU); + assert(pwrite(file, &changed, 1, 0) == 1); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + assert(pwrite(file, &first, 1, 0) == 1); + close(file); + + unsigned char *saved_pointer = NULL; + size_t used_count = 0; + full_file(pointer, &saved_pointer, &used_count); + overwrite(pointer, (const unsigned char *)"999999999\n", 10); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(fd == -1); + overwrite(pointer, saved_pointer, used_count); + free(saved_pointer); + + /* A substituted symlink or world-readable private root is refused. */ + char alias[4096]; + assert(snprintf(alias, sizeof(alias), "%s-symlink", private_root) + < (int)sizeof(alias)); + assert(symlink(private_root, alias) == 0); + ib_arena_source_owner linked_owner; + assert(ib_arena_source_owner_init(&linked_owner, alias) == 0); + ib_arena_source_owner_want(&linked_owner, 1); + assert(ib_arena_source_owner_open(&linked_owner, request_id, 1, NULL, &fd) != 0); + assert(unlink(alias) == 0); + assert(chmod(private_root, 0755) == 0); + assert(ib_arena_source_owner_open(&owner, request_id, 1, NULL, &fd) != 0); + assert(chmod(private_root, 0700) == 0); + + ib_arena_source_owner_want(&owner, 2); + ib_reader_source_want(&reader, 2); + /* Replaying the same admitted generation as a newer navigation refuses. */ + assert(ib_arena_source_owner_open(&owner, request_id, 2, NULL, &fd) != 0); + assert(fd == -1); + preserved(&reader, request_id, 1); + accept(&owner, &reader, "verified2", 2); + preserved(&reader, "verified2", 2); + ib_reader_source_clear(&reader); + free(original); + free(damaged); + puts("PASS: canonical appendfat v2 HTML admission, matching navigation generation, pipe/double SHA, v1/stale/forged-metadata/byte/permission/symlink/corrupt-pointer refusal and recovery"); + puts("NOT_RUN: Android private Binder transaction, actual Shizuku/ICU network, Idric reader screen or physical C67"); + return 0; +} diff --git a/tests/test_android_arena_source_owner.sh b/tests/test_android_arena_source_owner.sh new file mode 100644 index 00000000..747da5f3 --- /dev/null +++ b/tests/test_android_arena_source_owner.sh @@ -0,0 +1,92 @@ +#!/bin/sh +# Source-owner test consumes the real canonical appendfat_arena, not a mock. +# Admitted v2 HTML source is required before private PFD handoff. +set -eu +arena_tool=${IB_APPEND_ARENA:-} +helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} +slice=${IB_ARENA_SLICE:-} +test -n "$arena_tool" && test -x "$arena_tool" && +test -n "$helper" && test -x "$helper" && +test -n "$slice" && test -x "$slice" || { + echo 'canonical Arena, compiled reader and test helper required' >&2 + exit 2 +} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -m 700 "$work/private" +"$arena_tool" create "$work/private/responses.arena" 16777216 > "$work/create.txt" +printf '

IB C67

\n' > "$work/fixture.html" +test "$(wc -c < "$work/fixture.html" | tr -d '[:space:]')" -eq 14 +append=$("$arena_tool" append "$work/private/responses.arena" "$work/fixture.html") +old=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=\([0-9][0-9]*\) new_used=[0-9][0-9]* capacity_bytes=[0-9][0-9]*$/\1/p') +new=$(printf '%s\n' "$append" | sed -n 's/^APPENDED old_used=[0-9][0-9]* new_used=\([0-9][0-9]*\) capacity_bytes=[0-9][0-9]*$/\1/p') +test "$old" = 0 && test "$new" = 14 +"$slice" slice "$work/private/responses.arena" "$old" 14 > "$work/readback" +cmp "$work/fixture.html" "$work/readback" +digest=$(sha256sum "$work/fixture.html" | cut -d ' ' -f 1) +mkdir -m 700 "$work/private/receipts" "$work/private/staging" + +receipt_v2() { + id=$1 gen=$2 + { + printf 'schema\tib-icu-arena-v2\n' + printf 'request_id\t%s\n' "$id" + printf 'source_key\tarena:%s\n' "$id" + printf 'source_generation\t%s\n' "$gen" + printf 'requested_url\thttps://example.com/start\n' + printf 'resolved_url\thttps://example.com/final\n' + printf 'http_status\t200\n' + printf 'mime_type\ttext/html\n' + printf 'charset\tutf-8\n' + printf 'content_encoding\tidentity\n' + printf 'utf8\tvalid\n' + printf 'redirect_count\t1\n' + printf 'declared_length\t14\n' + printf 'received_length\t14\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$old" + printf 'arena_end\t%s\n' "$new" + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'acquisition_complete\tYES\n' + printf 'navigation_admitted\tYES\n' + } > "$work/private/receipts/$id.tsv" +} +receipt_v2 verified 1 +receipt_v2 verified2 2 + +# A fully hash-matching legacy body is nevertheless not authorized as HTML. +{ + printf 'schema\tib-icu-arena-v1\n' + printf 'request_id\tlegacy\n' + printf 'requested_url\thttps://example.com/\n' + printf 'resolved_url\tUNOBSERVED\n' + printf 'http_status\tUNOBSERVED\n' + printf 'mime_type\tUNOBSERVED\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$old" + printf 'arena_end\t%s\n' "$new" + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'navigation_admitted\tNO\n' +} > "$work/private/receipts/legacy.tsv" +# A separate AI-CI consumer independently checks this fixture's observed +# admission and committed bytes before the native PFD source owner runs. +aici=${IB_AICI_SOURCE_VERIFY:-} +test -n "$aici" && test -f "$aici" || { + printf 'source admission requires pinned independent AI-CI verifier\n' >&2 + exit 1 +} +sh "$aici" "$work/private" verified 1 https://example.com/start +sh "$aici" "$work/private" verified2 2 https://example.com/start +if sh "$aici" "$work/private" legacy 1 https://example.com/ > "$work/legacy.aici" 2>/dev/null; then + printf 'independent verifier admitted a v1 receipt\n' >&2 + exit 1 +fi +test ! -s "$work/legacy.aici" +"$helper" "$work/private" +printf '%s\n' 'checked_arena=actual_appends' 'pfd_source=v2_admitted_html_only' 'reader_body_sha256=verified' diff --git a/tests/test_android_arena_source_owner_synthetic.sh b/tests/test_android_arena_source_owner_synthetic.sh new file mode 100644 index 00000000..d199d8cb --- /dev/null +++ b/tests/test_android_arena_source_owner_synthetic.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Fast host contract test: synthetic pointer + v2 receipts, NOT canonical appendfat. +set -eu +helper=${IB_TEST_VERIFIED_ARENA_PIPE:-} +test -n "$helper" && test -x "$helper" || exit 2 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -m 700 "$work/private" "$work/private/receipts" "$work/private/staging" +printf '

IB C67

\n' > "$work/fixture.html" +test "$(wc -c < "$work/fixture.html" | tr -d '[:space:]')" -eq 14 +truncate -s 16777216 "$work/private/responses.arena" +dd if="$work/fixture.html" of="$work/private/responses.arena" conv=notrunc status=none +printf '14\n' > "$work/private/responses.arena.used" +: > "$work/private/responses.arena.lock" +digest=$(sha256sum "$work/fixture.html" | cut -d ' ' -f 1) +receipt_v2() { + id=$1 gen=$2 + { + printf 'schema\tib-icu-arena-v2\n' + printf 'request_id\t%s\n' "$id" + printf 'source_key\tarena:%s\n' "$id" + printf 'source_generation\t%s\n' "$gen" + printf 'requested_url\thttps://example.com/start\n' + printf 'resolved_url\thttps://example.com/final\n' + printf 'http_status\t200\n' + printf 'mime_type\ttext/html\n' + printf 'charset\tutf-8\n' + printf 'content_encoding\tidentity\n' + printf 'utf8\tvalid\n' + printf 'redirect_count\t1\n' + printf 'declared_length\t14\n' + printf 'received_length\t14\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t0\n' + printf 'arena_end\t14\n' + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'acquisition_complete\tYES\n' + printf 'navigation_admitted\tYES\n' + } > "$work/private/receipts/$id.tsv" +} +receipt_v2 verified 1 +receipt_v2 verified2 2 +{ + printf 'schema\tib-icu-arena-v1\n' + printf 'request_id\tlegacy\n' + printf 'requested_url\thttps://example.com/\n' + printf 'resolved_url\tUNOBSERVED\n' + printf 'http_status\tUNOBSERVED\n' + printf 'mime_type\tUNOBSERVED\n' + printf 'transport\ticu-app-process-via-binder-shizuku\n' + printf 'arena_name\tresponses.arena\n' + printf 'arena_offset\t0\n' + printf 'arena_end\t14\n' + printf 'byte_count\t14\n' + printf 'sha256\t%s\n' "$digest" + printf 'reopen_check\tMATCH\n' + printf 'navigation_admitted\tNO\n' +} > "$work/private/receipts/legacy.tsv" +# A separate AI-CI consumer independently checks this fixture's observed +# admission and committed bytes before the native PFD source owner runs. +aici=${IB_AICI_SOURCE_VERIFY:-} +test -n "$aici" && test -f "$aici" || { + printf 'source admission requires pinned independent AI-CI verifier\n' >&2 + exit 1 +} +sh "$aici" "$work/private" verified 1 https://example.com/start +sh "$aici" "$work/private" verified2 2 https://example.com/start +if sh "$aici" "$work/private" legacy 1 https://example.com/ > "$work/legacy.aici" 2>/dev/null; then + printf 'independent verifier admitted a v1 receipt\n' >&2 + exit 1 +fi +test ! -s "$work/legacy.aici" +"$helper" "$work/private" +printf 'fixture\tsynthetic-pointer-not-canonical-appendfat\n' diff --git a/tests/test_android_icu_arena.sh b/tests/test_android_icu_arena.sh new file mode 100644 index 00000000..147297cd --- /dev/null +++ b/tests/test_android_icu_arena.sh @@ -0,0 +1,215 @@ +#!/bin/sh +# Host contract test with real compiled canonical appendfat_arena and native +# reader. The Shizuku + ICU #35 producer is deliberately mocked here. +set -eu +root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +arena_tool=${IB_APPEND_ARENA:-} +reader=${IB_ARENA_SLICE:-} +test -x "$arena_tool" && test -x "$reader" || { + printf 'IB_APPEND_ARENA and IB_ARENA_SLICE executables required\n' >&2 + exit 2 +} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -p "$work/lib" "$work/bin" "$work/test-commands" "$work/mock" +cp "$root/lib/android_shizuku_icu_provider.grease" "$root/lib/android_binder_icu.grease" "$work/lib/" +cp "$root/bin/ib_android_icu_arena.grease" "$work/bin/" +cp "$root/bin/ib_android_arena_read.grease" "$work/bin/" + +# Simulate the *framed* output of a single successful PR #35 ICU invocation. +# The expected remote command must contain both paired capture env variables, +# signed artifact checks and the actual Idric.Generated get path. These tests +# say nothing about a deployed ICU binary, TLS or real rish. +cat > "$work/lib/android_shizuku_host.grease" <<'SHIZUKU' +ib_shizuku_authority() { + if test "${IB_TEST_DENIED:-0}" = 1; then printf 'uid:10123\n' + else printf 'shell\n'; fi +} +ib_shizuku_run_bounded() { + test "$1" = 30 || return 99 + printf '%s\n' "$2" >> "$IB_TEST_REMOTE_COMMANDS" + case "$2" in + *ICU_CAPTURE_BODY_PATH*ICU_CAPTURE_METADATA_PATH*Idric.Generated\ get*) ;; + *) return 91 ;; + esac + case "$2" in *sha256sum*'IBICU01 %08d %08d'*) ;; *) return 92 ;; esac + stub_work=$(mktemp -d "$IB_TEST_TMP/stub.XXXXXXXX") || return 90 + stub_body="$stub_work/body" + stub_meta="$stub_work/meta" + mode=${IB_TEST_MODE:-ok} + status=200 + resolved=https://example.com/final + redirect_count=2 + content_type='text/html; charset=utf-8' + content_encoding=identity + utf8=valid + case "$mode" in + ok|wrong_length|wrong_declared|duplicate|bad_status|bad_resolved|missing) printf 'C67 π ☼\n' > "$stub_body" ;; + binary) printf 'bin\000ary\377\n' > "$stub_body"; content_type=application/octet-stream; utf8=invalid ;; + utf8_invalid) printf 'bad\377' > "$stub_body"; utf8=invalid ;; + no_charset) printf '

no charset

' > "$stub_body"; content_type=text/html ;; + gzip) printf '

gzip marker

' > "$stub_body"; content_encoding=gzip ;; + fail) printf 'partial'; rm -rf "$stub_work"; return 29 ;; + bad_frame) printf 'not-an-ICU-envelope\n'; rm -rf "$stub_work"; return 0 ;; + oversized) head -c 4194305 /dev/zero > "$stub_body" ;; + *) rm -rf "$stub_work"; return 98 ;; + esac + case "$mode" in + bad_status) status=404 ;; + bad_resolved) resolved='javascript:alert(1)' ;; + esac + body_bytes=$(wc -c < "$stub_body" | tr -d '[:space:]') + declared=$body_bytes + received=$body_bytes + case "$mode" in + wrong_length) received=$((body_bytes - 1)) ;; + wrong_declared) declared=$((body_bytes - 1)) ;; + esac + { + printf 'http_status\t%s\n' "$status" + printf 'content_type\t%s\n' "$content_type" + printf 'content_encoding\t%s\n' "$content_encoding" + printf 'declared_length\t%s\n' "$declared" + printf 'received_length\t%s\n' "$received" + printf 'utf8\t%s\n' "$utf8" + printf 'resolved_url\t%s\n' "$resolved" + printf 'redirect_count\t%s\n' "$redirect_count" + if test "$mode" = duplicate; then printf 'http_status\t200\n'; fi + } > "$stub_meta" + if test "$mode" = missing; then + sed -i '/^redirect_count/d' "$stub_meta" + fi + meta_bytes=$(wc -c < "$stub_meta" | tr -d '[:space:]') + printf 'IBICU01 %08d %08d\n' "$meta_bytes" "$body_bytes" + cat "$stub_meta" + cat "$stub_body" + rm -rf "$stub_work" +} +SHIZUKU + +export IB_TEST_TMP="$work/mock" +export IB_TEST_REMOTE_COMMANDS="$work/test-commands/commands.txt" +export IB_C67_ICU_DEX=/data/local/tmp/ib-icu/classes.dex +export IB_C67_ICU_SO=/data/local/tmp/ib-icu/libicu_transport.so +export IB_C67_ICU_DEX_SHA256=1111111111111111111111111111111111111111111111111111111111111111 +export IB_C67_ICU_SO_SHA256=2222222222222222222222222222222222222222222222222222222222222222 +export IB_APPEND_ARENA="$arena_tool" +export IB_ARENA_SLICE="$reader" +mkdir -p "$work/private" +chmod 700 "$work/private" +"$arena_tool" create "$work/private/responses.arena" 16777216 >/dev/null +run() { sh "$work/bin/ib_android_icu_arena.grease" "$1" "$2" "$work/private" "${3:-1}"; } +readback() { sh "$work/bin/ib_android_arena_read.grease" "$1" "$work/private"; } +field() { awk -F '\t' -v name="$2" '$1==name {print $2}' "$work/private/receipts/$1.tsv"; } + +run first https://example.com/start 17 > "$work/output1" +test "$(field first schema)" = ib-icu-arena-v2 +test "$(field first http_status)" = 200 +test "$(field first resolved_url)" = https://example.com/final +test "$(field first redirect_count)" = 2 +test "$(field first mime_type)" = text/html +test "$(field first charset)" = utf-8 +test "$(field first source_generation)" = 17 +test "$(field first source_key)" = arena:first +test "$(field first acquisition_complete)" = YES +test "$(field first navigation_admitted)" = YES +first_bytes=$(field first byte_count) +test "$(field first arena_offset)" = 0 +test "$(cat "$work/private/responses.arena.used")" -gt 0 +printf 'C67 π ☼\n' > "$work/expected" +readback first > "$work/first-body" +cmp -s "$work/expected" "$work/first-body" + +export IB_TEST_MODE=binary +run second https://example.com/binary 18 > "$work/output2" +test "$(field second navigation_admitted)" = NO +test "$(field second mime_type)" = application/octet-stream +test "$(field second utf8)" = invalid +printf 'bin\000ary\377\n' > "$work/expected-binary" +readback second > "$work/read2" +cmp -s "$work/expected-binary" "$work/read2" + +# Correct non-HTML, unsupported encoding, absent charset and invalid UTF-8 +# remain readable as bounded bytes but never acquire navigation authority. +for mode in no_charset gzip utf8_invalid; do + export IB_TEST_MODE="$mode" + run "$mode" https://example.com/ > /dev/null + test "$(field "$mode" navigation_admitted)" = NO + readback "$mode" > "$work/$mode.read" + test -s "$work/$mode.read" +done + +# Mutated receipts must never emit any prefix. Also refuse forged admission +# on an otherwise valid non-HTML committed range. +cp "$work/private/receipts/first.tsv" "$work/first.original.tsv" +sed -i 's/^sha256[[:space:]].*/sha256\t0000000000000000000000000000000000000000000000000000000000000000/' \ + "$work/private/receipts/first.tsv" +if readback first > "$work/read-invalid" 2>/dev/null; then exit 1; fi +test ! -s "$work/read-invalid" +cp "$work/first.original.tsv" "$work/private/receipts/first.tsv" +cp "$work/private/receipts/second.tsv" "$work/second.original.tsv" +sed -i 's/^navigation_admitted[[:space:]].*/navigation_admitted\tYES/' \ + "$work/private/receipts/second.tsv" +if readback second > "$work/forged-admission" 2>/dev/null; then exit 1; fi +test ! -s "$work/forged-admission" +cp "$work/second.original.tsv" "$work/private/receipts/second.tsv" + +# v1 can still supply verified bytes but cannot be upgraded to navigation. +{ + printf 'schema\tib-icu-arena-v1\n' + printf 'request_id\tlegacy\n' + printf 'requested_url\t%s\n' "$(field first requested_url)" + printf 'resolved_url\tUNOBSERVED\nhttp_status\tUNOBSERVED\nmime_type\tUNOBSERVED\n' + printf 'transport\ticu-app-process-via-binder-shizuku\narena_name\tresponses.arena\n' + printf 'arena_offset\t%s\n' "$(field first arena_offset)" + printf 'arena_end\t%s\n' "$(field first arena_end)" + printf 'byte_count\t%s\n' "$(field first byte_count)" + printf 'sha256\t%s\n' "$(field first sha256)" + printf 'reopen_check\tMATCH\nnavigation_admitted\tNO\n' +} > "$work/private/receipts/legacy.tsv" +readback legacy > "$work/legacy-body" +cmp -s "$work/expected" "$work/legacy-body" + +# None of these producer failures may advance the commit pointer or publish a +# receipt. Fresh request IDs are used because request locks are single-use. +used=$(cat "$work/private/responses.arena.used") +expect_refusal() { + mode=$1 + export IB_TEST_MODE="$mode" + if run "reject_$mode" https://example.com/ > "$work/refused" 2>/dev/null; then exit 1; fi + test ! -e "$work/private/receipts/reject_$mode.tsv" + test "$(cat "$work/private/responses.arena.used")" = "$used" +} +for mode in fail bad_frame oversized wrong_length wrong_declared duplicate bad_status bad_resolved missing; do + expect_refusal "$mode" +done +export IB_TEST_MODE=ok +if run second https://example.com/ > /dev/null 2>&1; then exit 1; fi +if run zero https://example.com/ 0 > /dev/null 2>&1; then exit 1; fi +if run leading https://example.com/ 017 > /dev/null 2>&1; then exit 1; fi + +# Denied Shizuku or unsafe URLs must not run a remote command. +export IB_TEST_DENIED=1 +before=$(wc -l < "$IB_TEST_REMOTE_COMMANDS") +if run denied https://example.com/ > /dev/null 2>&1; then exit 1; fi +test "$(wc -l < "$IB_TEST_REMOTE_COMMANDS")" = "$before" +unset IB_TEST_DENIED +if run injected "https://example.com/a'\$(touch '$work/INJECTED')" > /dev/null 2>&1; then exit 1; fi +test ! -e "$work/INJECTED" +test "$(wc -l < "$IB_TEST_REMOTE_COMMANDS")" = "$before" +if run unsupported file:///etc/passwd > /dev/null 2>&1; then exit 1; fi +test "$(cat "$work/private/responses.arena.used")" = "$used" + +# Independent native reader guards out-of-range and malformed used pointers. +if "$reader" slice "$work/private/responses.arena" "$used" 1 >/dev/null 2>&1; then exit 1; fi +if "$reader" slice "$work/private/responses.arena" 0 4194305 >/dev/null 2>&1; then exit 1; fi +cp "$work/private/responses.arena.used" "$work/used.bak" +printf '99999999999999999\n' > "$work/private/responses.arena.used" +if "$reader" slice "$work/private/responses.arena" 0 1 >/dev/null 2>&1; then exit 1; fi +cp "$work/used.bak" "$work/private/responses.arena.used" +"$reader" slice "$work/private/responses.arena" 0 "$first_bytes" > "$work/restored" +cmp -s "$work/expected" "$work/restored" + +printf '%s\n' \ + 'PASS: framed ICU #35 metadata/body contract, observed redirect, canonical range/digest, v1 refusal, conditional navigation, binary fidelity and negative admission checks' \ + 'NOT_RUN: real ICU HTTPS, Android DEX/ARM32, physical Shizuku, C67 UI, private PFD or durable navigation' diff --git a/tests/test_android_icu_native_bridge.sh b/tests/test_android_icu_native_bridge.sh new file mode 100644 index 00000000..fd581a71 --- /dev/null +++ b/tests/test_android_icu_native_bridge.sh @@ -0,0 +1,232 @@ +#!/bin/sh +# Host integration of actual ICU #35 native transport through IB's unchanged +# Shizuku framing + canonical Arena producer and verified reader. +# The app_process wrapper, Shizuku authority and HTTP server are TEST ONLY. +# This does not qualify Idriç DEX, real rish, Android or physical C67. +set -eu +export LC_ALL=C +root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +arena_tool=${IB_APPEND_ARENA:-} +slice_tool=${IB_ARENA_SLICE:-} +probe=${IB_ICU_NATIVE_PROBE:-} +native_so=${IB_ICU_NATIVE_SO:-} +for executable in "$arena_tool" "$slice_tool" "$probe"; do + test -n "$executable" && test -x "$executable" || { + echo 'compiled native test prerequisites absent' >&2 + exit 2 + } +done +test -n "$native_so" && test -f "$native_so" || exit 2 +work=$(mktemp -d) +server_pid= +cleanup() { + if test -n "$server_pid"; then kill "$server_pid" 2>/dev/null || :; fi + rm -rf "$work" +} +trap cleanup EXIT HUP INT TERM +mkdir -p "$work/lib" "$work/bin" "$work/fake-bin" "$work/private-shell" "$work/remote-commands" +cp "$root/lib/android_shizuku_icu_provider.grease" "$root/lib/android_binder_icu.grease" "$work/lib/" +cp "$root/bin/ib_android_icu_arena.grease" "$root/bin/ib_android_arena_read.grease" "$work/bin/" +cp "$native_so" "$work/private-shell/libicu_transport.so" +printf '%s\n' 'fixture-not-real-android-dex' > "$work/private-shell/classes.dex" +chmod 700 "$work/private-shell" +export IB_ICU_TEST_SHELL_ROOT="$work/private-shell" +export IB_TEST_REMOTE_COMMANDS="$work/remote-commands/commands.txt" +export IB_ICU_NATIVE_PROBE="$probe" +export IB_C67_ICU_DEX=/data/local/tmp/ib-icu/classes.dex +export IB_C67_ICU_SO=/data/local/tmp/ib-icu/libicu_transport.so +export IB_C67_ICU_DEX_SHA256 +IB_C67_ICU_DEX_SHA256=$(sha256sum "$work/private-shell/classes.dex" | cut -d ' ' -f 1) +export IB_C67_ICU_SO_SHA256 +IB_C67_ICU_SO_SHA256=$(sha256sum "$work/private-shell/libicu_transport.so" | cut -d ' ' -f 1) + +cat > "$work/lib/android_shizuku_host.grease" <<'SHIZUKU' +ib_shizuku_authority() { printf 'shell\n'; } +ib_shizuku_run_bounded() { + test "$1" = 30 || return 90 + case "$2" in + *ICU_CAPTURE_BODY_PATH*ICU_CAPTURE_METADATA_PATH*Idric.Generated\ get*) ;; + *) return 91 ;; + esac + printf '%s\n' "$2" >> "$IB_TEST_REMOTE_COMMANDS" + # Host-only translation of the Android shell-owned private directory. + # The production provider still requires the pinned Android path atoms. + translated=$(printf '%s\n' "$2" | + sed "s|/data/local/tmp/ib-icu|$IB_ICU_TEST_SHELL_ROOT|g") + sh -c "$translated" +} +SHIZUKU +cat > "$work/fake-bin/app_process" <<'WRAPPER' +#!/bin/sh +# This wrapper invokes ICU #35's *actual compiled native transport*, but is +# deliberately not a claim of running a real Android Idriç app_process DEX. +set -eu +test "$#" = 5 +case "$1" in -Dicu.library=*) ;; *) exit 80 ;; esac +test "$2" = /system/bin +test "$3" = Idric.Generated +test "$4" = get +exec "$IB_ICU_NATIVE_PROBE" "$5" +WRAPPER +chmod 700 "$work/fake-bin/app_process" +PATH="$work/fake-bin:$PATH" +export PATH + +http_port=18932 +tls_port=18933 +openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$work/server.key" -out "$work/server.crt" -days 1 \ + -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost' \ + > /dev/null 2>&1 +export SSL_CERT_FILE="$work/server.crt" +python3 - "$http_port" "$tls_port" "$work/server.crt" "$work/server.key" \ + > "$work/server.log" 2>&1 <<'PY' & +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ssl +import sys +import threading + +plain_port, tls_port = map(int, sys.argv[1:3]) +cert_file, key_file = sys.argv[3:5] + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + + def log_message(self, *args): + pass + + def do_GET(self): + if self.path == "/start": + self.send_response(302) + self.send_header("Location", "/final") + self.end_headers() + return + if self.path == "/not-found": + self.send_response(404) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", "0") + self.end_headers() + return + if self.path == "/truncated": + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", "50") + self.end_headers() + self.wfile.write(b"short") + return + if self.path == "/binary": + body = bytes([0, 255, 10]) + mime = "application/octet-stream" + elif self.path == "/final": + body = b"

ICU 35 host

\n" + mime = "text/html; charset=utf-8" + else: + self.send_response(400) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +plain = ThreadingHTTPServer(("127.0.0.1", plain_port), Handler) +secure = ThreadingHTTPServer(("127.0.0.1", tls_port), Handler) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain(cert_file, key_file) +secure.socket = context.wrap_socket(secure.socket, server_side=True) +threading.Thread(target=plain.serve_forever, daemon=True).start() +threading.Thread(target=secure.serve_forever, daemon=True).start() +threading.Event().wait() +PY +server_pid=$! +ready=NO +for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if curl --max-time 1 -fsS "http://127.0.0.1:$http_port/final" >/dev/null 2>&1; then + ready=YES + break + fi + sleep 1 +done +test "$ready" = YES || { cat "$work/server.log" >&2; exit 1; } + +export IB_APPEND_ARENA="$arena_tool" IB_ARENA_SLICE="$slice_tool" +mkdir -m 700 "$work/private" +"$arena_tool" create "$work/private/responses.arena" 16777216 >/dev/null +run() { sh "$work/bin/ib_android_icu_arena.grease" "$1" "$2" "$work/private" "$3"; } +readback() { sh "$work/bin/ib_android_arena_read.grease" "$1" "$work/private"; } +field() { awk -F '\t' -v name="$2" '$1==name {print $2}' "$work/private/receipts/$1.tsv"; } + +# Both HTTPS and HTTP use the actual compiled ICU socket/TLS response parser, +# through IB's remote length-framed capture and verified canonical append. +run secure "https://localhost:$tls_port/start" 17 > "$work/out-secure" +test "$(field secure schema)" = ib-icu-arena-v2 +test "$(field secure resolved_url)" = "https://localhost:$tls_port/final" +test "$(field secure redirect_count)" = 1 +test "$(field secure http_status)" = 200 +test "$(field secure mime_type)" = text/html +test "$(field secure charset)" = utf-8 +test "$(field secure navigation_admitted)" = YES +printf '

ICU 35 host

\n' > "$work/expected.html" +readback secure > "$work/secure.body" +cmp -s "$work/expected.html" "$work/secure.body" +# A separate AI-CI program validates the *actual* persisted ICU response, +# with its own byte-offset read rather than IB's native slice/checking logic. +aici_verify=${IB_AICI_SOURCE_VERIFY:-} +test -n "$aici_verify" && test -f "$aici_verify" || { + printf 'independent AI-CI source verifier must be pinned\n' >&2 + exit 1 +} +sh "$aici_verify" "$work/private" secure 17 "https://localhost:$tls_port/start" \ + > "$work/aici-secure.tsv" +grep -F "$(printf 'PASS\tib-source-v2-admitted\tsecure\t17\t')" "$work/aici-secure.tsv" + +run plain "http://127.0.0.1:$http_port/start" 18 > "$work/out-plain" +test "$(field plain resolved_url)" = "http://127.0.0.1:$http_port/final" +test "$(field plain navigation_admitted)" = YES +readback plain > "$work/plain.body" +cmp -s "$work/expected.html" "$work/plain.body" +sh "$aici_verify" "$work/private" plain 18 "http://127.0.0.1:$http_port/start" \ + > "$work/aici-plain.tsv" +grep -F "$(printf 'PASS\tib-source-v2-admitted\tplain\t18\t')" "$work/aici-plain.tsv" + +run binary "http://127.0.0.1:$http_port/binary" 19 > "$work/out-binary" +test "$(field binary utf8)" = invalid +test "$(field binary navigation_admitted)" = NO +printf '\000\377\n' > "$work/expected-binary" +readback binary > "$work/binary.body" +cmp -s "$work/expected-binary" "$work/binary.body" +if sh "$aici_verify" "$work/private" binary 19 \ + "http://127.0.0.1:$http_port/binary" > "$work/aici-binary.tsv" 2>/dev/null; then + echo 'independent oracle wrongly admitted binary resource' >&2 + exit 1 +fi +test ! -s "$work/aici-binary.tsv" + +# ICU refuses incomplete and 404 acquisitions before any Arena publication. +used=$(cat "$work/private/responses.arena.used") +for mode in not-found truncated; do + if run "reject_$mode" "https://localhost:$tls_port/$mode" 20 \ + > "$work/reject.stdout" 2> "$work/reject.stderr"; then + echo "ICU native $mode unexpectedly succeeded" >&2 + exit 1 + fi + test ! -e "$work/private/receipts/reject_$mode.tsv" + test "$(cat "$work/private/responses.arena.used")" = "$used" +done +good_digest=$IB_C67_ICU_SO_SHA256 +IB_C67_ICU_SO_SHA256=0000000000000000000000000000000000000000000000000000000000000000 +export IB_C67_ICU_SO_SHA256 +if run wronghash "http://127.0.0.1:$http_port/final" 21 >/dev/null 2>&1; then + echo 'failed ICU binary pin was accepted' >&2 + exit 1 +fi +test ! -e "$work/private/receipts/wronghash.tsv" +test "$(cat "$work/private/responses.arena.used")" = "$used" +IB_C67_ICU_SO_SHA256=$good_digest +export IB_C67_ICU_SO_SHA256 +printf '%s\n' \ + 'PASS: real compiled pinned ICU #35 native HTTP and HTTPS+redirect captured by IB Shizuku frame into canonical appendfat and verified v2 reader' \ + 'PASS: independent AI-CI verified two actual persisted HTML sources and rejected binary resource' \ + 'PASS: binary fidelity, HTTP 404, truncated length, native hash mismatch, denied publication' \ + 'NOT_RUN: actual Android app_process Idric DEX, physical Shizuku/rish, Binder/PFD and C67 UI' diff --git a/tests/test_android_reader_pfd.c b/tests/test_android_reader_pfd.c new file mode 100644 index 00000000..f57ad300 --- /dev/null +++ b/tests/test_android_reader_pfd.c @@ -0,0 +1,218 @@ +#define _POSIX_C_SOURCE 200809L +#include "../native/android/ib_reader_pfd.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *abc_digest = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; +static const char *hello_digest = + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; +static const char *binary_digest = + "c8ac851bcdb2785ab14adb0b61e4743865e6999e03481b9409b67de2cdc29c4a"; + +static void must_write(int fd, const unsigned char *bytes, size_t count) { + while (count != 0) { + ssize_t written = write(fd, bytes, count); + if (written < 0 && errno == EINTR) continue; + assert(written > 0); + bytes += (size_t)written; + count -= (size_t)written; + } +} + +/* Host simulation only: SCM_RIGHTS carries an anonymous pipe between two + * independent processes. Android uses Binder's ParcelFileDescriptor instead. */ +static void send_descriptor(int socket, int descriptor) { + char byte = 'F'; + struct iovec segment = { &byte, sizeof(byte) }; + union { + struct cmsghdr align; + unsigned char space[CMSG_SPACE(sizeof(int))]; + } control; + memset(&control, 0, sizeof(control)); + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.space; + message.msg_controllen = sizeof(control.space); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + assert(header != NULL); + header->cmsg_level = SOL_SOCKET; + header->cmsg_type = SCM_RIGHTS; + header->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(header), &descriptor, sizeof(descriptor)); + assert(sendmsg(socket, &message, 0) == 1); +} + +static int receive_descriptor(int socket) { + char byte = '\0'; + struct iovec segment = { &byte, sizeof(byte) }; + union { + struct cmsghdr align; + unsigned char space[CMSG_SPACE(sizeof(int))]; + } control; + memset(&control, 0, sizeof(control)); + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.space; + message.msg_controllen = sizeof(control.space); + assert(recvmsg(socket, &message, 0) == 1); + assert(byte == 'F' && (message.msg_flags & MSG_CTRUNC) == 0); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + assert(header != NULL && header->cmsg_level == SOL_SOCKET); + assert(header->cmsg_type == SCM_RIGHTS); + assert(header->cmsg_len == CMSG_LEN(sizeof(int))); + int descriptor = -1; + memcpy(&descriptor, CMSG_DATA(header), sizeof(descriptor)); + assert(descriptor >= 0); + return descriptor; +} + +static int transferred_stream(const unsigned char *data, size_t size) { + int sockets[2]; + assert(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0); + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + close(sockets[0]); + int stream[2]; + if (pipe(stream) != 0) _exit(33); + send_descriptor(sockets[1], stream[0]); + close(stream[0]); + if (size != 0) must_write(stream[1], data, size); + close(stream[1]); + close(sockets[1]); + _exit(0); + } + close(sockets[1]); + int descriptor = receive_descriptor(sockets[0]); + close(sockets[0]); + int status = 0; + assert(waitpid(child, &status, 0) == child); + assert(WIFEXITED(status) && WEXITSTATUS(status) == 0); + return descriptor; +} + +static ib_reader_source_ticket ticket(const char *name, uint64_t generation, + uint32_t count, const char *digest) { + ib_reader_source_ticket value = {0}; + assert(strlen(name) <= IB_READER_REQUEST_ID_CAP); + memcpy(value.request_id, name, strlen(name) + 1U); + value.navigation_generation = generation; + value.byte_count = count; + assert(strlen(digest) == 64U); + memcpy(value.sha256_hex, digest, 65U); + return value; +} + +static void intact(const ib_reader_source *source, const unsigned char *bytes, + size_t count, const char *request, uint64_t generation) { + assert(source->bytes != NULL); + assert(source->byte_count == count); + assert(memcmp(source->bytes, bytes, count) == 0); + assert(strcmp(source->request_id, request) == 0); + assert(source->published_navigation_generation == generation); +} + +int main(void) { + static const unsigned char abc[] = "abc"; + static const unsigned char hello[] = "hello"; + static const unsigned char binary[] = {'b','i','n',0,'a','r','y',255,'\n'}; + ib_reader_source source; + ib_reader_source_init(&source); + ib_reader_source_want(&source, 7U); + + ib_reader_source_ticket first = ticket("first", 7, 3, abc_digest); + int fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, abc, 3, "first", 7); + assert(source.bytes[3] == '\0'); + + /* A readable file, even one with valid data/digest, is not an + * accepted PFD: the reader must never receive the Arena file. */ + FILE *file = tmpfile(); + assert(file != NULL); + assert(fwrite(abc, 1, 3, file) == 3U); + assert(fflush(file) == 0 && fseek(file, 0, SEEK_SET) == 0); + assert(ib_reader_source_accept_fd(&source, &first, fileno(file)) == + IB_READER_PFD_BAD_TICKET); + assert(fclose(file) == 0); + int rejected_pipe[2]; + assert(pipe(rejected_pipe) == 0); + assert(ib_reader_source_accept_fd(&source, &first, rejected_pipe[1]) == + IB_READER_PFD_BAD_TICKET); + close(rejected_pipe[0]); + close(rejected_pipe[1]); + intact(&source, abc, 3, "first", 7); + + /* Corrupt stream and metadata never evict the last published source. */ + ib_reader_source_ticket invalid_digest = ticket("broken", 7, 5, hello_digest); + invalid_digest.sha256_hex[0] = '0'; + fd = transferred_stream(hello, 5); + assert(ib_reader_source_accept_fd(&source, &invalid_digest, fd) == IB_READER_PFD_DIGEST); + close(fd); + intact(&source, abc, 3, "first", 7); + + fd = transferred_stream(abc, 2); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_IO); + close(fd); + intact(&source, abc, 3, "first", 7); + + static const unsigned char longer[] = "abcX"; + fd = transferred_stream(longer, 4); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_IO); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket invalid_name = ticket("..bad", 7, 3, abc_digest); + invalid_name.request_id[0] = '/'; + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &invalid_name, fd) == IB_READER_PFD_BAD_TICKET); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket too_large = ticket("large", 7, IB_READER_SOURCE_MAX_BYTES+1, + abc_digest); + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &too_large, fd) == IB_READER_PFD_BAD_TICKET); + close(fd); + intact(&source, abc, 3, "first", 7); + + /* New navigation invalidates an old PFD even if its hash is valid. */ + ib_reader_source_want(&source, 8U); + fd = transferred_stream(abc, 3); + assert(ib_reader_source_accept_fd(&source, &first, fd) == IB_READER_PFD_STALE); + close(fd); + intact(&source, abc, 3, "first", 7); + + ib_reader_source_ticket second = ticket("second", 8, 5, hello_digest); + fd = transferred_stream(hello, 5); + assert(ib_reader_source_accept_fd(&source, &second, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, hello, 5, "second", 8); + + ib_reader_source_want(&source, 9U); + ib_reader_source_ticket third = ticket("binary-source", 9, sizeof(binary), binary_digest); + fd = transferred_stream(binary, sizeof(binary)); + assert(ib_reader_source_accept_fd(&source, &third, fd) == IB_READER_PFD_ACCEPTED); + close(fd); + intact(&source, binary, sizeof(binary), "binary-source", 9); + + ib_reader_source_clear(&source); + assert(source.bytes == NULL && source.byte_count == 0U); + puts("PASS: two-process descriptor transfer, SHA-256, bounded exact binary, file/writable/stale/partial/extra/corrupt/oversized refusal, last-good preservation"); + puts("NOT_RUN: Android Binder service, Shizuku, native Idric screen wiring, actual C67"); + return 0; +}