also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
28 files
+1857
-481
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,65 @@ | ||
| 1 | import { afterEach, expect, it } from 'bun:test' | |
| 2 | import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { requireAndroidEmulatorRoot } from '../android-adb' | |
| 6 | ||
| 7 | const roots: string[] = [] | |
| 8 | afterEach(async () => { | |
| 9 | for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) | |
| 10 | }) | |
| 11 | ||
| 12 | async function fixture(rootReply: 'closed' | 'denied' | 'success', uid = '0', reconnect = true) { | |
| 13 | const root = await mkdtemp(join(tmpdir(), 'craft-adb-root-')) | |
| 14 | roots.push(root) | |
| 15 | const adb = join(root, 'adb') | |
| 16 | const log = join(root, 'commands.jsonl') | |
| 17 | await writeFile(adb, `#!/usr/bin/env bun | |
| 18 | import { appendFileSync } from 'node:fs' | |
| 19 | const args = process.argv.slice(2) | |
| 20 | appendFileSync(${JSON.stringify(log)}, JSON.stringify(args)+'\\n') | |
| 21 | if (args[2] === 'root') { | |
| 22 | if (${JSON.stringify(rootReply)} === 'closed') { console.error('adb: unable to connect for root: closed'); process.exit(1) } | |
| 23 | if (${JSON.stringify(rootReply)} === 'denied') { console.error('adbd cannot run as root in production builds'); process.exit(1) } | |
| 24 | console.log('restarting adbd as root') | |
| 25 | } | |
| 26 | if (args[2] === 'wait-for-device' && !${reconnect}) { console.error('transport unavailable'); process.exit(1) } | |
| 27 | if (args[2] === 'shell') console.log(${JSON.stringify(uid)}) | |
| 28 | `) | |
| 29 | await chmod(adb, 0o755) | |
| 30 | return { adb, commands: async () => (await readFile(log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) } | |
| 31 | } | |
| 32 | ||
| 33 | it('reconnects after the root daemon closes the transport, then proves root without repeating the request', async () => { | |
| 34 | const test = await fixture('closed') | |
| 35 | await requireAndroidEmulatorRoot(test.adb, 'emulator-5582') | |
| 36 | expect(await test.commands()).toEqual([ | |
| 37 | ['-s', 'emulator-5582', 'root'], | |
| 38 | ['-s', 'emulator-5582', 'wait-for-device'], | |
| 39 | ['-s', 'emulator-5582', 'shell', 'id', '-u'], | |
| 40 | ]) | |
| 41 | }) | |
| 42 | ||
| 43 | it('requires root after an acknowledged daemon restart too', async () => { | |
| 44 | const test = await fixture('success') | |
| 45 | await requireAndroidEmulatorRoot(test.adb, 'emulator-5582') | |
| 46 | expect((await test.commands()).at(-1)).toEqual(['-s', 'emulator-5582', 'shell', 'id', '-u']) | |
| 47 | }) | |
| 48 | ||
| 49 | it('rejects production images without attempting to reconnect or fault their storage', async () => { | |
| 50 | const test = await fixture('denied') | |
| 51 | await expect(requireAndroidEmulatorRoot(test.adb, 'emulator-5582')).rejects.toThrow('production builds') | |
| 52 | expect((await test.commands()).length).toBe(1) | |
| 53 | }) | |
| 54 | ||
| 55 | it('rejects a reconnected daemon that still runs as the shell user', async () => { | |
| 56 | const test = await fixture('closed', '2000') | |
| 57 | await expect(requireAndroidEmulatorRoot(test.adb, 'emulator-5582')).rejects.toThrow('rootable') | |
| 58 | }) | |
| 59 | ||
| 60 | it('preserves reconnect failures and refuses physical devices before executing ADB', async () => { | |
| 61 | const test = await fixture('closed', '0', false) | |
| 62 | await expect(requireAndroidEmulatorRoot(test.adb, 'emulator-5582')).rejects.toThrow('transport unavailable') | |
| 63 | await expect(requireAndroidEmulatorRoot(test.adb, 'phone')).rejects.toThrow('emulator serial') | |
| 64 | expect((await test.commands()).length).toBe(2) | |
| 65 | }) | |
| @@ -0,0 +1,24 @@ | ||
| 1 | /** Restart adbd once and require a rooted, explicitly selected disposable emulator. */ | |
| 2 | export async function requireAndroidEmulatorRoot(adb: string, serial: string, timeoutMs = 60_000): Promise<void> { | |
| 3 | if (!/^emulator-\d+$/.test(serial)) throw new Error('An explicit disposable Android emulator serial is required') | |
| 4 | const deadline = Date.now() + timeoutMs | |
| 5 | async function run(args: string[]): Promise<{ code: number, output: string }> { | |
| 6 | const remaining = deadline - Date.now() | |
| 7 | if (remaining <= 0) throw new Error('Android root reconnect deadline exceeded') | |
| 8 | const child = Bun.spawn([adb, '-s', serial, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: remaining }) | |
| 9 | const [code, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]) | |
| 10 | return { code, output: `${stdout}\n${stderr}`.trim() } | |
| 11 | } | |
| 12 | const root = await run(['root']) | |
| 13 | // Root changes restart adbd. Some versions close the transport before they | |
| 14 | // acknowledge the request; that response requires reconnection and UID proof. | |
| 15 | if (root.code !== 0 && !/^adb: unable to connect for root: closed$/.test(root.output)) { | |
| 16 | throw new Error(`adb root failed (${root.code}): ${root.output}`) | |
| 17 | } | |
| 18 | const connected = await run(['wait-for-device']) | |
| 19 | if (connected.code !== 0) throw new Error(`Android root reconnect failed (${connected.code}): ${connected.output}`) | |
| 20 | const identity = await run(['shell', 'id', '-u']) | |
| 21 | if (identity.code !== 0 || identity.output !== '0') { | |
| 22 | throw new Error(`Storage fault tests require a rootable disposable AOSP emulator: ${identity.output}`) | |
| 23 | } | |
| 24 | } | |
| @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' | ||
| 3 | 3 | import { mkdir, mkdtemp, rm } from 'node:fs/promises' |
| 4 | 4 | import { homedir, tmpdir } from 'node:os' |
| 5 | 5 | import { isAbsolute, join } from 'node:path' |
| 6 | import { requireAndroidEmulatorRoot } from './android-adb.js' | |
| 6 | 7 | |
| 7 | 8 | export interface MobileMediaCacheTestOptions { |
| 8 | 9 | platform: 'ios' | 'android' |
| @@ -165,9 +166,7 @@ export async function verifyMobileMediaCache(options: MobileMediaCacheTestOption | ||
| 165 | 166 | if (!interrupted && (!result.includes('PASS:') || result.includes('FAIL:'))) throw new Error(result) |
| 166 | 167 | print(result) |
| 167 | 168 | } |
| 168 | await run([adb, '-s', serial, 'root']) | |
| 169 | await run([adb, '-s', serial, 'wait-for-device']) | |
| 170 | if ((await run([adb, '-s', serial, 'shell', 'id', '-u'])).trim() !== '0') throw new Error('Storage fault tests require a rootable disposable AOSP emulator') | |
| 169 | await requireAndroidEmulatorRoot(adb, serial) | |
| 171 | 170 | await run([adb, '-s', serial, 'shell', 'run-as', packageName, 'mkdir', '-p', 'files/craft-media']) |
| 172 | 171 | const cache = `/data/user/0/${packageName}/files/craft-media` |
| 173 | 172 | const owner = (await run([adb, '-s', serial, 'shell', 'stat', '-c', '%u:%g', cache])).trim() |