@specific.dev/spectest 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import http from "node:http";
3
3
  import { spawn, execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { mkdir, stat, open, rm } from "node:fs/promises";
6
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
7
  const exec = promisify(execFile);
7
8
  const serial = process.env.ANDROID_SERIAL;
8
9
  const directory = "/recordings";
@@ -28,6 +29,7 @@ async function bootstrap() {
28
29
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
30
  );
30
31
  }
32
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
33
  await adb("install", "-r", "/spectest/app.apk");
32
34
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
35
  for (const setting of [
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -19,7 +19,7 @@ export interface AndroidOptions {
19
19
  recording?: false | AndroidRecordingOptions;
20
20
  dependsOn?: string[];
21
21
  }
22
- export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*\nRUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0\n";
22
+ export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*\nRUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0\n";
23
23
  /** Native Android, Appium and software recording, all inside the environment VM. */
24
24
  export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
25
25
  device: {
@@ -60,6 +60,7 @@ export declare function android(options: AndroidOptions): import("../index.js").
60
60
  inputs: string[];
61
61
  env: {
62
62
  ANDROID_SERIAL: string;
63
+ ANDROID_CA_PATH: string;
63
64
  ANDROID_ACTIVITY: string;
64
65
  ANDROID_SHOW_TOUCHES: string;
65
66
  };
@@ -3,8 +3,9 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
6
7
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
7
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
8
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
8
9
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
9
10
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
10
11
  RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
@@ -61,6 +62,10 @@ export function android(options) {
61
62
  path: "/spectest-android.mjs",
62
63
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
63
64
  },
65
+ {
66
+ path: "/spectest-android-trust.mjs",
67
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
68
+ },
64
69
  ],
65
70
  volumes: [
66
71
  {
@@ -72,6 +77,7 @@ export function android(options) {
72
77
  inputs: [apk],
73
78
  env: {
74
79
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
80
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
75
81
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
76
82
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
77
83
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.124.0",
3
+ "version": "0.125.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,6 +3,7 @@ import http from "node:http";
3
3
  import { spawn, execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { mkdir, stat, open, rm } from "node:fs/promises";
6
+ import { installAndroidTrust } from "./spectest-android-trust.mjs";
6
7
  const exec = promisify(execFile);
7
8
  const serial = process.env.ANDROID_SERIAL;
8
9
  const directory = "/recordings";
@@ -28,6 +29,7 @@ async function bootstrap() {
28
29
  "Android did not boot; the guest kernel needs Binder, DMA-BUF heaps and PSI enabled",
29
30
  );
30
31
  }
32
+ await installAndroidTrust(adb, process.env.ANDROID_CA_PATH);
31
33
  await adb("install", "-r", "/spectest/app.apk");
32
34
  await adb("shell", "settings", "put", "global", "private_dns_mode", "off");
33
35
  for (const setting of [
@@ -0,0 +1,126 @@
1
+ // Runtime-only trust provisioning for the private Android device. Never change
2
+ // the image or disable certificate/hostname verification in the application.
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const exec = promisify(execFile);
10
+
11
+ export const ANDROID_TRUST_SCRIPT = `#!/system/bin/sh
12
+ set -eu
13
+ cert=/data/local/tmp/spectest-ca.crt
14
+ store=/data/local/tmp/spectest-system-cacerts
15
+ system=/system/etc/security/cacerts
16
+ apex=/apex/com.android.conscrypt/cacerts
17
+ hash="$1"
18
+ test "$(id -u)" = 0
19
+ test -s "$cert"
20
+
21
+ # Keep a private copy of the active Android root set, including its public CAs.
22
+ # The directory and namespace mounts are part of the VM snapshot, not the image.
23
+ if [ ! -d "$store" ]; then
24
+ original="$system"
25
+ if [ -d "$apex" ] && [ -n "$(ls -A "$apex")" ]; then original="$apex"; fi
26
+ copy=$(mktemp -d /data/local/tmp/spectest-cacerts.XXXXXX)
27
+ trap 'rm -rf "$copy"' EXIT
28
+ cp "$original"/* "$copy"/
29
+ mv "$copy" "$store"
30
+ trap - EXIT
31
+ fi
32
+
33
+ # Android uses the old OpenSSL subject hash, with numbered collisions. Never
34
+ # overwrite a public root with the same subject hash, or duplicate our own CA.
35
+ index=0
36
+ while [ -f "$store/$hash.$index" ]; do
37
+ if cmp -s "$cert" "$store/$hash.$index"; then break; fi
38
+ index=$((index + 1))
39
+ done
40
+ cp "$cert" "$store/$hash.$index"
41
+ chown 0:0 "$store" "$store"/*
42
+ chmod 0755 "$store"
43
+ chmod 0644 "$store"/*
44
+ if [ "$(getenforce)" != Disabled ]; then
45
+ chcon u:object_r:system_file:s0 "$store" "$store"/*
46
+ fi
47
+
48
+ # Android 14 loads roots from Conscrypt's APEX. Each zygote has a private mount
49
+ # namespace, inherited by new apps and the pre-forked USAP pool. Enter those
50
+ # namespaces, including existing descendants, before the APK is launched.
51
+ zygotes="$(pidof zygote zygote64 || true)"
52
+ test -n "$zygotes" || { echo 'Android TLS: no zygote process found' >&2; exit 1; }
53
+ # Mount each generation before enumerating its children. A process forked
54
+ # after enumeration then inherits the mounted store, closing the USAP race.
55
+ frontier="$zygotes"
56
+ while [ -n "$frontier" ]; do
57
+ for pid in $frontier; do
58
+ [ -d "/proc/$pid" ] || continue
59
+ for target in "$system" "$apex"; do
60
+ [ -d "/proc/$pid/root$target" ] || continue
61
+ if ! cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"; then
62
+ # setns alone can retain the caller's filesystem root.
63
+ if ! nsenter --mount="/proc/$pid/ns/mnt" -- chroot "/proc/$pid/root" mount --bind "$store" "$target"; then
64
+ [ ! -d "/proc/$pid" ] && break
65
+ echo "Android TLS: cannot install trust in process $pid" >&2
66
+ exit 1
67
+ fi
68
+ fi
69
+ done
70
+ done
71
+ # Android 14's toybox has no awk. Walk with the system shell instead.
72
+ processes="$(ps -A -o PID,PPID)"
73
+ next=""
74
+ while read -r pid parent; do
75
+ case " $frontier " in *" $parent "*) next="$next $pid" ;; esac
76
+ done <<PROCESSES
77
+ $processes
78
+ PROCESSES
79
+ frontier="$next"
80
+ done
81
+ for pid in $zygotes; do
82
+ target="$system"
83
+ if [ -d "/proc/$pid/root$apex" ]; then target="$apex"; fi
84
+ cmp -s "$cert" "/proc/$pid/root$target/$hash.$index"
85
+ done
86
+ echo 'spectest-android-trust-ready'
87
+ `;
88
+
89
+ /** Install the environment CA before app installation/launch. Throws on failure. */
90
+ export async function installAndroidTrust(adb, caPath, run = exec) {
91
+ try {
92
+ const { stdout } = await run("openssl", ["x509", "-in", caPath, "-subject_hash_old", "-noout"]);
93
+ const hash = stdout.trim();
94
+ if (!/^[a-f0-9]{8}$/.test(hash)) throw new Error("invalid CA subject hash");
95
+
96
+ let root = [];
97
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
98
+ try {
99
+ if ((await adb("shell", "su", "0", "id", "-u")).stdout.trim() !== "0") throw new Error("not root");
100
+ root = ["su", "0"];
101
+ } catch {
102
+ await adb("root");
103
+ await adb("wait-for-device");
104
+ if ((await adb("shell", "id", "-u")).stdout.trim() !== "0") {
105
+ throw new Error("the Android image must provide root ADB or su access");
106
+ }
107
+ }
108
+ }
109
+ const directory = await mkdtemp(join(tmpdir(), "spectest-android-trust-"));
110
+ try {
111
+ const script = join(directory, "trust.sh");
112
+ await writeFile(script, ANDROID_TRUST_SCRIPT);
113
+ await adb("push", caPath, "/data/local/tmp/spectest-ca.crt");
114
+ await adb("push", script, "/data/local/tmp/spectest-trust.sh");
115
+ const result = await adb("shell", ...root, "sh", "/data/local/tmp/spectest-trust.sh", hash);
116
+ if (!result.stdout.includes("spectest-android-trust-ready")) {
117
+ throw new Error("system CA provisioning did not confirm success");
118
+ }
119
+ } finally {
120
+ await rm(directory, { recursive: true, force: true });
121
+ await adb("shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh").catch(() => {});
122
+ }
123
+ } catch (cause) {
124
+ throw new Error(`Android TLS trust provisioning failed: ${cause.message}`, { cause });
125
+ }
126
+ }
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { ANDROID_TRUST_SCRIPT, installAndroidTrust } from "./android-trust.mjs";
6
+
7
+ test("Android CA provisioning requires confirmed installation and supports root ADB restart", async () => {
8
+ const calls: string[][] = [];
9
+ let rooted = false;
10
+ const adb = async (...args: string[]) => {
11
+ calls.push(args);
12
+ if (args[0] === "root") rooted = true;
13
+ if (args.includes("su")) throw new Error("no su");
14
+ return { stdout: args.includes("id") ? (rooted ? "0\n" : "2000\n") : "" };
15
+ };
16
+ await expect(installAndroidTrust(adb, "/fixture/ca.crt", async () => ({ stdout: "1234abcd\n" })))
17
+ .rejects.toThrow("did not confirm success");
18
+ expect(calls.findIndex(args => args[0] === "root")).toBeLessThan(calls.findIndex(args => args[0] === "push"));
19
+ expect(calls).toContainEqual(["wait-for-device"]);
20
+ expect(calls.at(-1)).toEqual(["shell", "rm", "-f", "/data/local/tmp/spectest-ca.crt", "/data/local/tmp/spectest-trust.sh"]);
21
+ });
22
+
23
+ test("invalid CA data fails before accessing Android", async () => {
24
+ let called = false;
25
+ await expect(installAndroidTrust(async () => { called = true; }, "/fixture/ca.crt", async () => ({ stdout: "not-a-certificate" })))
26
+ .rejects.toThrow("invalid CA subject hash");
27
+ expect(called).toBe(false);
28
+ });
29
+
30
+ const canMount = process.platform === "linux" && Bun.spawnSync(["sh", "-c", "command -v unshare >/dev/null && unshare -Urnm true"], { stderr: "ignore" }).exitCode === 0;
31
+ if (process.env.SPECTEST_REQUIRE_ANDROID_TRUST_MOUNTS === "1" && !canMount) {
32
+ throw new Error("Android trust CI requires Linux user/mount namespaces (unshare -Urnm); refusing to skip mount coverage");
33
+ }
34
+
35
+ // Exercise real bind mounts and private zygote/child mount namespaces.
36
+ // This is intentionally more than an assertion about the generated script.
37
+ test.skipIf(!canMount)("Android trust preserves roots and hash collisions, reaches private app namespaces and is idempotent", async () => {
38
+ const directory = mkdtempSync(join(tmpdir(), "spectest-android-mounts-"));
39
+ for (const name of ["system", "apex", "bin"]) mkdirSync(join(directory, name));
40
+ writeFileSync(join(directory, "apex", "1234abcd.0"), "public root with the same subject hash\n");
41
+ writeFileSync(join(directory, "apex", "deadbeef.0"), "another public root\n");
42
+ writeFileSync(join(directory, "spectest-ca.crt"), "the environment CA\n");
43
+ writeFileSync(join(directory, "bin", "getenforce"), "#!/bin/sh\necho Disabled\n", { mode: 0o755 });
44
+ // Linux procps calls these fields lowercase; Android toybox uses uppercase.
45
+ writeFileSync(join(directory, "bin", "ps"), `#!/bin/sh
46
+ set -eu
47
+ snapshot=$(/bin/ps -A -o pid,ppid)
48
+ if [ ! -e '${directory}/race.triggered' ]; then
49
+ touch '${directory}/race.triggered'
50
+ kill -USR1 $(cat '${directory}/zygote.pid')
51
+ while [ ! -e '${directory}/late.ready' ]; do sleep 0.01; done
52
+ fi
53
+ # The late app is deliberately absent from this process-list snapshot. It
54
+ # must inherit the parent's trust even though it gets its own namespace.
55
+ printf '%s\\n' "$snapshot"
56
+ `, { mode: 0o755 });
57
+ writeFileSync(join(directory, "bin", "pidof"), `#!/bin/sh\ncat '${directory}/zygote.pid'\n`, { mode: 0o755 });
58
+ writeFileSync(join(directory, "trust.sh"), ANDROID_TRUST_SCRIPT
59
+ .replaceAll("/system/etc/security/cacerts", `${directory}/system`)
60
+ .replaceAll("/apex/com.android.conscrypt/cacerts", `${directory}/apex`)
61
+ .replaceAll("/data/local/tmp", directory));
62
+ writeFileSync(join(directory, "late.sh"), `#!/bin/sh
63
+ set -eu
64
+ mount --make-rprivate /
65
+ touch '${directory}/late.ready'
66
+ exec sleep 120
67
+ `);
68
+ writeFileSync(join(directory, "zygote.sh"), `#!/bin/sh
69
+ set -eu
70
+ mount --make-rprivate /
71
+ late_app() {
72
+ unshare -m sh '${directory}/late.sh' &
73
+ echo $! > '${directory}/late.pid'
74
+ }
75
+ trap late_app USR1
76
+ echo $$ > '${directory}/zygote.pid'
77
+ unshare -m sh -c 'mount --make-rprivate /; touch "${directory}/app.ready"; exec sleep 120' &
78
+ echo $! > '${directory}/app.pid'
79
+ while true; do wait || true; done
80
+ `);
81
+ writeFileSync(join(directory, "verify.sh"), `#!/bin/sh
82
+ set -eu
83
+ export PATH='${directory}/bin':"$PATH":/usr/sbin:/sbin
84
+ unshare -m sh '${directory}/zygote.sh' &
85
+ child=$!
86
+ trap 'kill "$child" $(cat "${directory}/app.pid" "${directory}/late.pid" 2>/dev/null) 2>/dev/null || true' EXIT
87
+ while [ ! -f '${directory}/app.ready' ]; do sleep 0.01; done
88
+ sh '${directory}/trust.sh' 1234abcd
89
+ for pid in $(cat '${directory}/zygote.pid' '${directory}/app.pid' '${directory}/late.pid'); do
90
+ for store in system apex; do
91
+ cmp '${directory}/spectest-ca.crt' "/proc/$pid/root${directory}/$store/1234abcd.1"
92
+ test "$(cat "/proc/$pid/root${directory}/$store/1234abcd.0")" = 'public root with the same subject hash'
93
+ test "$(cat "/proc/$pid/root${directory}/$store/deadbeef.0")" = 'another public root'
94
+ done
95
+ done
96
+ before=$(wc -l < /proc/$child/mountinfo)
97
+ sh '${directory}/trust.sh' 1234abcd
98
+ after=$(wc -l < /proc/$child/mountinfo)
99
+ test "$before" = "$after"
100
+ test ! -e '${directory}/spectest-system-cacerts/1234abcd.2'
101
+ # A populated shell trust store is insufficient without a usable zygote.
102
+ : > '${directory}/zygote.pid'
103
+ if sh '${directory}/trust.sh' 1234abcd; then exit 1; fi
104
+ `);
105
+ try {
106
+ const child = Bun.spawn(["timeout", "10s", "unshare", "-Urnm", "sh", join(directory, "verify.sh")], { stdout: "pipe", stderr: "pipe" });
107
+ const [stdout, stderr, status] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
108
+ expect({ status, stderr: stderr.replace("Android TLS: no zygote process found\n", "") }).toEqual({ status: 0, stderr: "" });
109
+ expect(stdout.match(/spectest-android-trust-ready/g)?.length).toBe(2);
110
+ } finally {
111
+ rmSync(directory, { recursive: true, force: true });
112
+ }
113
+ }, 20_000);
@@ -42,6 +42,7 @@ test("Android expands into two VM services with an APK input and private Appium/
42
42
  expect(tools.env?.ANDROID_SERIAL).toBe("phone-device.internal:5555");
43
43
  expect(tools.env?.ANDROID_ACTIVITY).toBe("com.example.app/.MainActivity");
44
44
  expect(tools.env?.ANDROID_SHOW_TOUCHES).toBe("1");
45
+ expect(tools.env?.ANDROID_CA_PATH).toBe(process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt");
45
46
  expect(tools.inputs).toContain("app.apk");
46
47
  expect(tools.volumes).toContainEqual({
47
48
  source: apk,
@@ -50,6 +51,7 @@ test("Android expands into two VM services with an APK input and private Appium/
50
51
  });
51
52
  expect(tools.readyCheck).toMatchObject({ type: "http", port: 4725 });
52
53
  expect(tools.files?.[0].content).toContain("--no-window");
54
+ expect(tools.files?.map((file: { path: string }) => file.path)).toContain("/spectest-android-trust.mjs");
53
55
  } finally {
54
56
  rmSync(dir, { recursive: true, force: true });
55
57
  }
@@ -3,6 +3,7 @@ import { posix } from "node:path";
3
3
  import { serviceGroup } from "../index.js";
4
4
  import { resolveExistingProjectPath } from "../project-files.js";
5
5
  import { androidApp, type AndroidApp, type AndroidRecordingOptions } from "../android.js";
6
+ import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
6
7
 
7
8
  export interface AndroidOptions {
8
9
  /** Project-relative path to an APK built for the worker's native ABI. */
@@ -26,7 +27,7 @@ export interface AndroidOptions {
26
27
  }
27
28
 
28
29
  export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
29
- RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
30
+ RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
30
31
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
31
32
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
32
33
  RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
@@ -91,6 +92,10 @@ export function android(options: AndroidOptions) {
91
92
  path: "/spectest-android.mjs",
92
93
  content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
93
94
  },
95
+ {
96
+ path: "/spectest-android-trust.mjs",
97
+ content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
98
+ },
94
99
  ],
95
100
  volumes: [
96
101
  {
@@ -102,6 +107,7 @@ export function android(options: AndroidOptions) {
102
107
  inputs: [apk],
103
108
  env: {
104
109
  ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
110
+ ANDROID_CA_PATH: ENVIRONMENT_CA_PATH,
105
111
  ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
106
112
  ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
107
113
  },