diff --git a/.github/workflows/ib-c67-shizuku-ingress.yml b/.github/workflows/ib-c67-shizuku-ingress.yml new file mode 100644 index 00000000..fdd467b0 --- /dev/null +++ b/.github/workflows/ib-c67-shizuku-ingress.yml @@ -0,0 +1,148 @@ +name: IB C67 Shizuku-to-private-Arena ingress +on: + pull_request: + paths: + - 'native/android/ib_shizuku_arena_ingress.*' + - 'native/android/ib_shizuku_arena_publish.*' + - 'native/android/ib_shizuku_ingress_binder.*' + - 'native/android/ib_shizuku_ingress_runner.*' + - 'lib/android_shizuku_icu_provider.grease' + - 'lib/android_binder_icu.grease' + - 'bin/ib_android_icu_stdout.grease' + - 'bin/ib_android_icu_frame.grease' + - 'tests/test_shizuku*' + - 'examples/autogenerated/google-capture/**' + - '.github/workflows/ib-c67-shizuku-ingress.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + fast-host: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - id: ick + uses: isomorphisms/ai-ci/ick-host@015cc7901ae0b3ad262b476f24e129b53c56db95 + - name: Cross-process binary PFD stream and refusal cases + env: + ICK: ${{ steps.ick.outputs.compiler }} + run: | + set -euo pipefail + mkdir -p build/shizuku-ingress/fast + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + native/android/ib_reader_pfd.c \ + native/android/ib_shizuku_arena_ingress.c \ + tests/test_shizuku_arena_ingress_host.c \ + -o build/shizuku-ingress/fast/test-ingress + build/shizuku-ingress/fast/test-ingress | tee build/shizuku-ingress/fast/receipt.txt + grep -F 'PASS: cross-process anonymous FD delivery' build/shizuku-ingress/fast/receipt.txt + sha256sum build/shizuku-ingress/fast/test-ingress > build/shizuku-ingress/fast/sha256.txt + - name: Pinned ICU stdout and Shizuku denial controls + run: | + set -euo pipefail + sh -n lib/android_shizuku_icu_provider.grease + sh -n lib/android_binder_icu.grease + sh -n bin/ib_android_icu_stdout.grease + sh -n bin/ib_android_icu_frame.grease + sh tests/test_shizuku_icu_stream.sh | tee build/shizuku-ingress/fast/icu-stream-receipt.txt + grep -F 'PASS: pinned ICU binary stdout' build/shizuku-ingress/fast/icu-stream-receipt.txt + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ib-shizuku-ingress-fast + path: build/shizuku-ingress/fast/ + if-no-files-found: error + + pinned-canonical-and-arm: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Checkout pinned canonical appendfat + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + repository: isomorphisms/sd-card-append-fat + ref: 05a0cb7246c5a9f46abfbe92925812e7b607f454 + path: .deps/appendfat + persist-credentials: false + - id: ick + uses: isomorphisms/ai-ci/ick-host@015cc7901ae0b3ad262b476f24e129b53c56db95 + - name: Actual canonical Arena publication and reopen + env: + ICK: ${{ steps.ick.outputs.compiler }} + run: | + set -euo pipefail + test "$(git -C .deps/appendfat rev-parse HEAD)" = 05a0cb7246c5a9f46abfbe92925812e7b607f454 + test -x "$ICK" + mkdir -p build/shizuku-ingress/canonical + "$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/shizuku-ingress/canonical/appendfat_arena + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIE -c .deps/appendfat/lib/appendfat_arena.c \ + -o build/shizuku-ingress/canonical/appendfat_arena.o + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread -I.deps/appendfat/lib \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + native/android/ib_shizuku_arena_ingress.c native/android/ib_shizuku_arena_publish.c \ + tests/test_shizuku_ingress_canonical.c \ + build/shizuku-ingress/canonical/appendfat_arena.o \ + -o build/shizuku-ingress/canonical/test-ingress + env IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/shizuku-ingress/canonical/appendfat_arena" \ + IB_INGRESS_CANONICAL_TEST="$GITHUB_WORKSPACE/build/shizuku-ingress/canonical/test-ingress" \ + sh tests/test_shizuku_ingress_canonical.sh | tee build/shizuku-ingress/canonical/receipt.txt + grep -F 'PASS: real pinned appendfat Arena stream commit' build/shizuku-ingress/canonical/receipt.txt + "$ICK" -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread -I.deps/appendfat/lib \ + native/android/ib_reader_pfd.c native/android/ib_arena_source_owner.c \ + native/android/ib_shizuku_arena_ingress.c native/android/ib_shizuku_arena_publish.c \ + tests/test_shizuku_frame_canonical.c \ + build/shizuku-ingress/canonical/appendfat_arena.o \ + -o build/shizuku-ingress/canonical/test-frame + env IB_APPEND_ARENA="$GITHUB_WORKSPACE/build/shizuku-ingress/canonical/appendfat_arena" \ + IB_INGRESS_CANONICAL_TEST="$GITHUB_WORKSPACE/build/shizuku-ingress/canonical/test-frame" \ + sh tests/test_shizuku_ingress_canonical.sh > build/shizuku-ingress/canonical/frame-receipt.txt + cat build/shizuku-ingress/canonical/frame-receipt.txt + grep -F 'PASS: paired ICU frame through private ingress' build/shizuku-ingress/canonical/frame-receipt.txt + sha256sum build/shizuku-ingress/canonical/test-ingress \ + build/shizuku-ingress/canonical/test-frame \ + build/shizuku-ingress/canonical/appendfat_arena \ + build/shizuku-ingress/canonical/appendfat_arena.o \ + > build/shizuku-ingress/canonical/sha256.txt + - name: Cross-compile Binder/PFD ingress and canonical publisher adapter + 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/shizuku-ingress/$abi" + "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIC -shared -pthread native/android/ib_reader_pfd.c \ + native/android/ib_arena_pfd_provider.c \ + native/android/ib_shizuku_arena_ingress.c \ + native/android/ib_shizuku_ingress_binder.c \ + native/android/ib_shizuku_ingress_runner.c -lbinder_ndk \ + -o "build/shizuku-ingress/$abi/libib_shizuku_ingress.so" + "$toolchain/$target-clang" -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIC -I.deps/appendfat/lib -c native/android/ib_shizuku_arena_publish.c \ + -o "build/shizuku-ingress/$abi/ib_arena_publisher.o" + "$toolchain/llvm-readelf" -h "build/shizuku-ingress/$abi/libib_shizuku_ingress.so" > "build/shizuku-ingress/$abi/elf.txt" + grep -F "Machine:" "build/shizuku-ingress/$abi/elf.txt" | grep -F "$machine" + sha256sum "build/shizuku-ingress/$abi/libib_shizuku_ingress.so" \ + "build/shizuku-ingress/$abi/ib_arena_publisher.o" > "build/shizuku-ingress/$abi/sha256.txt" + printf 'abi\t%s\nbinder_ndk\tCOMPILED\ncanonical_appendfat_android_link\tNOT_RUN\nshizuku_runtime\tNOT_RUN\nphysical_C67\tNOT_RUN\n' "$abi" \ + > "build/shizuku-ingress/$abi/receipt.tsv" + done + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ib-shizuku-ingress-canonical-abi + path: | + build/shizuku-ingress/canonical/ + build/shizuku-ingress/armeabi-v7a/ + build/shizuku-ingress/arm64-v8a/ + if-no-files-found: error diff --git a/bin/ib_android_icu_frame.grease b/bin/ib_android_icu_frame.grease new file mode 100644 index 00000000..28eb1397 --- /dev/null +++ b/bin/ib_android_icu_frame.grease @@ -0,0 +1,15 @@ +#!/usr/bin/env grease +# Private Binder worker: send ICU's paired capture to the app's WRITE PFD. +# The app validates framing/metadata, commits the body, and reopens it. This +# worker has no app-private Arena path or publication authority. +set -eu +if test "$#" -ne 1; then + printf 'usage: ib_android_icu_frame.grease HTTP_URL\n' >&2 + exit 2 +fi +script_directory=$(dirname "$0") +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" +ib_binder_icu_frame "$1" diff --git a/bin/ib_android_icu_stdout.grease b/bin/ib_android_icu_stdout.grease new file mode 100644 index 00000000..6741a27a --- /dev/null +++ b/bin/ib_android_icu_stdout.grease @@ -0,0 +1,18 @@ +#!/usr/bin/env grease +# Run only from an authorized, pinned private Binder broker holding the +# WRITE-end PFD as stdout. No Arena path or app UID filesystem access. +set -eu +if test "$#" -ne 1; then + printf 'usage: ib_android_icu_stdout.grease HTTP_URL\n' >&2 + exit 2 +fi +script_directory=$(dirname "$0") +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" + +# Writes literal ICU response bytes to stdout. The caller must close its PFD +# and send the real exit status to private Binder FINISH; failed or truncated +# stdout cannot become a committed source. +ib_binder_icu_stream "$1" diff --git a/docs/c67-shizuku-arena-ingress.md b/docs/c67-shizuku-arena-ingress.md new file mode 100644 index 00000000..89f4cc1e --- /dev/null +++ b/docs/c67-shizuku-arena-ingress.md @@ -0,0 +1,129 @@ +# C67 Shizuku → app-private Arena ingress + +**Status:** implemented native stream/commit boundaries, host tests and ABI build; +**not a usable phone URL flow**. Stacked on IB #153 → #152 → #112. + +## Reconciliation with admitted v2 source owner + +The paired ICU metadata/body capture from the v2 source branch and the +body-only stdout ingress remain distinct capabilities. Reconciliation retains +the metadata-aware three-argument `ib_shizuku_icu_get`; the stdout helper does +not overwrite it. The canonical ingress publisher still emits v1 with +`navigation_admitted=NO`. Tests independently reopen its retained bytes with +the canonical store and require the newer v2 HTML source owner to refuse +those receipts. They do not weaken the source owner or claim that this +body-only publisher can feed an admitted page. + +The host object is compiled with `-fPIE` for its PIE test executable. Stream +tests count actual invocations separately from lines in the remote command. +The inherited shell CI boundary remains compatibility debt; local stream +execution uses Grease implementation `5651cf97a1b5042f24f14112a7ade9a1518eb0bc` +with inherited-shell compatibility enabled. Leak checking is disabled only +for that sanitized runtime in the disposable container, whose process +namespace prevents LeakSanitizer inspection. + +The next product dependency is observed HTTP metadata over the authorized +private ingress, followed by Android app-handle lifecycle and the checked +Idriç reader. None is established by compiling the Binder adapter. + +## One owner per operation + +The *browser/Idriç host* owns the requested URL, request identity and +navigation generation. An approved Shizuku broker (UID 2000 if the actual +Binder caller is shell; otherwise an explicitly granted app UID) has only +a one-shot **write descriptor**. It never opens app-private storage or +receives a pathname for the Arena. + + app-owned browser event (URL, request ID, navigation generation) + ↓ explicit approved Binder caller UID + private ShizukuArenaIngress.v1 Binder object + ↙ transaction 2: BEGIN(request_id, generation) + app returns approved URL + one WRITE ParcelFileDescriptor + ↓ + approved broker process / pinned Grease runner + ↓ verified Shizuku authority (rish), checked ICU DEX + native SO + ↓ exactly binary ICU stdout → inherited PFD; no body-as-string + app-owned reader thread drains a bounded anonymous pipe concurrently + ↓ broker closes its WRITE descriptor + transaction 3: FINISH(request_id, generation, actual ICU exit) + ↓ app confirms EOF, <= 4 MiB, successful zero exit, current generation + ↓ app-owned canonical sd-card-append-fat append + commit pointer + ↓ independent committed-range readback and exact byte comparison + ↓ atomic private ib-icu-arena-v1 receipt publication + existing IB source-owner verifies receipt, bytes and SHA-256 + ↓ later source-linked Idriç prepaint and native reader (NOT WIRED) + +### Native owners + +- \`ib_shizuku_arena_ingress.{h,c}\`: single-use stream lifecycle, exact + Binder caller UID/ID/generation admission, bounded concurrent draining, + 35-second deadline, 4 MiB body cap, cancellation and fail-closed FINISH. +- \`ib_shizuku_ingress_binder.{h,c}\`: separate private Binder interface. + BEGIN transmits the *app-authorized* URL and a PFD, never a storage name. + FINISH accepts only process exit 0 before calling the publisher. +- \`ib_shizuku_ingress_runner.{h,c}\`: broker side, obtains the write PFD, + launches a separately qualified Grease executable/worker using + \`posix_spawn\` with that FD mapped onto stdout, watches exit/death, + closes PFD, reports actual exit status. The launcher and script must be + pinned by Cat Food; the local ownership checks alone do not attest them. +- \`lib/android_shizuku_icu_provider.grease\`: existing checked ICU + command reused for both ordinary capture and new stdout streaming. + \`bin/ib_android_icu_stdout.grease\` is its narrow binary output entry. +- \`ib_shizuku_arena_publish.{h,c}\`: app-owned persistence callback using + the **actual** \`appendfat_arena\` C API, not an alternate store. The + source root/receipts/locks must already exist with 0700 ownership. + Each request ID gets a unique lock. Existing appendfat owns the .used + commit-pointer and lock protocol. After append it independently + reopens/verifies every byte, then fsyncs a temporary receipt and + atomically links it at \`receipts/REQUEST_ID.tsv\`. Failed writes do not + publish a valid receipt; committed but unreferenced tails remain + inaccessible to reader navigation. + +The v1 receipt retains \`UNOBSERVED\` for HTTP status, resolved URL and MIME +and \`navigation_admitted=NO\` until ICU #35 metadata is integrated. It is +**only** proof of retained source bytes, not evidence that a URL was rendered. + +## Security properties + +- Shell UID 2000 and a Termux/app bridge UID differ. Access is bound to + the *actual* Binder caller UID that the app approved for one navigation; + there is no wildcard permission and no authority derived from page data. +- The source directory is 0700 and belongs to the receiving Android app + process. No world-readable \`/data/local/tmp\` response and no reliance + on cross-UID ordinary-file access. Path names from the producer never + identify source files in the app. +- On nonzero ICU exit, source too large or empty, stale generation, denied + Shizuku, broker death, timeout, duplicate ID, full Arena, write/readback + failure, malformed receipt, or path substitution: do not make a new + current source available. The prior committed receipt remains valid. +- Broker's actual ICU command still requires Cat Food-pinned SHA-256 of + both Android ICU DEX and native transport. It does not silently use + WebView, \`HttpURLConnection\`, curl or a reader-owned network client. +- Binder service is never registered globally; the native Android host + must distribute its object privately and reject unknown/unapproved + clients. Merely compiling a Binder class is not physical authorization. + +## Verification limits + +The fast source tests use two Linux processes and SCM_RIGHTS transfer to +exercise the actual reader thread and one-shot source admission. They +test refused UID, request identity, epoch, unsafe URL, worker exit 29, +empty/oversized/late output, duplicate calls and retained recovery. + +The pinned-storage tests use the real \`sd-card-append-fat\` C implementation +compiled by the checked ICK compiler; append, pointer publication, complete +source receipt, duplicate prevention, corruption and independent PFD reopen +are exercised. The Android CI compiles the native Binder/runner library for +ARM32 and ARM64 and the canonical publisher *adapter object*. Linking +the canonical appendfat code for Android still needs the qualified target +compiler/runtime; the object alone is not a complete Android app. + +**Not yet done:** delivering private Binder handles between real MIRO C67 +UIDs; app-side Android lifecycle creating/arming ingress; Cat Food packaging +and pinned launcher deployment; real Shizuku+rish execution of ICU HTTPS +and its response metadata; full native Android publisher linkage; +Idriç prepaint projection and live reader navigation; physical device +receipts. Do not advertise a URL-capable APK from this PR. + +Tracking: IB #151, #142, #141, #97; PR #112, #129, #147, #152, #153, +and ICU PR #35. diff --git a/examples/autogenerated/google-capture/attempt.md b/examples/autogenerated/google-capture/attempt.md new file mode 100644 index 00000000..663f40e4 --- /dev/null +++ b/examples/autogenerated/google-capture/attempt.md @@ -0,0 +1,48 @@ +# Admit the current ICU metadata format without admitting an unsupported page + +The one-off ChordReader task has explicit authorization to proceed without FP. +An actual unchanged ICU command requested Google's specified search address. +HTTP/1.0 returned 426; its existing HTTP/1.1 mode returned 200 with one redirect, +chunked transfer decoded by ICU, and declared ISO-8859-1 HTML. The original +requested URL, body and full observed metadata remain separate evidence. + +The private ingress previously accepted only ICU's older eight-field capture. +Current ICU adds content_range and transfer_decoding. The maintained native +adapter now validates either complete format, rejects a half extension, +duplicate/unknown fields, non-absent ranges and unsupported transfer decoders, +and preserves the observed ISO-8859-1 charset as an observation. That charset +is not newly supported for decoding or navigation. Existing UTF-8 and +redirect-provenance admission requirements remain in force. + +The Shizuku capture command now selects ICU's existing HTTP/1.1 mode. The +native test extends the established canonical appendfat → source-owner/PFD +path, with synthetic positive/refusal cases and an optional actual host ICU +observation. The latter must retain and reopen the exact body while refusing +reader admission and preserving the previous admitted source. Host pipes are +not Android Binder; neither compilation nor this replay accepts C67. + +Qualification: unchanged eight-field and current ten-field positive captures +passed through canonical Arena and verified source-owner/PFD reopen. Half +extensions, ranges, unsupported transfer decoders, duplicate and unknown fields +were refused without changing the prior admitted source. The actual host ICU +Google capture was committed, reopened byte-for-byte and refused reader +admission. The separately maintained ai-ci oracle also refused that receipt at +its declared charset check and accepted the earlier admitted source. The +existing stdout/nonzero/denial/quoting suite passed with the explicit HTTP/1.1 +worker command. + +The local cached ICK frontend could build the changed ordinary-C adapters but +refused the unchanged canonical library's Unicode division token. No source was +rewritten and no compiler was substituted. The verified canonical object and +tool were reused from exact qualified ICK run 40103215403, archive SHA-256 +ac21c863c8da584f613e23d2b5a03413d26ed6a5669daff7d5c471ee7f760759, +canonical source 05a0cb7246c5a9f46abfbe92925812e7b607f454. The reusable verifier +checks those exact products before linking the changed ICK-built adapter. +The initial local root-permission test setup was corrected to create the +Arena root itself as 0700; the admission checks were unchanged. + +No decoder, browser UI, package identity, signer or application manifest +changes are selected by this repair. The separate legacy file-capture +eight-field adapter remains outside this private-ingress repair and must not +be substituted for the qualified path. Android Binder and physical C67 remain +NOT_RUN; full search remains incomplete. diff --git a/examples/autogenerated/google-capture/verify.grease b/examples/autogenerated/google-capture/verify.grease new file mode 100644 index 00000000..1cee1d22 --- /dev/null +++ b/examples/autogenerated/google-capture/verify.grease @@ -0,0 +1,69 @@ +#!/usr/bin/env grease +if (len(ARGV) !== 7 and len(ARGV) !== 8) { + echo 'usage: grease verify.grease IB APPENDFAT ICK ICK_TOOLS HOST_SUPPORT LIVE_CAPTURE NEW_OUTPUT [QUALIFIED_CANONICAL_DIRECTORY]' >&2 + exit 2 +} +var repository = ARGV[0] +var appendfat = ARGV[1] +var compiler = ARGV[2] +var tools = ARGV[3] +var host_support = ARGV[4] +var observation = ARGV[5] +var output = ARGV[6] +for path in "$repository" "$appendfat" "$compiler" "$tools" "$host_support" "$observation" "$output" { + if (path[0] !== '/') { echo 'FAIL absolute path required' >&2; exit 2 } +} +test "$(git -C "$appendfat" rev-parse HEAD)" = 05a0cb7246c5a9f46abfbe92925812e7b607f454 +git -C "$appendfat" diff --quiet HEAD -- lib tools +if test -e "$output" { echo 'FAIL output already exists' >&2; exit 2 } +mkdir -p "$output" +printf 'toolchain\tick\ncanonical_store\t05a0cb7246c5a9f46abfbe92925812e7b607f454\nAndroid_Binder\tNOT_RUN\n' > "$output/build.tsv" +git -C "$repository" diff --binary HEAD > "$output/implementation.patch" +sha256sum "$compiler" "$tools/cc1" "$host_support/include/stddef.h" \ + "$repository/native/android/ib_shizuku_arena_publish.c" \ + "$repository/tests/test_shizuku_frame_canonical.c" > "$output/input.sha256" +if (len(ARGV) === 8) { + var canonical = ARGV[7] + if (canonical[0] !== '/') { echo 'FAIL canonical absolute path required' >&2; exit 2 } + # Unchanged ICK products from IB's qualified pinned canonical source run. + # These are explicit prebuilt inputs, never a substitute Arena implementation. + printf '%s %s\n' a573f5093f512630159b54af0ea029a8dbf7c9baccfac81ac4ca6c0b2a9f71f4 "$canonical/appendfat_arena" \ + da6f5ac730526505dc838937d7c057858d76b0e8243c256e0195a79bb7611c0f "$canonical/appendfat_arena.o" | sha256sum -c - + cp "$canonical/appendfat_arena" "$canonical/appendfat_arena.o" "$output/" + printf 'canonical_build\tREUSED_EXACT_QUALIFIED_ICK_PRODUCTS\n' >> "$output/build.tsv" +} else { + "$compiler" -B "$tools/" -B "$host_support/" -isystem "$host_support/include" \ + -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + "$appendfat/lib/appendfat_arena.c" "$appendfat/tools/appendfat_arena.c" \ + -o "$output/appendfat_arena" > "$output/arena-build.txt" 2>&1 + "$compiler" -B "$tools/" -B "$host_support/" -isystem "$host_support/include" \ + -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror \ + -fPIE -c "$appendfat/lib/appendfat_arena.c" -o "$output/appendfat_arena.o" \ + > "$output/arena-object-build.txt" 2>&1 +} +"$compiler" -B "$tools/" -B "$host_support/" -isystem "$host_support/include" \ + -fno-link-libatomic -std=c11 -O2 -Wall -Wextra -Wpedantic -Werror -pthread \ + -I "$appendfat/lib" "$output/appendfat_arena.o" \ + "$repository/native/android/ib_reader_pfd.c" "$repository/native/android/ib_arena_source_owner.c" \ + "$repository/native/android/ib_shizuku_arena_ingress.c" "$repository/native/android/ib_shizuku_arena_publish.c" \ + "$repository/tests/test_shizuku_frame_canonical.c" \ + -o "$output/test-frame" > "$output/frame-build.txt" 2>&1 +for fixture in synthetic actual-google { + var root = "$output/$fixture" + mkdir -m 700 "$root" + mkdir -m 700 "$root/receipts" "$root/locks" "$root/staging" + "$output/appendfat_arena" create "$root/responses.arena" 16777216 > "$output/$fixture-create.txt" + if (fixture === 'synthetic') { + "$output/test-frame" "$root" > "$output/$fixture.txt" 2>&1 + } else { + var url = $(cat "$observation/requested-url.txt") + "$output/test-frame" "$root" "$url" "$observation/capture/metadata" "$observation/capture/body" \ + > "$output/$fixture.txt" 2>&1 + rg -x -F "$(printf 'charset\tiso-8859-1')" "$root/receipts/live-google.tsv" + rg -x -F "$(printf 'navigation_admitted\tNO')" "$root/receipts/live-google.tsv" + } + cat "$output/$fixture.txt" +} +sha256sum "$output/test-frame" "$output/appendfat_arena" \ + "$observation/capture/body" "$observation/capture/metadata" > "$output/artifacts.sha256" +printf '\033[32mPASS\033[0m strict current metadata, canonical source retention and live Google admission refusal\n' diff --git a/examples/autogenerated/paired-icu-ingress/attempt.md b/examples/autogenerated/paired-icu-ingress/attempt.md new file mode 100644 index 00000000..c74578e5 --- /dev/null +++ b/examples/autogenerated/paired-icu-ingress/attempt.md @@ -0,0 +1,69 @@ +# Paired ICU capture into the existing private Arena publisher + +The requested result is an installable checked Idriç native reader for real +pages. This change addresses one necessary native ingress boundary; it does +not establish that product result. + +## Values, types and invariants + +The browser owns request identity and positive navigation generation. An +explicitly authorized bridge receives only a private write descriptor and the +browser-approved URL. ICU produces an observed response metadata record and +exact response bytes. The platform adapter receives their bounded `IBICU01` +frame, validates it, and uses the existing canonical appendfat API to commit +only the body bytes. Reopening must reproduce the exact body and SHA-256. + +Conceptual signatures: + +- receive capture: authorized request × finite byte stream → response or error +- validate response: observed metadata × exact bytes → verified capture or error +- commit response: current generation × verified capture → immutable Arena receipt +- reopen source: request × current generation → checked read-only source descriptor + +The source generation passed to the publisher comes from the armed session, +not from worker-controlled metadata. A nonzero worker exit, cancelled/stale +generation, incomplete frame, malformed/duplicated/missing metadata, byte-count +mismatch, false UTF-8 claim, or wrong digest must not append or replace content. +The legacy body-only publisher still writes v1 with navigation admission NO. +No body-only path can call the v2 publisher successfully. + +The existing narrow C/Binder/storage adapter remains the native implementation +boundary. No parallel browser state, parser, renderer, network client, store or +application-specific DEX is introduced. Idriç continues to own browser semantics; +the checked source-to-DEX consumer is an independent unfinished dependency. + +## Implementation and effects + +The existing paired ICU command is shared between the two-file adapter and a +new frame-only Grease worker. It has no app-private Arena pathname. The native +publisher validates the eight existing ICU metadata fields and full frame +length, independently checks UTF-8 and hashes, commits through the existing +appendfat library, reopens bytes, then syncs an immutable Arena v2 receipt. + +Observed redirects are retained but cannot admit navigation because this ICU +capture supplies only a redirect count, not the required redirect chain. +An unobserved charset is likewise not silently treated as UTF-8. + +## Verification boundary + +Grease runtime: compiled Oils source 5651cf97a1b5042f24f14112a7ade9a1518eb0bc, +binary SHA-256 7e31cd05b7a9d8fb2a4a9e003a7f3fcb0159138506d17f0fb28da8cbe22aa85c. +The existing stdout/producer-failure/authorization/quoting controls execute with +the actual Grease entrypoint in its inherited-shell compatibility mode. + +The canonical native test uses the pinned actual appendfat library and checks +the joined private-pipe → framed capture → Arena v2 → verified reader PFD path. +Its metadata/body source is synthetic. Hosted ICK compilation/execution and +ARM32/ARM64 adapter cross-compilation are required and initially PENDING. +Real Android Binder, Shizuku authorization, ICU HTTPS, checked Idriç rendering, +APK packaging/publication, and physical C67 acceptance remain NOT_RUN here. + +## Remaining language and product work + +The reusable DEX backend must execute canonical reader list/record/closure +operations; the known consumer failure is the changing list accumulator in +IB.Information.tokenize_chars. A host compiler or ingress test is not evidence +that this program runs under ART. Complete that backend, wire app-owned Binder +capability lifecycle and durable navigation to the existing checked UI, preserve +the installed material3 signer/package, and exercise real fetched pages before +publishing a reader APK. diff --git a/lib/android_binder_icu.grease b/lib/android_binder_icu.grease index bb1f819a..ac969e29 100644 --- a/lib/android_binder_icu.grease +++ b/lib/android_binder_icu.grease @@ -3,3 +3,13 @@ ib_binder_icu_get() { ib_shizuku_icu_get "$@" } + +ib_binder_icu_frame() { + ib_shizuku_icu_frame "$@" +} + +# Same source-of-truth pinned ICU request, but stdout is a binary transport +# stream to an authorized caller-held write PFD rather than an Arena pathname. +ib_binder_icu_stream() { + ib_shizuku_icu_stream "$@" +} diff --git a/lib/android_shizuku_icu_provider.grease b/lib/android_shizuku_icu_provider.grease index e3292c84..e3bcc4bd 100644 --- a/lib/android_shizuku_icu_provider.grease +++ b/lib/android_shizuku_icu_provider.grease @@ -50,6 +50,24 @@ ib_shizuku_icu_get() { 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; } + ib_shizuku_icu_prepare_capture "$icu_url" || return $? + + # 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 + ib_shizuku_icu_split_capture +} + +# Shared paired-capture command. Private Binder ingress forwards this exact +# frame; the existing file adapter splits the same frame into two outputs. +ib_shizuku_icu_prepare_capture() { + test "$#" -eq 1 || return 2 + ib_shizuku_icu_prepare "$1" || return $? # Validate all interpolated atoms before assembling a shell command. The # response URL is the only untrusted interpolation and cannot contain '. command_text="set -eu @@ -62,7 +80,7 @@ ib_shizuku_icu_get() { 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 + app_process -Dicu.library='$icu_so' /system/bin Idric.Generated --http1.1 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:]') @@ -73,14 +91,9 @@ ib_shizuku_icu_get() { 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 +} + +ib_shizuku_icu_split_capture() { packet_header=$(head -c 26 "$local_packet") || { rm -rf "$local_packet_directory" return 1 @@ -127,3 +140,40 @@ ib_shizuku_icu_get() { } rm -rf "$local_packet_directory" } + +ib_shizuku_icu_frame() { + test "$#" -eq 1 || return 2 + ib_shizuku_icu_prepare_capture "$1" || return $? + authority=$(ib_shizuku_authority) || return 1 + case "$authority" in shell|root) ;; *) return 1 ;; esac + (umask 077; ib_shizuku_run_bounded 30 "$command_text") +} + +# Body-only ingress is a separate capability from the paired metadata capture +# above. Its publisher emits v1/NO, which the admitted HTML reader must refuse. +ib_shizuku_icu_prepare() { + test "$#" -eq 1 || return 2 + icu_url=$1 + 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" || return 2 + ib_shizuku_icu_atom_path "$icu_dex" || return 2 + ib_shizuku_icu_atom_path "$icu_so" || return 2 + ib_shizuku_icu_sha256 "$icu_dex_sha" || return 2 + ib_shizuku_icu_sha256 "$icu_so_sha" || return 2 + command_text="set -eu + 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 + export CLASSPATH='$icu_dex' + exec app_process -Dicu.library='$icu_so' /system/bin Idric.Generated --http1.1 get '$icu_url'" +} + +ib_shizuku_icu_stream() { + test "$#" -eq 1 || return 2 + ib_shizuku_icu_prepare "$1" || return $? + authority=$(ib_shizuku_authority) || return 1 + case "$authority" in shell|root) ;; *) return 1 ;; esac + (umask 077; ib_shizuku_run_bounded 30 "$command_text") +} diff --git a/native/android/ib_shizuku_arena_ingress.c b/native/android/ib_shizuku_arena_ingress.c new file mode 100644 index 00000000..d1de7fb8 --- /dev/null +++ b/native/android/ib_shizuku_arena_ingress.c @@ -0,0 +1,273 @@ +#define _GNU_SOURCE +#define _POSIX_C_SOURCE 200809L +#include "ib_shizuku_arena_ingress.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define INGEST_POLL_SLICE_MS 250 +#define INGEST_BUFFER_CAP (IB_INGRESS_STREAM_MAX_BYTES + 1U) + +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 int valid_request_id(const char *request_id) { + if (!request_id) return 0; + size_t size = strnlen(request_id, IB_READER_REQUEST_ID_CAP + 1U); + if (size == 0 || size > IB_READER_REQUEST_ID_CAP || + (size == 1 && request_id[0] == '.') || + (size == 2 && request_id[0] == '.' && request_id[1] == '.')) + return 0; + for (size_t i = 0; i < size; ++i) { + unsigned char c = (unsigned char)request_id[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-')) return 0; + } + return 1; +} + +static int valid_requested_url(const char *url) { + if (!url) return 0; + size_t size = strnlen(url, IB_INGRESS_URL_CAP + 1U); + if (size <= 8 || size > IB_INGRESS_URL_CAP || + (strncmp(url, "http://", 7) != 0 && + strncmp(url, "https://", 8) != 0)) + return 0; + /* Match the pinned Shizuku ICU shell quoting contract: no apostrophes, + * whitespace, controls, non-ASCII or shell metacharacter expansion via + * the command adapter. HTTP URL syntax is still owned by ICU. */ + for (size_t i = 0; i < size; ++i) { + unsigned char c = (unsigned char)url[i]; + if (c < 33 || c > 126 || c == '\'') return 0; + } + size_t authority = strncmp(url, "https://", 8) == 0 ? 8U : 7U; + if (url[authority] == '\0' || url[authority] == '/' || + url[authority] == '?' || url[authority] == '#') return 0; + /* No credentials forwarded to redirects or included in receipts. */ + for (size_t i = authority; i < size && url[i] != '/' && + url[i] != '?' && url[i] != '#'; ++i) + if (url[i] == '@') return 0; + return 1; +} + +static int allowed_broker_uid(uid_t uid) { + /* Only an app-approved explicit UID, never a wildcard. Shizuku's shell + * UID is 2000, but a Termux broker may use a different app UID. */ + return uid == (uid_t)2000 || uid >= (uid_t)10000; +} + +static int matching_session(const ib_shizuku_arena_ingress *session, + uid_t caller, const char *id, uint64_t generation) { + return session && caller == session->permitted_bridge_uid && + valid_request_id(id) && + strcmp(id, session->request_id) == 0 && + generation != 0 && generation == session->request_generation && + generation == + atomic_load(&session->wanted_navigation_generation); +} + +static void *consume_pipe(void *opaque) { + ib_shizuku_arena_ingress *session = opaque; + unsigned char *bytes = malloc(INGEST_BUFFER_CAP); + size_t count = 0; + int complete = 0; + int fd = session->source_fd; + int64_t start = monotonic_milliseconds(); + int64_t deadline = start < 0 ? 0 : start + IB_INGRESS_TIMEOUT_MILLISECONDS; + if (!bytes || start < 0) goto done; + + for (;;) { + if (atomic_load(&session->cancelled) || + session->request_generation != + atomic_load(&session->wanted_navigation_generation)) + goto done; + int64_t now = monotonic_milliseconds(); + if (now < 0 || now >= deadline) goto done; + int timeout = (int)(deadline - now); + if (timeout > INGEST_POLL_SLICE_MS) timeout = INGEST_POLL_SLICE_MS; + struct pollfd descriptor = {fd, POLLIN | POLLHUP, 0}; + int observed = poll(&descriptor, 1, timeout); + if (observed < 0 && errno == EINTR) continue; + if (observed < 0 || (descriptor.revents & (POLLERR | POLLNVAL))) + goto done; + if (observed == 0) continue; + size_t available = INGEST_BUFFER_CAP - count; + if (available == 0) goto done; + if (available > 65536U) available = 65536U; + ssize_t amount = read(fd, bytes + count, available); + if (amount < 0 && (errno == EINTR || errno == EAGAIN)) continue; + if (amount < 0) goto done; + if (amount == 0) { + if (count == 0 || count > IB_INGRESS_STREAM_MAX_BYTES) goto done; + complete = 1; + break; + } + count += (size_t)amount; + if (count > IB_INGRESS_STREAM_MAX_BYTES) goto done; + } + +done: + close(fd); + session->source_fd = -1; + if (!complete) { + free(bytes); + bytes = NULL; + count = 0; + } + session->bytes = bytes; + session->byte_count = count; + session->stream_complete = complete; + return NULL; +} + +int ib_ingress_init(ib_shizuku_arena_ingress *session, + ib_ingress_publish publish, void *source_owner) { + if (!session || !publish || !source_owner) return -1; + memset(session, 0, sizeof(*session)); + if (pthread_mutex_init(&session->mutex, NULL) != 0) return -1; + atomic_init(&session->wanted_navigation_generation, 0); + atomic_init(&session->cancelled, false); + session->source_fd = -1; + session->publish = publish; + session->source_owner = source_owner; + session->state = IB_INGRESS_IDLE; + return 0; +} + +int ib_ingress_arm(ib_shizuku_arena_ingress *session, + const char *request_id, uint64_t navigation_generation, + const char *requested_url, uid_t explicitly_allowed_uid) { + if (!session || !valid_request_id(request_id) || + !valid_requested_url(requested_url) || + !allowed_broker_uid(explicitly_allowed_uid) || + navigation_generation == 0) return -1; + pthread_mutex_lock(&session->mutex); + if (session->state != IB_INGRESS_IDLE) { + pthread_mutex_unlock(&session->mutex); + return -1; + } + memcpy(session->request_id, request_id, strlen(request_id) + 1U); + memcpy(session->requested_url, requested_url, strlen(requested_url) + 1U); + session->request_generation = navigation_generation; + session->permitted_bridge_uid = explicitly_allowed_uid; + atomic_store(&session->wanted_navigation_generation, navigation_generation); + session->state = IB_INGRESS_ARMED; + pthread_mutex_unlock(&session->mutex); + return 0; +} + +void ib_ingress_want(ib_shizuku_arena_ingress *session, + uint64_t navigation_generation) { + if (session) + atomic_store(&session->wanted_navigation_generation, + navigation_generation); +} + +int ib_ingress_begin(ib_shizuku_arena_ingress *session, + uid_t calling_uid, const char *request_id, + uint64_t generation, int *owned_write_fd) { + if (owned_write_fd) *owned_write_fd = -1; + if (!session || !owned_write_fd) return -1; + pthread_mutex_lock(&session->mutex); + if (session->state != IB_INGRESS_ARMED || + !matching_session(session, calling_uid, request_id, generation)) { + pthread_mutex_unlock(&session->mutex); + return -1; + } + int pair[2] = {-1, -1}; + if (pipe2(pair, O_CLOEXEC | O_NONBLOCK) != 0) { + pthread_mutex_unlock(&session->mutex); + return -1; + } + int flags = fcntl(pair[1], F_GETFL); + if (flags < 0 || fcntl(pair[1], F_SETFL, flags & ~O_NONBLOCK) != 0) { + close(pair[0]); + close(pair[1]); + pthread_mutex_unlock(&session->mutex); + return -1; + } + session->source_fd = pair[0]; + if (pthread_create(&session->read_thread, NULL, consume_pipe, session) != 0) { + session->source_fd = -1; + close(pair[0]); + close(pair[1]); + pthread_mutex_unlock(&session->mutex); + return -1; + } + session->thread_started = 1; + session->state = IB_INGRESS_STREAMING; + *owned_write_fd = pair[1]; + pthread_mutex_unlock(&session->mutex); + return 0; +} + +int ib_ingress_finish(ib_shizuku_arena_ingress *session, + uid_t calling_uid, const char *request_id, + uint64_t generation, int producer_exit_status) { + if (!session) return -1; + pthread_mutex_lock(&session->mutex); + if (session->state != IB_INGRESS_STREAMING || + !matching_session(session, calling_uid, request_id, generation)) { + pthread_mutex_unlock(&session->mutex); + return -1; + } + session->state = IB_INGRESS_FINISHING; + if (producer_exit_status != 0) + atomic_store(&session->cancelled, true); + pthread_mutex_unlock(&session->mutex); + + if (session->thread_started) { + (void)pthread_join(session->read_thread, NULL); + session->thread_started = 0; + } + int result = -1; + if (producer_exit_status == 0 && + session->stream_complete && session->bytes && + session->byte_count != 0 && + session->byte_count <= IB_INGRESS_STREAM_MAX_BYTES && + !atomic_load(&session->cancelled) && + generation == atomic_load(&session->wanted_navigation_generation)) { + char digest[65]; + ib_reader_sha256_hex(session->bytes, session->byte_count, digest); + /* The source owner independently read-back verifies the exact + * canonical append before any receipt becomes visible. */ + result = session->publish(session->source_owner, session->request_id, + session->request_generation, + session->requested_url, session->bytes, + session->byte_count, digest); + } + free(session->bytes); + session->bytes = NULL; + session->byte_count = 0; + session->stream_complete = 0; + pthread_mutex_lock(&session->mutex); + session->state = IB_INGRESS_CLOSED; + pthread_mutex_unlock(&session->mutex); + return result; +} + +void ib_ingress_dispose(ib_shizuku_arena_ingress *session) { + if (!session) return; + atomic_store(&session->cancelled, true); + if (session->thread_started) { + (void)pthread_join(session->read_thread, NULL); + session->thread_started = 0; + } + free(session->bytes); + session->bytes = NULL; + session->byte_count = 0; + session->source_fd = -1; + session->state = IB_INGRESS_CLOSED; + pthread_mutex_destroy(&session->mutex); +} diff --git a/native/android/ib_shizuku_arena_ingress.h b/native/android/ib_shizuku_arena_ingress.h new file mode 100644 index 00000000..b779a225 --- /dev/null +++ b/native/android/ib_shizuku_arena_ingress.h @@ -0,0 +1,90 @@ +#ifndef IB_SHIZUKU_ARENA_INGRESS_H +#define IB_SHIZUKU_ARENA_INGRESS_H + +#include +#include +#include +#include +#include + +#include "ib_reader_pfd.h" + +#define IB_INGRESS_URL_CAP 2048U +#define IB_INGRESS_TIMEOUT_MILLISECONDS 35000 +/* The paired ICU frame carries a 26-byte header and bounded metadata in + * addition to the body. The publisher validates the body limit separately. */ +#define IB_INGRESS_FRAME_HEADER_BYTES 26U +#define IB_INGRESS_METADATA_MAX_BYTES 8192U +#define IB_INGRESS_STREAM_MAX_BYTES \ + (IB_READER_SOURCE_MAX_BYTES + IB_INGRESS_METADATA_MAX_BYTES + \ + IB_INGRESS_FRAME_HEADER_BYTES) + +/* Storage owner is the only component permitted to append the source. + * Exactly one call follows a fully received stream, a confirmed worker exit + * status of zero, and a matching current browser navigation generation. + * Publisher checks duplicate request IDs and independently read-back verifies + * the canonical userspace appendfat Arena before publishing a receipt. */ +typedef int (*ib_ingress_publish)(void *source_owner, + const char *request_id, + uint64_t navigation_generation, + const char *requested_url, + const unsigned char *exact_bytes, + size_t byte_count, + const char sha256_hex[65]); + +typedef enum { + IB_INGRESS_IDLE, + IB_INGRESS_ARMED, + IB_INGRESS_STREAMING, + IB_INGRESS_FINISHING, + IB_INGRESS_CLOSED +} ib_ingress_state; + +typedef struct { + pthread_mutex_t mutex; + atomic_uint_fast64_t wanted_navigation_generation; + atomic_bool cancelled; + uid_t permitted_bridge_uid; + uint64_t request_generation; + char request_id[IB_READER_REQUEST_ID_CAP + 1U]; + char requested_url[IB_INGRESS_URL_CAP + 1U]; + ib_ingress_state state; + int source_fd; + pthread_t read_thread; + int thread_started; + unsigned char *bytes; + size_t byte_count; + int stream_complete; + ib_ingress_publish publish; + void *source_owner; +} ib_shizuku_arena_ingress; + +/* The app/browser owner calls arm with the current accepted navigation and + * a verified, explicitly authorized bridge process UID. No token or URL from + * page content is accepted as Binder authority. One request per instance. */ +int ib_ingress_init(ib_shizuku_arena_ingress *session, + ib_ingress_publish publish, void *source_owner); +int ib_ingress_arm(ib_shizuku_arena_ingress *session, + const char *request_id, uint64_t navigation_generation, + const char *requested_url, uid_t explicitly_allowed_uid); +void ib_ingress_want(ib_shizuku_arena_ingress *session, + uint64_t navigation_generation); + +/* Returns an owned blocking WRITE pipe descriptor suitable for Binder PFD. + * The app retains only the READ end and consumes in a bounded worker thread. + * No app-private Arena pathname or FD is passed to Shizuku. */ +int ib_ingress_begin(ib_shizuku_arena_ingress *session, + uid_t calling_uid, + const char *request_id, uint64_t generation, + int *owned_write_fd); + +/* The authorized bridge calls finish only after ICU exits and the transferred + * write PFD is closed; nonzero/timeout producer exit is a mandatory refusal. + * No source becomes visible until verified canonical commit and receipt. */ +int ib_ingress_finish(ib_shizuku_arena_ingress *session, + uid_t calling_uid, + const char *request_id, uint64_t generation, + int producer_exit_status); +void ib_ingress_dispose(ib_shizuku_arena_ingress *session); + +#endif diff --git a/native/android/ib_shizuku_arena_publish.c b/native/android/ib_shizuku_arena_publish.c new file mode 100644 index 00000000..eb47b6b6 --- /dev/null +++ b/native/android/ib_shizuku_arena_publish.c @@ -0,0 +1,462 @@ +#define _GNU_SOURCE +#define _FILE_OFFSET_BITS 64 +#include "ib_shizuku_arena_publish.h" +#include "ib_shizuku_arena_ingress.h" +#include "ib_reader_pfd.h" +#include "appendfat_arena.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define RECEIPT_MAX_BYTES 8192U +#define READBACK_CHUNK 65536U + +typedef struct { + unsigned status; + char resolved_url[IB_INGRESS_URL_CAP + 1U]; + char mime_type[256]; + char charset[64]; + char content_encoding[256]; + int utf8_valid; + unsigned redirects; + int64_t declared_length; + uint64_t received_length; + int admitted; +} observed_response; + +static int decimal(const char *text, uint64_t *value) { + if (!text || !*text) return -1; + uint64_t result = 0; + for (const unsigned char *p = (const unsigned char *)text; *p; ++p) { + if (*p < '0' || *p > '9' || + result > ((uint64_t)INT64_MAX - (*p - '0')) / 10U) + return -1; + result = result * 10U + (*p - '0'); + } + *value = result; + return 0; +} + +static int copied(char *out, size_t capacity, const char *input) { + size_t n = strlen(input); + if (n == 0 || n >= capacity) return -1; + memcpy(out, input, n + 1U); + return 0; +} + +/* Check code points independently of the worker's UTF-8 observation. */ +static int valid_utf8(const unsigned char *bytes, size_t length) { + size_t i = 0; + while (i < length) { + unsigned first = bytes[i++]; + if (first < 0x80U) continue; + unsigned remaining, point, minimum; + if (first >= 0xc2U && first <= 0xdfU) { + remaining = 1; point = first & 0x1fU; minimum = 0x80U; + } else if (first >= 0xe0U && first <= 0xefU) { + remaining = 2; point = first & 0x0fU; minimum = 0x800U; + } else if (first >= 0xf0U && first <= 0xf4U) { + remaining = 3; point = first & 0x07U; minimum = 0x10000U; + } else return 0; + if (remaining > length - i) return 0; + while (remaining--) { + unsigned next = bytes[i++]; + if ((next & 0xc0U) != 0x80U) return 0; + point = (point << 6U) | (next & 0x3fU); + } + if (point < minimum || point > 0x10ffffU || + (point >= 0xd800U && point <= 0xdfffU)) return 0; + } + return 1; +} + +static int media_type(observed_response *response, const char *value) { + char normalized[512]; + size_t used = 0; + for (const unsigned char *p = (const unsigned char *)value; *p; ++p) { + if (used + 1U >= sizeof(normalized)) return -1; + normalized[used++] = (char)(*p >= 'A' && *p <= 'Z' ? *p + ('a' - 'A') : *p); + } + normalized[used] = '\0'; + char *media = normalized; + while (*media == ' ') ++media; + char *parameter = strchr(media, ';'); + if (parameter) *parameter++ = '\0'; + size_t media_length = strlen(media); + while (media_length && media[media_length-1U] == ' ') media[--media_length] = '\0'; + if (!strchr(media, '/') && strcmp(media, "unknown") != 0) + return -1; + for (const char *p = media; *p; ++p) + if (!( (*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + strchr(".+/_-", *p))) return -1; + if (copied(response->mime_type, sizeof(response->mime_type), media) != 0) + return -1; + memcpy(response->charset, "UNOBSERVED", sizeof("UNOBSERVED")); + if (parameter) { + while (*parameter == ' ') ++parameter; + size_t parameter_length = strlen(parameter); + while (parameter_length && parameter[parameter_length-1U] == ' ') + parameter[--parameter_length] = '\0'; + } + if (parameter && (strcmp(parameter, "charset=utf-8") == 0 || + strcmp(parameter, "charset=\"utf-8\"") == 0)) + memcpy(response->charset, "utf-8", sizeof("utf-8")); + else if (parameter && (strcmp(parameter, "charset=iso-8859-1") == 0 || + strcmp(parameter, "charset=\"iso-8859-1\"") == 0)) + memcpy(response->charset, "iso-8859-1", sizeof("iso-8859-1")); + return 0; +} + +static int frame_response(const unsigned char *frame, size_t length, + const char *requested_url, observed_response *response, + const unsigned char **body, size_t *body_bytes) { + static const char *const names[] = { + "http_status", "content_type", "content_encoding", "declared_length", + "received_length", "utf8", "resolved_url", "redirect_count", + "content_range", "transfer_decoding" + }; + if (length < IB_INGRESS_FRAME_HEADER_BYTES || + memcmp(frame, "IBICU01 ", 8U) != 0 || frame[16] != ' ' || + frame[25] != '\n') return -1; + size_t metadata_length = 0, source_length = 0; + for (size_t i = 0; i < 8U; ++i) { + unsigned char m = frame[8U + i], b = frame[17U + i]; + if (m < '0' || m > '9' || b < '0' || b > '9') return -1; + metadata_length = metadata_length * 10U + (m - '0'); + source_length = source_length * 10U + (b - '0'); + } + if (!metadata_length || metadata_length > IB_INGRESS_METADATA_MAX_BYTES || + !source_length || source_length > IB_READER_SOURCE_MAX_BYTES || + length != IB_INGRESS_FRAME_HEADER_BYTES + metadata_length + source_length) + return -1; + char metadata[IB_INGRESS_METADATA_MAX_BYTES + 1U]; + memcpy(metadata, frame + IB_INGRESS_FRAME_HEADER_BYTES, metadata_length); + metadata[metadata_length] = '\0'; + char *fields[10] = {0}; + char *position = metadata, *end = metadata + metadata_length; + while (position < end) { + char *newline = memchr(position, '\n', (size_t)(end - position)); + if (!newline) return -1; + char *tab = memchr(position, '\t', (size_t)(newline - position)); + if (!tab || tab == position || tab + 1 == newline) return -1; + for (char *p = position; p < newline; ++p) + if (p != tab && ((unsigned char)*p < 32U || (unsigned char)*p > 126U)) + return -1; + *tab = '\0'; *newline = '\0'; + size_t key = 0; + while (key < 10U && strcmp(position, names[key]) != 0) ++key; + if (key == 10U || fields[key]) return -1; + fields[key] = tab + 1; + position = newline + 1; + } + for (size_t i = 0; i < 8U; ++i) if (!fields[i]) return -1; + /* Accept the original eight-field capture or ICU's complete ten-field + * full-response capture. Extensions arrive as a pair, not arbitrary + * ignored metadata. Range responses cannot enter this whole-page path. */ + if ((fields[8] == NULL) != (fields[9] == NULL)) return -1; + if (fields[8] && + (strcmp(fields[8], "absent") != 0 || + (strcmp(fields[9], "identity") != 0 && + strcmp(fields[9], "chunked") != 0))) return -1; + uint64_t status, received, redirects, declared = 0; + if (decimal(fields[0], &status) != 0 || status < 200U || status > 299U || + decimal(fields[4], &received) != 0 || received != source_length || + decimal(fields[7], &redirects) != 0 || redirects > 8U || + (strcmp(fields[3], "-1") != 0 && + (decimal(fields[3], &declared) != 0 || declared != source_length))) + return -1; + if (copied(response->resolved_url, sizeof(response->resolved_url), fields[6]) != 0 || + copied(response->content_encoding, sizeof(response->content_encoding), fields[2]) != 0 || + media_type(response, fields[1]) != 0) + return -1; + if (strcmp(fields[5], "valid") != 0 && strcmp(fields[5], "invalid") != 0) + return -1; + *body = frame + IB_INGRESS_FRAME_HEADER_BYTES + metadata_length; + *body_bytes = source_length; + response->utf8_valid = valid_utf8(*body, source_length); + if (response->utf8_valid != (strcmp(fields[5], "valid") == 0)) return -1; + response->status = (unsigned)status; + response->received_length = received; + response->declared_length = strcmp(fields[3], "-1") == 0 ? -1 : (int64_t)declared; + response->redirects = (unsigned)redirects; + /* The current ICU capture reports only a redirect count, not the chain. + * Retain those bytes, but do not admit redirected content on that evidence. */ + response->admitted = status == 200U && response->utf8_valid && + strcmp(response->mime_type, "text/html") == 0 && + strcmp(response->charset, "utf-8") == 0 && + strcmp(response->content_encoding, "identity") == 0 && + redirects == 0 && strcmp(requested_url, response->resolved_url) == 0; + return 0; +} + +static int valid_id(const char *id) { + if (!id) return 0; + size_t n = strnlen(id, IB_READER_REQUEST_ID_CAP + 1U); + if (!n || n > IB_READER_REQUEST_ID_CAP || + strcmp(id, ".") == 0 || strcmp(id, "..") == 0) return 0; + for (size_t i = 0; i < n; ++i) { + unsigned char c = (unsigned char)id[i]; + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + c == '.' || c == '-' || c == '_')) return 0; + } + return 1; +} + +static int valid_url(const char *url) { + if (!url) return 0; + size_t n = strnlen(url, IB_INGRESS_URL_CAP + 1U); + if (n < 9 || n > IB_INGRESS_URL_CAP || + (strncmp(url, "https://", 8) != 0 && + strncmp(url, "http://", 7) != 0)) return 0; + size_t authority = strncmp(url, "https://", 8) == 0 ? 8U : 7U; + if (!url[authority] || url[authority] == '/' || + url[authority] == '?' || url[authority] == '#') return 0; + for (size_t i = 0; i < n; ++i) { + unsigned char c = (unsigned char)url[i]; + if (c < 33U || c > 126U || c == '\'') return 0; + if (i >= authority && c == '@') { + size_t j = i; + while (j > authority && url[j-1] != '/' && + url[j-1] != '?' && url[j-1] != '#') --j; + if (j == authority) return 0; + } + } + return 1; +} + +static int owned_directory(int fd) { + struct stat info; + return fd >= 0 && fstat(fd, &info) == 0 && + S_ISDIR(info.st_mode) && info.st_uid == geteuid() && + (info.st_mode & 077U) == 0; +} + +static int owned_regular(int parent, const char *name) { + int fd = openat(parent, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return -1; + struct stat info; + int result = fstat(fd, &info) == 0 && S_ISREG(info.st_mode) && + info.st_uid == geteuid() && info.st_nlink == 1; + close(fd); + return result ? 0 : -1; +} + +static int write_all(int fd, const char *text, size_t count) { + size_t written = 0; + while (written < count) { + ssize_t got = write(fd, text + written, count - written); + if (got < 0 && errno == EINTR) continue; + if (got <= 0) return -1; + written += (size_t)got; + } + return 0; +} + +static int receipt_publish(int receipts_dir, const char *id, + const char *url, uint64_t offset, + size_t length, const char digest[65], + uint64_t generation, const observed_response *response) { + char receipt_name[IB_READER_REQUEST_ID_CAP + 5U]; + char temporary_name[IB_READER_REQUEST_ID_CAP + 48U]; + char content[RECEIPT_MAX_BYTES]; + int n = snprintf(receipt_name, sizeof(receipt_name), "%s.tsv", id); + int t = snprintf(temporary_name, sizeof(temporary_name), + ".%s.%ld.pending", id, (long)getpid()); + if (n <= 0 || (size_t)n >= sizeof(receipt_name) || + t <= 0 || (size_t)t >= sizeof(temporary_name)) return -1; + if (response) { + n = snprintf(content, sizeof(content), + "schema\tib-icu-arena-v2\nrequest_id\t%s\n" + "source_key\tarena:%s\nsource_generation\t%" PRIu64 "\n" + "requested_url\t%s\nresolved_url\t%s\nhttp_status\t%u\n" + "mime_type\t%s\ncharset\t%s\ncontent_encoding\t%s\n" + "utf8\t%s\nredirect_count\t%u\ndeclared_length\t%" PRId64 "\n" + "received_length\t%" PRIu64 "\n" + "transport\ticu-app-process-via-binder-shizuku\n" + "arena_name\tresponses.arena\narena_offset\t%" PRIu64 "\n" + "arena_end\t%" PRIu64 "\nbyte_count\t%zu\nsha256\t%s\n" + "reopen_check\tMATCH\nacquisition_complete\tYES\n" + "navigation_admitted\t%s\n", + id, id, generation, url, response->resolved_url, response->status, + response->mime_type, response->charset, response->content_encoding, + response->utf8_valid ? "valid" : "invalid", response->redirects, + response->declared_length, response->received_length, + offset, offset + (uint64_t)length, length, digest, + response->admitted ? "YES" : "NO"); + } else n = snprintf(content, sizeof(content), + "schema\tib-icu-arena-v1\n" + "request_id\t%s\n" + "requested_url\t%s\n" + "resolved_url\tUNOBSERVED\n" + "http_status\tUNOBSERVED\n" + "mime_type\tUNOBSERVED\n" + "transport\ticu-app-process-via-binder-shizuku\n" + "arena_name\tresponses.arena\n" + "arena_offset\t%" PRIu64 "\n" + "arena_end\t%" PRIu64 "\n" + "byte_count\t%zu\n" + "sha256\t%s\n" + "reopen_check\tMATCH\n" + "navigation_admitted\tNO\n", + id, url, offset, offset + (uint64_t)length, length, digest); + if (n <= 0 || (size_t)n >= sizeof(content)) return -1; + + int fd = openat(receipts_dir, temporary_name, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) return -1; + int ok = write_all(fd, content, (size_t)n) == 0 && fsync(fd) == 0; + if (close(fd) != 0) ok = 0; + if (!ok) { + (void)unlinkat(receipts_dir, temporary_name, 0); + return -1; + } + /* Immutable source receipt appears atomically only after source readback + * has succeeded and the complete staged receipt was synced. */ + int linked = linkat(receipts_dir, temporary_name, + receipts_dir, receipt_name, 0) == 0; + (void)unlinkat(receipts_dir, temporary_name, 0); + if (!linked) return -1; + if (fsync(receipts_dir) != 0) { + (void)unlinkat(receipts_dir, receipt_name, 0); + (void)fsync(receipts_dir); + return -1; + } + return 0; +} + +int ib_ingress_arena_init(ib_ingress_arena *arena, const char *private_root) { + if (!arena || !private_root || private_root[0] != '/') return -1; + size_t n = strnlen(private_root, IB_INGRESS_ARENA_ROOT_CAP); + if (!n || n >= IB_INGRESS_ARENA_ROOT_CAP) return -1; + memset(arena, 0, sizeof(*arena)); + memcpy(arena->private_root, private_root, n + 1U); + return 0; +} + +static int publish_response(void *opaque, const char *request_id, + uint64_t generation, + const char *requested_url, + const unsigned char *exact_bytes, + size_t byte_count, + const char sha256_hex[65], + const observed_response *response) { + ib_ingress_arena *publisher = opaque; + if (!publisher || !generation || !valid_id(request_id) || !valid_url(requested_url) || + (response && !valid_url(response->resolved_url)) || + !exact_bytes || !sha256_hex || byte_count == 0 || + byte_count > IB_READER_SOURCE_MAX_BYTES || + strnlen(sha256_hex, 65U) != 64U) return -1; + char computed[65]; + ib_reader_sha256_hex(exact_bytes, byte_count, computed); + if (memcmp(sha256_hex, computed, 65U) != 0) return -1; + + int root = -1, locks = -1, receipts = -1, result = -1; + int arena_is_open = 0; + char arena_path[IB_INGRESS_ARENA_ROOT_CAP + 24U]; + appendfat_arena canonical = {0}; + struct stat info; + int n = snprintf(arena_path, sizeof(arena_path), + "%s/responses.arena", publisher->private_root); + if (n <= 0 || (size_t)n >= sizeof(arena_path)) return -1; + + root = open(publisher->private_root, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (!owned_directory(root)) goto done; + locks = openat(root, "locks", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + receipts = openat(root, "receipts", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (!owned_directory(locks) || !owned_directory(receipts) || + owned_regular(root, "responses.arena") != 0 || + owned_regular(root, "responses.arena.used") != 0 || + owned_regular(root, "responses.arena.lock") != 0) + goto done; + + /* Bind request identity before changing the canonical commit pointer. + * Lock remains after failure for audit and prevents retries with same ID. */ + if (mkdirat(locks, request_id, 0700) != 0) goto done; + if (appendfat_arena_open(&canonical, arena_path, 1) != 0) goto done; + arena_is_open = 1; + uint64_t old_used = appendfat_arena_committed(&canonical); + if (old_used > appendfat_arena_capacity(&canonical) || + byte_count > appendfat_arena_capacity(&canonical) - old_used) + goto done; + if (appendfat_arena_append(&canonical, exact_bytes, byte_count) != 0) { + appendfat_arena_discard_uncommitted(&canonical); + goto done; + } + if (appendfat_arena_commit(&canonical) != 0) goto done; + if (appendfat_arena_committed(&canonical) - old_used != byte_count) + goto done; + appendfat_arena_close(&canonical); + arena_is_open = 0; + + if (appendfat_arena_open(&canonical, arena_path, 0) != 0) goto done; + arena_is_open = 1; + if (appendfat_arena_committed(&canonical) < + old_used + (uint64_t)byte_count) goto done; + size_t read_back = 0; + unsigned char block[READBACK_CHUNK]; + while (read_back < byte_count) { + size_t max = byte_count - read_back, amount = 0; + if (max > sizeof(block)) max = sizeof(block); + if (appendfat_arena_read(&canonical, + old_used + (uint64_t)read_back, + block, max, &amount) != 0 || + amount == 0 || + memcmp(block, exact_bytes + read_back, amount) != 0) + goto done; + read_back += amount; + } + appendfat_arena_close(&canonical); + arena_is_open = 0; + if (fstat(receipts, &info) != 0 || !S_ISDIR(info.st_mode)) + goto done; + if (receipt_publish(receipts, request_id, requested_url, + old_used, byte_count, computed, generation, response) != 0) + goto done; + result = 0; +done: + if (arena_is_open) appendfat_arena_close(&canonical); + if (receipts >= 0) close(receipts); + if (locks >= 0) close(locks); + if (root >= 0) close(root); + return result; +} + +int ib_ingress_arena_publish(void *opaque, const char *request_id, + uint64_t generation, const char *requested_url, + const unsigned char *exact_bytes, size_t byte_count, + const char sha256_hex[65]) { + return publish_response(opaque, request_id, generation, requested_url, + exact_bytes, byte_count, sha256_hex, NULL); +} + +int ib_ingress_arena_publish_frame(void *opaque, const char *request_id, + uint64_t generation, const char *requested_url, + const unsigned char *frame, size_t length, + const char expected[65]) { + if (!frame || !expected || !valid_url(requested_url) || + !length || length > IB_INGRESS_STREAM_MAX_BYTES || + strnlen(expected, 65U) != 64U) return -1; + char computed[65]; + ib_reader_sha256_hex(frame, length, computed); + if (memcmp(expected, computed, 65U) != 0) return -1; + observed_response response = {0}; + const unsigned char *body = NULL; + size_t body_bytes = 0; + if (frame_response(frame, length, requested_url, &response, + &body, &body_bytes) != 0) return -1; + ib_reader_sha256_hex(body, body_bytes, computed); + return publish_response(opaque, request_id, generation, requested_url, + body, body_bytes, computed, &response); +} diff --git a/native/android/ib_shizuku_arena_publish.h b/native/android/ib_shizuku_arena_publish.h new file mode 100644 index 00000000..06eda18c --- /dev/null +++ b/native/android/ib_shizuku_arena_publish.h @@ -0,0 +1,39 @@ +#ifndef IB_SHIZUKU_ARENA_PUBLISH_H +#define IB_SHIZUKU_ARENA_PUBLISH_H + +#include +#include + +#define IB_INGRESS_ARENA_ROOT_CAP 4096U + +typedef struct { + char private_root[IB_INGRESS_ARENA_ROOT_CAP]; +} ib_ingress_arena; + +/* App-owned, preinitialized 0700 Arena root. Existing responses.arena, + * .used and .lock files, receipts/ and locks/ directories must already + * exist. Never create another canonical source store or let Shizuku open it. */ +int ib_ingress_arena_init(ib_ingress_arena *arena, const char *private_root); + +/* Implements ib_ingress_publish. Uses the canonical appendfat_arena C API + * pinned by the application. Commit and independent readback precede the + * immutable, sync'd v1 source receipt. Unsupported HTTP metadata remains + * explicitly UNOBSERVED; navigation_admitted remains NO. */ +int ib_ingress_arena_publish(void *arena, const char *request_id, + uint64_t navigation_generation, + const char *requested_url, + const unsigned char *exact_bytes, + size_t byte_count, + const char sha256_hex[65]); + +/* Paired ICU capture only: validates the exact IBICU01 frame and observed + * metadata before appending its body. Publishes the existing Arena v2 receipt. + * A body-only stream is never promoted to an admitted response. */ +int ib_ingress_arena_publish_frame(void *arena, const char *request_id, + uint64_t navigation_generation, + const char *requested_url, + const unsigned char *frame, + size_t frame_bytes, + const char frame_sha256_hex[65]); + +#endif diff --git a/native/android/ib_shizuku_ingress_binder.c b/native/android/ib_shizuku_ingress_binder.c new file mode 100644 index 00000000..e2227819 --- /dev/null +++ b/native/android/ib_shizuku_ingress_binder.c @@ -0,0 +1,212 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_shizuku_ingress_binder.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 + +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + char id[IB_READER_REQUEST_ID_CAP + 1U]; + int32_t capacity; +} request_name; + +static bool allocate_name(void *opaque, int32_t length, char **out) { + request_name *name = opaque; + if (!name || !out || length <= 1 || + length > (int32_t)sizeof(name->id)) return false; + name->capacity = length; + *out = name->id; + return true; +} + +static int valid_name(const request_name *name) { + if (!name || name->capacity < 2) return 0; + size_t n = strnlen(name->id, sizeof(name->id)); + if (n + 1U != (size_t)name->capacity || !n || + (n == 1U && name->id[0] == '.') || + (n == 2U && name->id[0] == '.' && name->id[1] == '.')) + return 0; + for (size_t i = 0; i < n; ++i) { + unsigned char c = (unsigned char)name->id[i]; + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + c == '.' || c == '_' || c == '-')) return 0; + } + return 1; +} + +typedef struct { + char url[IB_INGRESS_URL_CAP + 1U]; + int32_t capacity; +} authorized_url_reply; + +static bool allocate_url(void *opaque, int32_t length, char **out) { + authorized_url_reply *reply = opaque; + if (!reply || !out || length < 10 || + length > (int32_t)sizeof(reply->url)) return false; + reply->capacity = length; + *out = reply->url; + return true; +} + +static void *created(void *user_data) { return user_data; } +static void destroyed(void *user_data) { (void)user_data; } + +static binder_status_t serve(AIBinder *binder, transaction_code_t code, + const AParcel *in, AParcel *out) { + if (code != IB_SHIZUKU_INGRESS_BEGIN && + code != IB_SHIZUKU_INGRESS_FINISH) return STATUS_UNKNOWN_TRANSACTION; + ib_shizuku_arena_ingress *session = + (ib_shizuku_arena_ingress *)AIBinder_getUserData(binder); + if (!session) return AParcel_writeInt32(out, -1); + + request_name name = {{0}, 0}; + int64_t generation = 0; + binder_status_t status = AParcel_readString(in, &name, allocate_name); + if (status != STATUS_OK || !valid_name(&name)) + return AParcel_writeInt32(out, -1); + status = AParcel_readInt64(in, &generation); + if (status != STATUS_OK || generation <= 0) + return AParcel_writeInt32(out, -1); + + uid_t calling_uid = (uid_t)AIBinder_getCallingUid(); + if (code == IB_SHIZUKU_INGRESS_BEGIN) { + int fd = -1; + int accepted = ib_ingress_begin(session, calling_uid, name.id, + (uint64_t)generation, &fd); + if (accepted != 0 || fd < 0) { + if (fd >= 0) close(fd); + return AParcel_writeInt32(out, -1); + } + status = AParcel_writeInt32(out, 0); + if (status == STATUS_OK) + status = AParcel_writeString( + out, session->requested_url, + (int32_t)strlen(session->requested_url)); + if (status == STATUS_OK) + status = AParcel_writeParcelFileDescriptor(out, fd); + close(fd); + if (status != STATUS_OK) + ib_ingress_want(session, 0); + return status; + } + + int32_t process_exit_status = -1; + status = AParcel_readInt32(in, &process_exit_status); + if (status != STATUS_OK || process_exit_status < 0 || + process_exit_status > 255) + return AParcel_writeInt32(out, -1); + int result = ib_ingress_finish(session, calling_uid, name.id, + (uint64_t)generation, process_exit_status); + return AParcel_writeInt32(out, result == 0 ? 0 : -1); +} + +static pthread_once_t binder_once = PTHREAD_ONCE_INIT; +static AIBinder_Class *ingress_class = NULL; + +static void initialize_class(void) { + ingress_class = AIBinder_Class_define( + "org.isomorphisms.ib.ShizukuArenaIngress.v1", + created, destroyed, serve + ); +} +static const AIBinder_Class *get_ingress_class(void) { + (void)pthread_once(&binder_once, initialize_class); + return ingress_class; +} + +AIBinder *ib_shizuku_ingress_binder_new(ib_shizuku_arena_ingress *session) { + if (!session || !get_ingress_class()) return NULL; + return AIBinder_new(get_ingress_class(), session); +} + +static int prepare(AIBinder *binder, const char *id, uint64_t generation, + AParcel **request) { + if (!binder || !id || !generation || + !get_ingress_class() || + !AIBinder_associateClass(binder, get_ingress_class())) + return -1; + size_t len = strnlen(id, IB_READER_REQUEST_ID_CAP + 1U); + if (len == 0 || len > IB_READER_REQUEST_ID_CAP) return -1; + binder_status_t status = AIBinder_prepareTransaction(binder, request); + if (status != STATUS_OK || !*request) return -1; + status = AParcel_writeString(*request, id, (int32_t)len); + if (status == STATUS_OK) + status = AParcel_writeInt64(*request, (int64_t)generation); + if (status != STATUS_OK) { + AParcel_delete(*request); + *request = NULL; + return -1; + } + return 0; +} + +int ib_shizuku_ingress_begin_remote(AIBinder *binder, const char *id, + uint64_t generation, + char app_authorized_url[IB_INGRESS_URL_CAP + 1U], + int *write_fd) { + if (write_fd) *write_fd = -1; + if (app_authorized_url) app_authorized_url[0] = '\0'; + if (!write_fd || !app_authorized_url) return -1; + AParcel *request = NULL, *reply = NULL; + if (prepare(binder, id, generation, &request) != 0) return -1; + binder_status_t status = AIBinder_transact( + binder, IB_SHIZUKU_INGRESS_BEGIN, &request, &reply, 0); + if (status != STATUS_OK || !reply) { + if (reply) AParcel_delete(reply); + return -1; + } + int32_t remote_status = -1; + int descriptor = -1; + authorized_url_reply authorized_url = {{0}, 0}; + status = AParcel_readInt32(reply, &remote_status); + if (status == STATUS_OK && remote_status == 0) + status = AParcel_readString(reply, &authorized_url, allocate_url); + if (status == STATUS_OK && remote_status == 0) { + if (authorized_url.capacity < 10 || + strnlen(authorized_url.url, sizeof(authorized_url.url)) + 1U != + (size_t)authorized_url.capacity) + status = STATUS_BAD_VALUE; + } + if (status == STATUS_OK && remote_status == 0) + status = AParcel_readParcelFileDescriptor(reply, &descriptor); + AParcel_delete(reply); + if (status != STATUS_OK || remote_status != 0 || descriptor < 0) { + if (descriptor >= 0) close(descriptor); + return -1; + } + memcpy(app_authorized_url, authorized_url.url, + (size_t)authorized_url.capacity); + *write_fd = descriptor; + return 0; +} + +int ib_shizuku_ingress_finish_remote(AIBinder *binder, const char *id, + uint64_t generation, int exit_status) { + if (exit_status < 0 || exit_status > 255) return -1; + AParcel *request = NULL, *reply = NULL; + if (prepare(binder, id, generation, &request) != 0) return -1; + binder_status_t status = AParcel_writeInt32(request, exit_status); + if (status != STATUS_OK) { + AParcel_delete(request); + return -1; + } + status = AIBinder_transact( + binder, IB_SHIZUKU_INGRESS_FINISH, &request, &reply, 0); + if (status != STATUS_OK || !reply) { + if (reply) AParcel_delete(reply); + return -1; + } + int32_t result = -1; + status = AParcel_readInt32(reply, &result); + AParcel_delete(reply); + return status == STATUS_OK && result == 0 ? 0 : -1; +} +#endif diff --git a/native/android/ib_shizuku_ingress_binder.h b/native/android/ib_shizuku_ingress_binder.h new file mode 100644 index 00000000..3ff905d7 --- /dev/null +++ b/native/android/ib_shizuku_ingress_binder.h @@ -0,0 +1,37 @@ +#ifndef IB_SHIZUKU_INGRESS_BINDER_H +#define IB_SHIZUKU_INGRESS_BINDER_H + +#include "ib_shizuku_arena_ingress.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* Private app-created Binder capability; never register it with Android's + * global service manager. App host supplies it only to its explicitly + * authorized Shizuku/rish bridge. The caller never gets an Arena pathname. */ +AIBinder *ib_shizuku_ingress_binder_new(ib_shizuku_arena_ingress *session); + +/* Transaction 2: BEGIN(request_id, generation) -> (status, URL, WRITE PFD). + * Transaction 3: FINISH(request_id, generation, ICU process exit status) + * -> (status). + * Status zero is permitted only after the app independently committed and + * read-back verified the canonical Arena source receipt. */ +enum { + IB_SHIZUKU_INGRESS_BEGIN = 2, + IB_SHIZUKU_INGRESS_FINISH = 3 +}; + +/* The bridge uses the privately passed Binder handle. It must close the + * returned FD before reporting ICU completion. Only app-owned code arms + * the request identity and approved caller UID. */ +int ib_shizuku_ingress_begin_remote(AIBinder *binder, + const char *request_id, + uint64_t navigation_generation, + char app_authorized_url[IB_INGRESS_URL_CAP + 1U], + int *write_fd); +int ib_shizuku_ingress_finish_remote(AIBinder *binder, + const char *request_id, + uint64_t navigation_generation, + int icu_exit_status); +#endif +#endif diff --git a/native/android/ib_shizuku_ingress_runner.c b/native/android/ib_shizuku_ingress_runner.c new file mode 100644 index 00000000..a02a6cac --- /dev/null +++ b/native/android/ib_shizuku_ingress_runner.c @@ -0,0 +1,123 @@ +#define _POSIX_C_SOURCE 200809L +#include "ib_shizuku_ingress_runner.h" + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include "ib_shizuku_ingress_binder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +#define BROKER_PROCESS_DEADLINE_MS 35000 + +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 int approved_executable_path(const char *path, int executable) { + if (!path || path[0] != '/') return 0; + size_t n = strnlen(path, 4097U); + if (n < 2 || n > 4096) return 0; + /* No path substitution via relative traversal or writable leaf. The + * independent Cat Food digest/executable pin must also be checked by the + * caller; a same-UID attacker could otherwise replace ancestor paths. */ + if (strstr(path, "/../") != NULL || strstr(path, "/./") != NULL || + (n >= 3 && strcmp(path + n - 3, "/..") == 0) || + (n >= 2 && strcmp(path + n - 2, "/.") == 0)) + return 0; + struct stat info; + if (lstat(path, &info) != 0 || !S_ISREG(info.st_mode) || + (info.st_mode & 022U) != 0 || + (info.st_uid != geteuid() && info.st_uid != 0)) + return 0; + return !executable || (info.st_mode & 0111U) != 0; +} + +static int run_process(const char *qualified_grease, const char *worker_script, + const char *authorized_url, int write_fd) { + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) return 127; + int actions_status = + posix_spawn_file_actions_adddup2(&actions, write_fd, STDOUT_FILENO); + if (actions_status == 0 && write_fd != STDOUT_FILENO) + actions_status = posix_spawn_file_actions_addclose(&actions, write_fd); + + pid_t pid = -1; + int spawned = actions_status; + if (spawned == 0) { + char *const argv[] = { + (char *)qualified_grease, (char *)worker_script, + (char *)authorized_url, NULL + }; + spawned = posix_spawn(&pid, qualified_grease, &actions, + NULL, argv, environ); + } + (void)posix_spawn_file_actions_destroy(&actions); + if (spawned != 0 || pid <= 0) return 127; + + int64_t start = monotonic_milliseconds(); + int64_t deadline = start < 0 ? 0 : start + BROKER_PROCESS_DEADLINE_MS; + for (;;) { + int status = 0; + pid_t result = waitpid(pid, &status, WNOHANG); + if (result == pid) { + if (WIFEXITED(status)) return WEXITSTATUS(status); + if (WIFSIGNALED(status)) { + int code = 128 + WTERMSIG(status); + return code > 255 ? 255 : code; + } + return 125; + } + if (result < 0 && errno != EINTR) return 125; + int64_t now = monotonic_milliseconds(); + if (now < 0 || now >= deadline) { + (void)kill(pid, SIGKILL); + while (waitpid(pid, NULL, 0) < 0 && errno == EINTR) { } + return 124; + } + struct timespec interval = {0, 50000000L}; + while (nanosleep(&interval, &interval) != 0 && errno == EINTR) { } + } +} + +int ib_shizuku_ingress_run_icu(AIBinder *private_ingress, + const char *request_id, + uint64_t navigation_generation, + const char *qualified_grease, + const char *trusted_worker_script) { + if (!private_ingress || !request_id || navigation_generation == 0) + return -1; + + char authorized_url[IB_INGRESS_URL_CAP + 1U] = {0}; + int write_fd = -1; + if (ib_shizuku_ingress_begin_remote( + private_ingress, request_id, navigation_generation, + authorized_url, &write_fd) != 0 || write_fd < 0) + return -1; + + int result = 127; + if (approved_executable_path(qualified_grease, 1) && + approved_executable_path(trusted_worker_script, 0)) + result = run_process(qualified_grease, trusted_worker_script, + authorized_url, write_fd); + + /* Always close the only broker-side copy BEFORE FINISH. Otherwise a + * held write reference prevents the app from observing the exact EOF. */ + close(write_fd); + int published = ib_shizuku_ingress_finish_remote( + private_ingress, request_id, navigation_generation, result); + return result == 0 && published == 0 ? 0 : -1; +} +#endif diff --git a/native/android/ib_shizuku_ingress_runner.h b/native/android/ib_shizuku_ingress_runner.h new file mode 100644 index 00000000..d59d746f --- /dev/null +++ b/native/android/ib_shizuku_ingress_runner.h @@ -0,0 +1,23 @@ +#ifndef IB_SHIZUKU_INGRESS_RUNNER_H +#define IB_SHIZUKU_INGRESS_RUNNER_H + +#include + +#if defined(__ANDROID__) && __ANDROID_API__ >= 29 +#include + +/* Run in the explicitly authorized broker process (for example, an + * app-approved Termux/Shizuku broker), never in the display reader. + * The app-private Binder object is passed to this process out-of-band. + * + * qualified_grease and trusted_worker_script are immutable, Cat Food + * qualified absolute paths supplied by the broker/host, never by a page. + * The URL comes only from the app's BEGIN reply; no caller URL is used. + * No stdout body is captured as a String or on disk. */ +int ib_shizuku_ingress_run_icu(AIBinder *private_ingress, + const char *request_id, + uint64_t navigation_generation, + const char *qualified_grease, + const char *trusted_worker_script); +#endif +#endif diff --git a/tests/test_android_icu_arena.sh b/tests/test_android_icu_arena.sh index 147297cd..17fc1ef2 100644 --- a/tests/test_android_icu_arena.sh +++ b/tests/test_android_icu_arena.sh @@ -18,7 +18,7 @@ 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 +# signed artifact checks and the explicit HTTP/1.1 Idriç command. These tests # say nothing about a deployed ICU binary, TLS or real rish. cat > "$work/lib/android_shizuku_host.grease" <<'SHIZUKU' ib_shizuku_authority() { @@ -29,7 +29,7 @@ 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*) ;; + *ICU_CAPTURE_BODY_PATH*ICU_CAPTURE_METADATA_PATH*Idric.Generated\ --http1.1\ get*) ;; *) return 91 ;; esac case "$2" in *sha256sum*'IBICU01 %08d %08d'*) ;; *) return 92 ;; esac diff --git a/tests/test_android_icu_native_bridge.sh b/tests/test_android_icu_native_bridge.sh index da577973..66459969 100644 --- a/tests/test_android_icu_native_bridge.sh +++ b/tests/test_android_icu_native_bridge.sh @@ -45,7 +45,7 @@ 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*) ;; + *ICU_CAPTURE_BODY_PATH*ICU_CAPTURE_METADATA_PATH*Idric.Generated\ --http1.1\ get*) ;; *) return 91 ;; esac printf '%s\n' "$2" >> "$IB_TEST_REMOTE_COMMANDS" @@ -61,12 +61,13 @@ cat > "$work/fake-bin/app_process" <<'WRAPPER' # 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 +test "$#" = 6 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" +test "$4" = --http1.1 +test "$5" = get +exec "$IB_ICU_NATIVE_PROBE" "$6" WRAPPER chmod 700 "$work/fake-bin/app_process" PATH="$work/fake-bin:$PATH" diff --git a/tests/test_shizuku_arena_ingress_host.c b/tests/test_shizuku_arena_ingress_host.c new file mode 100644 index 00000000..c937fb9e --- /dev/null +++ b/tests/test_shizuku_arena_ingress_host.c @@ -0,0 +1,214 @@ +#define _GNU_SOURCE +#include "../native/android/ib_shizuku_arena_ingress.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const uid_t bridge_uid = (uid_t)2000; +static const char *example_url = "https://example.com/"; +static const unsigned char body[] = {'I', 'B', 0, 'C', '6', '7', 255, '\n'}; + +typedef struct { + int publications; + int deny_publication; + unsigned char last[sizeof(body)]; + size_t count; + char id[IB_READER_REQUEST_ID_CAP + 1U]; + char digest[65]; +} fake_arena; + +static int publish(void *opaque, const char *id, uint64_t generation, + const char *url, + const unsigned char *bytes, size_t n, + const char sha[65]) { + fake_arena *store = opaque; + assert(generation != 0); + assert(strcmp(url, example_url) == 0); + if (store->deny_publication) return -1; + if (n != sizeof(body) || memcmp(body, bytes, n) != 0) return -1; + char computed[65]; + ib_reader_sha256_hex(bytes, n, computed); + if (strcmp(computed, sha) != 0) return -1; + memcpy(store->last, bytes, n); + store->count = n; + memcpy(store->id, id, strlen(id) + 1U); + memcpy(store->digest, sha, 65U); + store->publications += 1; + return 0; +} + +static int transmit_descriptor(int socket, int descriptor) { + char payload = 'P'; + struct iovec segment = {&payload, 1U}; + union {struct cmsghdr align; unsigned char body[CMSG_SPACE(sizeof(int))];} control = {0}; + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.body; + message.msg_controllen = sizeof(control.body); + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + if (!header) return -1; + header->cmsg_level = SOL_SOCKET; + header->cmsg_type = SCM_RIGHTS; + header->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(header), &descriptor, sizeof(descriptor)); + return sendmsg(socket, &message, 0) == 1 ? 0 : -1; +} + +static int receive_descriptor(int socket) { + char payload = '\0'; + struct iovec segment = {&payload, 1U}; + union {struct cmsghdr align; unsigned char body[CMSG_SPACE(sizeof(int))];} control = {0}; + struct msghdr message = {0}; + message.msg_iov = &segment; + message.msg_iovlen = 1; + message.msg_control = control.body; + message.msg_controllen = sizeof(control.body); + if (recvmsg(socket, &message, 0) != 1 || payload != 'P' || + (message.msg_flags & MSG_CTRUNC) != 0) return -1; + struct cmsghdr *header = CMSG_FIRSTHDR(&message); + if (!header || header->cmsg_level != SOL_SOCKET || + header->cmsg_type != SCM_RIGHTS || + header->cmsg_len != CMSG_LEN(sizeof(int))) return -1; + int fd = -1; + memcpy(&fd, CMSG_DATA(header), sizeof(fd)); + return fd; +} + +static void write_source(int fd, const unsigned char *bytes, size_t count) { + size_t sent = 0; + while (sent < count) { + ssize_t n = write(fd, bytes + sent, count - sent); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) _exit(38); + sent += (size_t)n; + } +} + +static int exchange(ib_shizuku_arena_ingress *session, + const char *id, uint64_t generation, + const unsigned char *bytes, size_t count, + int producer_status, int stale_during_transfer) { + int sockets[2]; + assert(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0); + /* Fork BEFORE the source-owner creates the pipe. The bridge process + * cannot inherit an app-private Arena FD or source-reader pipe end. */ + pid_t child = fork(); + assert(child >= 0); + if (child == 0) { + signal(SIGPIPE, SIG_IGN); + close(sockets[0]); + int transferred = receive_descriptor(sockets[1]); + if (transferred < 0) _exit(41); + write_source(transferred, bytes, count); + close(transferred); + close(sockets[1]); + _exit(producer_status); + } + close(sockets[1]); + int write_fd = -1; + assert(ib_ingress_begin(session, (uid_t)10001, id, generation, + &write_fd) != 0); + assert(write_fd == -1); + assert(ib_ingress_begin(session, bridge_uid, "wrong", generation, + &write_fd) != 0); + assert(write_fd == -1); + assert(ib_ingress_begin(session, bridge_uid, id, generation + 1U, + &write_fd) != 0); + assert(write_fd == -1); + assert(ib_ingress_begin(session, bridge_uid, id, generation, + &write_fd) == 0); + assert(write_fd >= 0); + int repeated_fd = -1; + assert(ib_ingress_begin(session, bridge_uid, id, generation, + &repeated_fd) != 0 && repeated_fd == -1); + assert(transmit_descriptor(sockets[0], write_fd) == 0); + close(write_fd); + close(sockets[0]); + if (stale_during_transfer) + ib_ingress_want(session, generation + 1U); + int wait_status = 0; + assert(waitpid(child, &wait_status, 0) == child); + int exit_status = WIFEXITED(wait_status) ? WEXITSTATUS(wait_status) : 128; + assert(ib_ingress_finish(session, (uid_t)10001, id, + generation, exit_status) != 0); + int result = ib_ingress_finish(session, bridge_uid, id, + generation, exit_status); + assert(ib_ingress_finish(session, bridge_uid, id, + generation, exit_status) != 0); + return result; +} + +static void arm(ib_shizuku_arena_ingress *session, + fake_arena *store, const char *id, uint64_t generation) { + assert(ib_ingress_init(session, publish, store) == 0); + assert(ib_ingress_arm(session, id, generation, example_url, bridge_uid) == 0); +} + +int main(void) { + fake_arena store = {0}; + ib_shizuku_arena_ingress session; + assert(ib_ingress_init(&session, publish, &store) == 0); + assert(ib_ingress_arm(&session, "bad", 1, "file:///etc/passwd", bridge_uid) != 0); + assert(ib_ingress_arm(&session, "bad", 1, + "https://user:password@example.com/", bridge_uid) != 0); + assert(ib_ingress_arm(&session, "../bad", 1, example_url, bridge_uid) != 0); + assert(ib_ingress_arm(&session, "bad", 1, example_url, (uid_t)0) != 0); + assert(ib_ingress_arm(&session, "good", 1, example_url, bridge_uid) == 0); + assert(ib_ingress_arm(&session, "second", 2, example_url, bridge_uid) != 0); + assert(exchange(&session, "good", 1, body, sizeof(body), 0, 0) == 0); + assert(store.publications == 1); + assert(store.count == sizeof(body)); + assert(memcmp(store.last, body, sizeof(body)) == 0); + assert(strcmp(store.id, "good") == 0); + ib_ingress_dispose(&session); + + arm(&session, &store, "exit29", 2); + assert(exchange(&session, "exit29", 2, body, sizeof(body), 29, 0) != 0); + assert(store.publications == 1); + ib_ingress_dispose(&session); + + arm(&session, &store, "empty", 3); + assert(exchange(&session, "empty", 3, body, 0, 0, 0) != 0); + assert(store.publications == 1); + ib_ingress_dispose(&session); + + arm(&session, &store, "stale", 4); + assert(exchange(&session, "stale", 4, body, sizeof(body), 0, 1) != 0); + assert(store.publications == 1); + ib_ingress_dispose(&session); + + arm(&session, &store, "denied", 5); + store.deny_publication = 1; + assert(exchange(&session, "denied", 5, body, sizeof(body), 0, 0) != 0); + assert(store.publications == 1); + ib_ingress_dispose(&session); + store.deny_publication = 0; + + arm(&session, &store, "too-large", 6); + unsigned char *oversized = malloc(IB_READER_SOURCE_MAX_BYTES + 1U); + assert(oversized != NULL); + memset(oversized, 'Z', IB_READER_SOURCE_MAX_BYTES + 1U); + assert(exchange(&session, "too-large", 6, oversized, + IB_READER_SOURCE_MAX_BYTES + 1U, 0, 0) != 0); + assert(store.publications == 1); + free(oversized); + ib_ingress_dispose(&session); + + arm(&session, &store, "recovery", 7); + assert(exchange(&session, "recovery", 7, body, sizeof(body), 0, 0) == 0); + assert(store.publications == 2); + ib_ingress_dispose(&session); + + puts("PASS: cross-process anonymous FD delivery, app-owned source publish, binary bytes, UID/ID/generation/URL refusal, nonzero ICU exit, stale, empty, oversized, duplicate and recovery"); + puts("NOT_RUN: actual Android Binder, real Shizuku/ICU HTTPS and physical C67"); + return 0; +} diff --git a/tests/test_shizuku_frame_canonical.c b/tests/test_shizuku_frame_canonical.c new file mode 100644 index 00000000..c5c20cef --- /dev/null +++ b/tests/test_shizuku_frame_canonical.c @@ -0,0 +1,254 @@ +#define _GNU_SOURCE +#include "../native/android/ib_shizuku_arena_ingress.h" +#include "../native/android/ib_shizuku_arena_publish.h" +#include "../native/android/ib_arena_source_owner.h" +#include "appendfat_arena.h" + +#include +#include +#include +#include +#include +#include +#include + +static const char *url = "https://example.com/"; +static const unsigned char html[] = "

Reading

caf\303\251 \360\237\214\215

\n"; + +static uint64_t committed(const char *root) { + char path[4096]; + int n = snprintf(path, sizeof(path), "%s/responses.arena", root); + assert(n > 0 && (size_t)n < sizeof(path)); + appendfat_arena arena; + assert(appendfat_arena_open(&arena, path, 0) == 0); + uint64_t count = appendfat_arena_committed(&arena); + appendfat_arena_close(&arena); + return count; +} + +static size_t frame(unsigned char *out, size_t capacity, const char *metadata, + const unsigned char *body, size_t count) { + size_t m = strlen(metadata); + assert(m <= 8192U && m + count + 27U <= capacity); + int n = snprintf((char *)out, capacity, "IBICU01 %08zu %08zu\n", m, count); + assert(n == 26); + memcpy(out + 26U, metadata, m); + memcpy(out + 26U + m, body, count); + return 26U + m + count; +} + +static void metadata(char *out, size_t capacity, const char *type, + const char *resolved, unsigned redirects, + const char *utf8, size_t count) { + int n = snprintf(out, capacity, + "http_status\t200\ncontent_type\t%s\ncontent_encoding\tidentity\n" + "declared_length\t%zu\nreceived_length\t%zu\nutf8\t%s\n" + "resolved_url\t%s\nredirect_count\t%u\n", + type, count, count, utf8, resolved, redirects); + assert(n > 0 && (size_t)n < capacity); +} + +static int send_frame(ib_ingress_arena *publisher, const char *id, + const unsigned char *bytes, size_t count, + int worker_status, int stale) { + ib_shizuku_arena_ingress session; + assert(ib_ingress_init(&session, ib_ingress_arena_publish_frame, publisher) == 0); + assert(ib_ingress_arm(&session, id, 19, url, (uid_t)2000) == 0); + int fd = -1; + assert(ib_ingress_begin(&session, (uid_t)2000, id, 19, &fd) == 0); + size_t sent = 0; + while (sent < count) { + size_t chunk = count - sent; + if (chunk > 13U) chunk = 13U; /* Split the header and metadata too. */ + ssize_t n = write(fd, bytes + sent, chunk); + if (n < 0 && errno == EINTR) continue; + assert(n > 0); + sent += (size_t)n; + } + assert(close(fd) == 0); + if (stale) ib_ingress_want(&session, 20); + int result = ib_ingress_finish(&session, (uid_t)2000, id, 19, worker_status); + ib_ingress_dispose(&session); + return result; +} + +static int reopen(const char *root, const char *id, uint64_t generation) { + ib_arena_source_owner owner; + ib_reader_source_ticket ticket = {0}; + ib_reader_source reader; + int fd = -1; + assert(ib_arena_source_owner_init(&owner, root) == 0); + ib_arena_source_owner_want(&owner, generation); + int result = ib_arena_source_owner_open(&owner, id, generation, &ticket, &fd); + if (result != 0) { assert(fd == -1); return result; } + assert(ticket.navigation_generation == generation); + assert(ticket.byte_count == sizeof(html) - 1U); + ib_reader_source_init(&reader); + ib_reader_source_want(&reader, generation); + assert(ib_reader_source_accept_fd(&reader, &ticket, fd) == IB_READER_PFD_ACCEPTED); + assert(close(fd) == 0); + assert(reader.byte_count == sizeof(html) - 1U); + assert(memcmp(reader.bytes, html, sizeof(html) - 1U) == 0); + ib_reader_source_clear(&reader); + return 0; +} + +static void reject(ib_ingress_arena *publisher, const char *root, + const char *id, const unsigned char *bytes, size_t count) { + uint64_t before = committed(root); + assert(send_frame(publisher, id, bytes, count, 0, 0) != 0); + assert(committed(root) == before); + assert(reopen(root, id, 19) != 0); + assert(reopen(root, "observed", 19) == 0); +} + +static void current_metadata_cases(ib_ingress_arena *publisher, const char *root) { + unsigned char packet[16384]; + char base[4096], fields[8193]; + metadata(base, sizeof(base), "text/html; charset=utf-8", url, 0, + "valid", sizeof(html)-1U); + const char *valid[] = {"identity", "chunked"}; + const char *identities[] = {"current-identity", "current-chunked"}; + for (size_t i = 0; i < 2U; ++i) { + int n = snprintf(fields, sizeof(fields), + "%scontent_range\tabsent\ntransfer_decoding\t%s\n", base, valid[i]); + assert(n > 0 && (size_t)n < sizeof(fields)); + size_t count = frame(packet, sizeof(packet), fields, html, sizeof(html)-1U); + assert(send_frame(publisher, identities[i], packet, count, 0, 0) == 0); + assert(reopen(root, identities[i], 19) == 0); + } + const char *invalid[] = { + "content_range\tabsent\n", + "transfer_decoding\tidentity\n", + "content_range\tbytes 0-1/2\ntransfer_decoding\tidentity\n", + "content_range\tabsent\ntransfer_decoding\tgzip\n", + "content_range\tabsent\ntransfer_decoding\tchunked\ncontent_range\tabsent\n" + }; + for (size_t i = 0; i < sizeof(invalid)/sizeof(invalid[0]); ++i) { + char identity[64]; + int n = snprintf(identity, sizeof(identity), "current-refusal-%zu", i); + assert(n > 0 && (size_t)n < sizeof(identity)); + n = snprintf(fields, sizeof(fields), "%s%s", base, invalid[i]); + assert(n > 0 && (size_t)n < sizeof(fields)); + size_t count = frame(packet, sizeof(packet), fields, html, sizeof(html)-1U); + reject(publisher, root, identity, packet, count); + } + puts("PASS: current ICU ten-field identity/chunked captures and paired-field/range/decoder/duplicate refusals"); +} + +static unsigned char *read_observation(const char *filename, size_t bound, size_t *length) { + FILE *stream = fopen(filename, "rb"); + assert(stream != NULL); + unsigned char *bytes = malloc(bound + 1U); + assert(bytes != NULL); + *length = fread(bytes, 1, bound + 1U, stream); + assert(!ferror(stream) && *length > 0 && *length <= bound); + assert(fclose(stream) == 0); + bytes[*length] = 0; + return bytes; +} + +static void retain_live_observation(ib_ingress_arena *publisher, const char *root, + const char *requested, const char *metadata_file, + const char *body_file) { + size_t metadata_length, body_length; + unsigned char *fields = read_observation(metadata_file, 8192U, &metadata_length); + unsigned char *body = read_observation(body_file, IB_READER_SOURCE_MAX_BYTES, &body_length); + assert(strlen((const char *)fields) == metadata_length); + assert(strstr((const char *)fields, "charset=ISO-8859-1\n") != NULL); + unsigned char *packet = malloc(27U + metadata_length + body_length); + assert(packet != NULL); + size_t count = frame(packet, 27U + metadata_length + body_length, + (const char *)fields, body, body_length); + uint64_t before = committed(root); + url = requested; + assert(send_frame(publisher, "live-google", packet, count, 0, 0) == 0); + assert(committed(root) == before + body_length); + assert(reopen(root, "live-google", 19) != 0); + char arena_path[4096]; + int n = snprintf(arena_path, sizeof(arena_path), "%s/responses.arena", root); + assert(n > 0 && (size_t)n < sizeof(arena_path)); + appendfat_arena arena; + assert(appendfat_arena_open(&arena, arena_path, 0) == 0); + unsigned char *reopened = malloc(body_length); + assert(reopened != NULL); + size_t got = 0; + assert(appendfat_arena_read(&arena, before, reopened, body_length, &got) == 0); + assert(got == body_length && memcmp(reopened, body, body_length) == 0); + appendfat_arena_close(&arena); + assert(reopen(root, "observed", 19) == 0); + free(reopened); free(packet); free(body); free(fields); + puts("PASS: actual ICU Google body retained in canonical Arena and byte-verified; unsupported charset/redirect remains reader-ineligible"); +} + +int main(int argc, char **argv) { + assert(argc == 2 || argc == 5); + const char *root = argv[1]; + ib_ingress_arena publisher; + assert(ib_ingress_arena_init(&publisher, root) == 0); + assert(committed(root) == 0); + unsigned char packet[16384], bad[16384]; + char fields[8193], altered[8193]; + metadata(fields, sizeof(fields), "text/html; charset=utf-8", url, 0, "valid", sizeof(html)-1U); + size_t count = frame(packet, sizeof(packet), fields, html, sizeof(html)-1U); + assert(send_frame(&publisher, "observed", packet, count, 0, 0) == 0); + assert(committed(root) == sizeof(html)-1U); /* Frame/metadata are not page bytes. */ + assert(reopen(root, "observed", 19) == 0); + assert(reopen(root, "observed", 20) != 0); + reject(&publisher, root, "body-only", html, sizeof(html)-1U); + reject(&publisher, root, "incomplete", packet, count-1U); + memcpy(bad, packet, count); bad[count] = 'x'; + reject(&publisher, root, "trailing", bad, count+1U); + memcpy(bad, packet, count); bad[25] = 'x'; + reject(&publisher, root, "header", bad, count); + memcpy(bad, packet, count); memcpy(bad+8, "99999999", 8); + reject(&publisher, root, "oversized", bad, count); + int n = snprintf(altered, sizeof(altered), "%shttp_status\t200\n", fields); + assert(n > 0 && (size_t)n < sizeof(altered)); + size_t bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + reject(&publisher, root, "duplicate", bad, bad_count); + n = snprintf(altered, sizeof(altered), "%sunknown\tvalue\n", fields); + assert(n > 0 && (size_t)n < sizeof(altered)); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + reject(&publisher, root, "unknown", bad, bad_count); + memcpy(bad, packet, count); bad[26U] = 0; + reject(&publisher, root, "metadata-nul", bad, count); + metadata(altered, sizeof(altered), "text/html; charset=utf-8", url, 0, "valid", sizeof(html)); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + reject(&publisher, root, "wrong-length", bad, bad_count); + metadata(altered, sizeof(altered), "text/html; charset=utf-8", url, 0, "invalid", sizeof(html)-1U); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + reject(&publisher, root, "false-utf8", bad, bad_count); + metadata(altered, sizeof(altered), "text/html; charset=utf-8", "https://user:secret@example.com/", 0, "valid", sizeof(html)-1U); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + reject(&publisher, root, "credentials", bad, bad_count); + uint64_t before = committed(root); + assert(send_frame(&publisher, "worker-failed", packet, count, 29, 0) != 0); + assert(send_frame(&publisher, "stale", packet, count, 0, 1) != 0); + assert(committed(root) == before); + assert(send_frame(&publisher, "observed", packet, count, 0, 0) != 0); + assert(committed(root) == before); + char digest[65]; + ib_reader_sha256_hex(packet, count, digest); + digest[0] = digest[0] == '0' ? '1' : '0'; + assert(ib_ingress_arena_publish_frame(&publisher, "bad-digest", 19, url, + packet, count, digest) != 0); + assert(committed(root) == before); + metadata(altered, sizeof(altered), "text/html; charset=utf-8", "https://example.org/", 1, "valid", sizeof(html)-1U); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + assert(send_frame(&publisher, "unproven-redirect", bad, bad_count, 0, 0) == 0); + assert(reopen(root, "unproven-redirect", 19) != 0); + metadata(altered, sizeof(altered), "text/html", url, 0, "valid", sizeof(html)-1U); + bad_count = frame(bad, sizeof(bad), altered, html, sizeof(html)-1U); + assert(send_frame(&publisher, "unknown-charset", bad, bad_count, 0, 0) == 0); + assert(reopen(root, "unknown-charset", 19) != 0); + assert(reopen(root, "observed", 19) == 0); + current_metadata_cases(&publisher, root); + if (argc == 5) retain_live_observation(&publisher, root, argv[2], argv[3], argv[4]); + puts("PASS: paired ICU frame through private ingress into real canonical Arena v2 and exact reopened reader bytes"); + puts("PASS: body-only, partial, trailing, header, size, metadata, UTF-8, credentials, digest, worker and stale refusals preserve prior content"); + puts("PASS: incomplete redirect provenance and unobserved charset cannot admit navigation"); + puts("NOT_RUN: real Shizuku/Binder, checked Idric view, Android ART and physical C67"); + if (argc == 2) puts("NOT_RUN: ICU HTTPS; this invocation used synthetic captures only"); + return 0; +} diff --git a/tests/test_shizuku_icu_stream.sh b/tests/test_shizuku_icu_stream.sh new file mode 100644 index 00000000..030eabd3 --- /dev/null +++ b/tests/test_shizuku_icu_stream.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Shell/Binder-shaped provider contract. No actual Shizuku, network or +# imported Android Binder handle exists in this host test. +set -eu +root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +. "$root/lib/android_shizuku_icu_provider.grease" +. "$root/lib/android_binder_icu.grease" + +IB_C67_ICU_DEX=/data/local/tmp/ib-icu/classes.dex +IB_C67_ICU_SO=/data/local/tmp/ib-icu/libicu_transport.so +IB_C67_ICU_DEX_SHA256=1111111111111111111111111111111111111111111111111111111111111111 +IB_C67_ICU_SO_SHA256=2222222222222222222222222222222222222222222222222222222222222222 +export IB_C67_ICU_DEX IB_C67_ICU_SO IB_C67_ICU_DEX_SHA256 IB_C67_ICU_SO_SHA256 + +ib_shizuku_authority() { + if test "${TEST_DENIED:-0}" = 1; then printf 'uid:10222\n' + else printf 'shell\n'; fi +} +ib_shizuku_run_bounded() { + test "$1" = 30 || return 61 + printf '%s\n' "$2" >> "$work/commands" + printf 'call\n' >> "$work/invocations" + case "$2" in + *"app_process -Dicu.library="*" Idric.Generated --http1.1 get "*) ;; + *) return 62 ;; + esac + case "${TEST_RESPONSE:-success}" in + success) printf 'IB\000C67\377\n' ;; + partial) printf 'fragment\000'; return 29 ;; + *) return 63 ;; + esac +} +ib_binder_icu_stream https://example.com/ > "$work/body" +printf 'IB\000C67\377\n' > "$work/expected" +cmp "$work/expected" "$work/body" +grep -F "sha256sum '/data/local/tmp/ib-icu/classes.dex'" "$work/commands" >/dev/null +test "$(wc -l < "$work/invocations")" -eq 1 + +TEST_RESPONSE=partial +if ib_binder_icu_stream https://example.com/ > "$work/partial"; then + echo 'partial stdout falsely succeeded' >&2 + exit 1 +fi +test -s "$work/partial" +# The byte stream is discarded by the app when FINISH gets nonzero 29. +TEST_RESPONSE=success +TEST_DENIED=1 +before=$(wc -l < "$work/invocations") +if ib_binder_icu_stream https://example.com/ > "$work/denied"; then + echo 'unauthorized Shizuku erroneously succeeded' >&2 + exit 1 +fi +test ! -s "$work/denied" +test "$(wc -l < "$work/invocations")" -eq "$before" +TEST_DENIED=0 +if ib_binder_icu_stream "https://example.com/a'" \ + > "$work/injected"; then + echo 'URL command injection erroneously succeeded' >&2 + exit 1 +fi +test ! -e "$work/INJECTED" +test "$(wc -l < "$work/invocations")" -eq "$before" +printf 'PASS: pinned ICU binary stdout, nonzero producer propagation, denial and quoting refusal\n' +printf 'NOT_RUN: actual Shizuku/rish, Android Binder, ICU HTTPS, physical C67\n' diff --git a/tests/test_shizuku_ingress_canonical.c b/tests/test_shizuku_ingress_canonical.c new file mode 100644 index 00000000..8c887f66 --- /dev/null +++ b/tests/test_shizuku_ingress_canonical.c @@ -0,0 +1,158 @@ +#define _GNU_SOURCE +#include "../native/android/ib_shizuku_arena_ingress.h" +#include "../native/android/ib_shizuku_arena_publish.h" +#include "../native/android/ib_arena_source_owner.h" +#include "appendfat_arena.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const unsigned char first[] = {'I', 'B', 0, 'C', '6', '7', 255, '\n'}; +static const char *url = "https://example.com/"; + +static void path(char *out, size_t capacity, const char *root, + const char *name) { + int n = snprintf(out, capacity, "%s/%s", root, name); + assert(n > 0 && (size_t)n < capacity); +} + +static int commit_attempt(ib_ingress_arena *publisher, + const char *id, uint64_t generation, + const unsigned char *bytes, size_t byte_count, + int worker_exit, int invalidate) { + ib_shizuku_arena_ingress session; + assert(ib_ingress_init(&session, ib_ingress_arena_publish, publisher) == 0); + assert(ib_ingress_arm(&session, id, generation, url, (uid_t)2000) == 0); + int fd = -1; + assert(ib_ingress_begin(&session, (uid_t)2000, id, generation, &fd) == 0); + assert(fd >= 0); + if (byte_count) { + size_t sent = 0; + while (sent < byte_count) { + ssize_t count = write(fd, bytes + sent, byte_count - sent); + if (count < 0 && errno == EINTR) continue; + if (count <= 0) break; + sent += (size_t)count; + } + } + assert(close(fd) == 0); + if (invalidate) + ib_ingress_want(&session, generation + 1U); + int status = ib_ingress_finish(&session, (uid_t)2000, + id, generation, worker_exit); + ib_ingress_dispose(&session); + return status; +} + +static uint64_t committed_count(const char *root) { + char arena_path[4096]; + path(arena_path, sizeof(arena_path), root, "responses.arena"); + appendfat_arena arena; + assert(appendfat_arena_open(&arena, arena_path, 0) == 0); + uint64_t result = appendfat_arena_committed(&arena); + appendfat_arena_close(&arena); + return result; +} + +static void retained_matches(const char *root, const char *request_id, + uint64_t generation, uint64_t offset, + const unsigned char *expected, size_t count) { + /* Body-only ingress has no observed HTTP metadata. Check retained bytes + * through the canonical store, and require the v2 HTML owner to refuse it. + * A valid body digest must never upgrade this v1 receipt to navigation. */ + char filename[4096]; + path(filename, sizeof(filename), root, "responses.arena"); + appendfat_arena arena; + assert(appendfat_arena_open(&arena, filename, 0) == 0); + unsigned char retained[sizeof(first)]; + size_t got = 0; + assert(count == sizeof(retained)); + assert(appendfat_arena_read(&arena, offset, retained, count, &got) == 0); + assert(got == count && memcmp(retained, expected, count) == 0); + appendfat_arena_close(&arena); + ib_arena_source_owner source; + ib_reader_source_ticket ticket = {0}; + int fd = -1; + assert(ib_arena_source_owner_init(&source, root) == 0); + ib_arena_source_owner_want(&source, generation); + assert(ib_arena_source_owner_open(&source, request_id, generation, + &ticket, &fd) != 0); + assert(fd == -1); +} + +static void source_absent(const char *root, const char *id, + uint64_t generation) { + ib_arena_source_owner source; + ib_reader_source_ticket ticket = {0}; + int fd = -1; + assert(ib_arena_source_owner_init(&source, root) == 0); + ib_arena_source_owner_want(&source, generation); + assert(ib_arena_source_owner_open(&source, id, generation, &ticket, &fd) != 0); + assert(fd == -1); +} + +int main(int argc, char **argv) { + assert(argc == 2); + const char *root = argv[1]; + ib_ingress_arena publisher; + assert(ib_ingress_arena_init(&publisher, root) == 0); + assert(committed_count(root) == 0); + + assert(commit_attempt(&publisher, "real-first", 1, + first, sizeof(first), 0, 0) == 0); + assert(committed_count(root) == sizeof(first)); + retained_matches(root, "real-first", 1, 0, first, sizeof(first)); + + uint64_t good = committed_count(root); + assert(commit_attempt(&publisher, "failed-icu", 2, + first, sizeof(first), 29, 0) != 0); + assert(committed_count(root) == good); + source_absent(root, "failed-icu", 2); + + assert(commit_attempt(&publisher, "stale-page", 3, + first, sizeof(first), 0, 1) != 0); + assert(committed_count(root) == good); + source_absent(root, "stale-page", 3); + + assert(commit_attempt(&publisher, "empty-body", 4, + first, 0, 0, 0) != 0); + assert(committed_count(root) == good); + source_absent(root, "empty-body", 4); + + assert(commit_attempt(&publisher, "real-first", 5, + first, sizeof(first), 0, 0) != 0); + assert(committed_count(root) == good); + retained_matches(root, "real-first", 1, 0, first, sizeof(first)); + + char fake_hash[65]; + memset(fake_hash, '0', 64); + fake_hash[64] = '\0'; + assert(ib_ingress_arena_publish(&publisher, "invalid-hash", 6, + url, first, sizeof(first), + fake_hash) != 0); + assert(committed_count(root) == good); + + /* No shared writable path: this app-owned directory is private. */ + assert(chmod(root, 0755) == 0); + assert(commit_attempt(&publisher, "world-readable", 6, + first, sizeof(first), 0, 0) != 0); + assert(chmod(root, 0700) == 0); + assert(committed_count(root) == good); + source_absent(root, "world-readable", 6); + + assert(commit_attempt(&publisher, "real-second", 7, + first, sizeof(first), 0, 0) == 0); + assert(committed_count(root) == 2U * sizeof(first)); + retained_matches(root, "real-second", 7, sizeof(first), first, sizeof(first)); + retained_matches(root, "real-first", 1, 0, first, sizeof(first)); + puts("PASS: real pinned appendfat Arena stream commit, canonical byte readback, body-only v1 navigation refusal, duplicate/failed producer/stale/empty/denied-root/hash refusal and prior-byte reopening"); + puts("NOT_RUN: physical Android Binder, authorized Shizuku, ICU HTTPS, C67 app-private hosting"); + return 0; +} diff --git a/tests/test_shizuku_ingress_canonical.sh b/tests/test_shizuku_ingress_canonical.sh new file mode 100644 index 00000000..4b9594bf --- /dev/null +++ b/tests/test_shizuku_ingress_canonical.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Uses the pinned real sd-card-append-fat library and executable, not a +# simulated Arena writer. The binder transport itself is host-simulated. +set -eu +tool=${IB_APPEND_ARENA:-} +helper=${IB_INGRESS_CANONICAL_TEST:-} +test -n "$tool" && test -x "$tool" && +test -n "$helper" && test -x "$helper" || { + printf 'real appendfat and compiled ingress test required\n' >&2 + exit 2 +} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT HUP INT TERM +mkdir -m 700 "$work/private" +mkdir -m 700 "$work/private/receipts" "$work/private/locks" +"$tool" create "$work/private/responses.arena" 16777216 > "$work/create.txt" +"$helper" "$work/private" +printf 'producer\tcanonical-sd-card-append-fat\n' +printf 'private_app_root\t0700\n' +printf 'real_icu_and_shizuku\tNOT_RUN\n' +printf 'android_binder_transaction\tNOT_RUN\n'