OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android commits / fdd64d2

fix the three loose ends: cover the boot path, stop redundant downloads, pin the policy scope
1. THE BOOT DECISION IS NOW TESTED. worklet.js was the only uncovered file in the repo and it is the one that decides what EXECUTES — on a device with no adb and no UI left if the node cannot boot. It was uncovered because it imports react-native-bare-kit and react-native-fs at module scope and cannot load under bare. host/boot-plan.js now holds the decision with every filesystem touch injected, and worklet.js is a faithful adapter that calls it — extraction without that would be duplication, which is worse than no coverage. Nine tests over the tree that matters: the watchdog arming BEFORE a risky boot, a survived pending marker condemned before anything starts, a refusal said out loud and NOT arming the watchdog (we declined; nothing was tried), the loader falling back when the asset is absent, a corrupt OTA directory never stopping the app, the size-stamp copy cache, and the handoff carrying the APK addon list. 2. ADDONS THE APK ALREADY HAS ARE NO LONGER DOWNLOADED. Measured: the bundle demands 58 and the installed APK carries 28 of them, so fetching all cost 39.7 MB where 27.8 MB is needed. The store learns the APK's list through the shell handoff — only the shell can answer it, since jniLibs are mapped out of the APK and never unpacked — and skips BEFORE fetchContent, which is where the saving is. ⚠ No list means skip nothing. An older shell sends none, and a soname wrongly assumed present is a dlopen failure with no way back in: absence of knowledge must cost bandwidth, never correctness. The marker records only what was written, so hasTree still means the whole tree. 3. THE SELINUX FINDING NOW HAS A CHECKED SCOPE rather than a caveat. The image's vendor_sepolicy.cil and plat_pub_versioned.cil contain ZERO rules mentioning untrusted_app or app_data_file execute, so the grant is purely platform policy. SELinux has no revoke — a vendor can only add allows, or assert a neverallow, which is compile-time and would fail the build against the platform allow, and CTS enforces the platform neverallow set. So a compliant Android 14 device cannot be more restrictive here than the image measured. Recorded in memory. Suite 93/93, 310/310; both bundles pack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fdd64d2c1c6ffa4b7e6998b954de339f5856fe98
by plankatron <fleeky@gmail.com> · 2026-09-11T01:09:55.000Z

Changed files (7) +367 -149

lib/host/boot-plan.js +125 -0
……@@ -0,0 +1,125 @@
1// What the shell decides before it starts the runtime: which bundle runs, with which addons, and what
2// the node is told.
3//
4// ⛔ EXTRACTED SO IT CAN BE TESTED. This is the code path that decides what EXECUTES on a device with no
5// adb and no UI left if the node cannot boot — and it was the one file with no coverage, because
6// worklet.js imports react-native-bare-kit and react-native-fs at module scope and cannot load under
7// bare. Every filesystem touch is injected, so the whole decision tree runs in the suite: the watchdog
8// arming before a risky boot, a condemnation recorded before anything starts, the loader falling back,
9// and a refusal being said out loud.
10//
11// worklet.js keeps only the RN glue — RNFS calls and `worklet.start` — and hands the plan straight to
12// bare-kit.
13import { chooseBundle } from './choose-bundle.js'
14import { encodeShell, SHELL_FILE } from './shell-handoff.js'
15
16/**
17 * @param {object} opts
18 * @param {string} opts.storage the app-private directory
19 * @param {object} opts.io injected filesystem + logging
20 * @returns {Promise<{entryPath, startArgs, bundleHash, addonsDir, reason}>}
21 */
22export async function planBoot ({ storage, io }) {
23 const bdir = storage + '/bundles'
24 const dest = storage + '/worklet.bundle'
25
26 // ⛔ THE BUNDLE MUST NOT PASS THROUGH THE JS HEAP — see worklet.js. The size stamp is written by
27 // prebuild, so a change is known at build time and nothing hashes 32 MB on a phone at every launch.
28 const wantSize = Number((await io.readAsset('worklet.bundle.size')).trim())
29 let assetBuiltAt = null
30 try { assetBuiltAt = Number((await io.readAsset('worklet.bundle.built')).trim()) || null } catch {}
31 let haveSize = -1
32 try { haveSize = await io.size(dest) } catch { /* first run */ }
33 if (haveSize !== wantSize) {
34 try { await io.unlink(dest) } catch {}
35 await io.copyAsset('worklet.bundle', dest)
36 }
37
38 // The sonames THIS APK ships. Nothing at runtime can enumerate jniLibs — they are mapped out of the
39 // APK, never unpacked — so the list is written at build time by scripts/stage-addons.mjs. Absent on an
40 // older APK, which chooseBundle reads as "not known" and proceeds on.
41 let apkAddons = null
42 try { apkAddons = JSON.parse(await io.readAsset('apk-addons.json')) } catch {}
43
44 let bundlePath = dest
45 let bundleHash = null
46 let addonsDir = null
47 let reason = 'apk bundle'
48
49 try {
50 const failed = (await io.readJson(bdir + '/failed.json')) || []
51 const choice = chooseBundle({
52 current: await io.readJson(bdir + '/current.json'),
53 pending: await io.readJson(bdir + '/pending.json'),
54 failed,
55 assetBuiltAt,
56 apkAddons
57 })
58 reason = choice.reason
59
60 if (choice.condemn) {
61 // ⛔ BEFORE ANYTHING STARTS. Last boot began this bundle and never reached ready; if it were not
62 // recorded now, every boot would pick it, die the same way, and the phone would be unrecoverable
63 // from outside.
64 io.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned')
65 if (!failed.includes(choice.condemn)) failed.push(choice.condemn)
66 await io.writeText(bdir + '/failed.json', JSON.stringify(failed))
67 try { await io.unlink(bdir + '/pending.json') } catch {}
68 }
69
70 // ⛔ SAY A REFUSAL OUT LOUD. Otherwise it is invisible: the app keeps running the old bundle, which
71 // is exactly what a working install looks like.
72 if (choice.use === 'asset' && choice.reason.includes('needs ')) {
73 io.log('[worklet] REFUSED the staged bundle: ' + choice.reason)
74 }
75
76 if (choice.use === 'staged' && await io.exists(bdir + '/' + choice.file)) {
77 // The watchdog arms itself here, before the risky boot and never after it.
78 await io.writeText(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }))
79 bundlePath = bdir + '/' + choice.file
80 bundleHash = choice.hash
81 if (choice.addons) addonsDir = bdir + '/' + choice.addons
82 io.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
83 }
84 } catch (e) {
85 // A broken OTA directory must never stop the app starting: the APK's bundle is always there.
86 io.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message)
87 bundlePath = dest
88 bundleHash = null
89 addonsDir = null
90 }
91
92 // ── THE ENTRY ─────────────────────────────────────────────────────────────────────────────────────
93 // lib/bootstrap.js inside the bundle already registers staged addons and can find them unaided, so
94 // this loader is belt and braces: it is the only thing that can help when bare-fs, bare-path or
95 // bare-url THEMSELVES changed, which the in-bundle bootstrap cannot bootstrap around.
96 //
97 // ⚠ FALLING BACK IS SAFE. Starting the bundle directly is what this app did before the loader existed.
98 let entryPath = bundlePath
99 let startArgs = [storage]
100 try {
101 const loaderDest = storage + '/loader.bundle'
102 await io.copyAsset('loader.bundle', loaderDest)
103 const args = ['--bundle=' + bundlePath]
104 if (addonsDir) args.push('--addons=' + addonsDir)
105 // ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle
106 // staged before the loader existed.
107 args.push(storage)
108 entryPath = loaderDest
109 startArgs = args
110 } catch (e) {
111 io.log('[worklet] no loader asset, starting the bundle directly: ' + e.message)
112 }
113
114 // What this side knows and the node cannot. Written before every start, never appended to.
115 const dns = await io.dnsServers()
116 try {
117 await io.writeText(storage + '/' + SHELL_FILE, encodeShell({
118 ota: true, pair: true, bundle: bundleHash, dns, apkAddons: apkAddons || []
119 }))
120 } catch (e) {
121 io.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
122 }
123
124 return { entryPath, startArgs, bundleHash, addonsDir, reason }
125}
lib/host/ota.js +15 -3
……@@ -251,7 +251,16 @@ export function bundleFetcher ({ base = DEFAULT_BASE, repo = DEFAULT_REPO, doFet
251251 * Android — which is what made a 269-dep closure balloon to ~5.7 GB on celltron. One file, written
252252 * directly, is the whole of what a bundle needs.
253253 */
254export function bundleStore ({ dir }) {
254export function bundleStore ({ dir, apkAddons = null }) {
255 // ⛔ WHAT THE APK ALREADY PROVIDES IS NOT WORTH DOWNLOADING. Measured on the real bundle: it demands
256 // 58 addons and the installed APK carries 28 of them, so fetching the lot costs 39.7 MB where 27.8 MB
257 // is needed. A soname the APK provides resolves by dlopen anyway, so the bytes would buy a
258 // byte-identical library.
259 //
260 // ⚠ NULL MEANS SKIP NOTHING. An older shell sends no list, and a soname wrongly assumed present is a
261 // dlopen failure on a device with no way back in. Absence of knowledge must cost bandwidth, never
262 // correctness.
263 const apkHas = Array.isArray(apkAddons) && apkAddons.length > 0 ? new Set(apkAddons) : null
255264 const markerPath = (treeHash) => path.join(dir, treeHash + '.json')
256265 // ⛔ NAMED OFF THE BUNDLE FILE, NOT THE TREE HASH. The staged bundle derives this itself from
257266 // import.meta.url (host/loader-core.js addonsDirFor), which is what lets the preloader ship as an
……@@ -373,6 +382,9 @@ export function bundleStore ({ dir }) {
373382 const sub = await fetchTree(addonDirEntry.childHash)
374383 for (const e of (sub || [])) {
375384 if (!e || e.kind !== KIND_FILE) continue
385 // Skipped BEFORE fetchContent, which is where the saving is — the old loop downloaded every
386 // entry and only then decided what to keep.
387 if (apkHas && apkHas.has(e.name)) continue
376388 const b = await fetchContent(e.childHash)
377389 const d = path.join(adir, e.name)
378390 const pt = d + '.part'
……@@ -503,7 +515,7 @@ export async function swarmFetcher ({ registryKeyHex = DEFAULT_REGISTRY, repo =
503515 *
504516 * Never throws: the caller boots a node either way.
505517 */
506export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
518export async function startOta ({ storage, apkAddons = null, say = () => {}, repo = DEFAULT_REPO, interval = DEFAULT_INTERVAL, currentTreeHash = null, registryKeyHex = DEFAULT_REGISTRY, swarm = null, fetcher: injectedFetcher = null } = {}) {
507519 let PghRuntime = null
508520 try {
509521 ;({ PghRuntime } = await import('pgh-runtime'))
……@@ -513,7 +525,7 @@ export async function startOta ({ storage, say = () => {}, repo = DEFAULT_REPO,
513525 }
514526
515527 const dir = path.join(storage, 'bundles')
516 const store = bundleStore({ dir })
528 const store = bundleStore({ dir, apkAddons })
517529 store.init()
518530
519531 // THE FETCHER IS INJECTED, exactly as pgh-runtime injects one into PghRuntime — one transport seam, in
lib/host/shell-handoff.js +9 -4
……@@ -56,7 +56,7 @@ const isAddr = (v) => typeof v === 'string' && v.length > 0 && v.length <= 45 &&
5656export function parseShell (text) {
5757 let raw = null
5858 try { raw = typeof text === 'string' ? JSON.parse(text) : text } catch { raw = null }
59 if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [] }
59 if (!raw || typeof raw !== 'object') return { ota: false, pair: false, bundle: null, dns: [], apkAddons: [] }
6060 return {
6161 ota: raw.ota === true,
6262 // A SEPARATE capability from `ota`, deliberately. The page arrives by OTA and this shell by APK, so
……@@ -64,17 +64,22 @@ export function parseShell (text) {
6464 // actually running — a page must never render a control the shell beneath it cannot honour.
6565 pair: raw.pair === true,
6666 bundle: isHash(raw.bundle) ? String(raw.bundle) : null,
67 dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : []
67 dns: Array.isArray(raw.dns) ? raw.dns.filter(isAddr).map(String) : [],
68 // The sonames THIS APK ships, so the OTA store can skip downloading addons that are already on the
69 // device. Only the shell can answer it — jniLibs are mapped out of the APK, never unpacked — and an
70 // older shell sends nothing, which the store reads as "skip nothing".
71 apkAddons: Array.isArray(raw.apkAddons) ? raw.apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : []
6872 }
6973}
7074
7175/** The shell's side of the same shape. Written before every worklet start, never appended to. */
72export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [] } = {}) {
76export function encodeShell ({ ota = false, pair = false, bundle = null, dns = [], apkAddons = [] } = {}) {
7377 return JSON.stringify({
7478 ota: ota === true,
7579 pair: pair === true,
7680 bundle: isHash(bundle) ? String(bundle) : null,
77 dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : []
81 dns: Array.isArray(dns) ? dns.filter(isAddr).map(String) : [],
82 apkAddons: Array.isArray(apkAddons) ? apkAddons.filter((a) => typeof a === 'string' && a.endsWith('.so')) : []
7883 })
7984}
8085
lib/host/worklet.js +28 -142
……@@ -10,9 +10,8 @@ import RNFS from 'react-native-fs'
1010import { AppState, DeviceEventEmitter, NativeModules, PermissionsAndroid, Platform } from 'react-native'
1111import { WorkletClient } from './ipc-client'
1212import { createLifecycle } from './lifecycle'
13import { chooseBundle } from './choose-bundle'
13import { planBoot } from './boot-plan'
1414import { encodeUtf8, createUtf8Decoder } from './utf8'
15import { encodeShell, SHELL_FILE } from './shell-handoff'
1615
1716let started = null
1817let starting = null
……@@ -103,151 +102,38 @@ async function boot () {
103102 //
104103 // Copied once and re-copied only when the size stamp changes (written by prebuild): hashing 35 MB on
105104 // a phone at every launch to detect a change we already know about at build time would be silly.
106 const t0 = Date.now()
107 const dest = RNFS.DocumentDirectoryPath + '/worklet.bundle'
108 const wantSize = Number((await RNFS.readFileAssets('worklet.bundle.size', 'utf8')).trim())
109 // When the bundle inside THIS APK was built. chooseBundle compares it with the staged bundle's release
110 // time so that installing a newer APK is not silently overridden by JS staged before it. Absent on an
111 // APK built before the stamp existed, which chooseBundle reads as "no ordering available".
112 let assetBuiltAt = null
113 try { assetBuiltAt = Number((await RNFS.readFileAssets('worklet.bundle.built', 'utf8')).trim()) || null } catch {}
114 let haveSize = -1
115 try { haveSize = (await RNFS.stat(dest)).size } catch { /* first run */ }
116 if (haveSize !== wantSize) {
117 try { await RNFS.unlink(dest) } catch {}
118 await RNFS.copyFileAssets('worklet.bundle', dest)
119 }
120 // ── WHICH BUNDLE BOOTS ────────────────────────────────────────────────────────────────────────────
121 // OTA stages new worklet bundles into <storage>/bundles (lib/ota.js, inside the worklet). This side
122 // decides whether to RUN one — and it is the decision that can brick a handset, because there is no
123 // adb on a user's phone and no UI left if the node cannot boot.
105 // ⛔ THE DECISION LIVES IN host/boot-plan.js, WHERE IT CAN BE TESTED. This is the path that decides
106 // what EXECUTES on a device with no adb and no UI left if the node cannot boot, and it was the one
107 // uncovered file in the repo — uncovered because this module imports react-native-bare-kit and
108 // react-native-fs at module scope and so cannot load under bare. Everything below is now RN glue:
109 // the filesystem surface planBoot needs, injected.
124110 //
125 // The watchdog: record the attempt BEFORE starting, clear it once the node reports ready, and treat a
126 // marker that survived a boot as proof that bundle does not work. chooseBundle is pure and tested
127 // (test/ota.test.mjs); everything here is the file I/O it asks for, and every failure falls back to the
128 // APK's own bundle, which is the one known to work because it shipped with this binary.
129 let bundlePath = dest
130 let bundleHashChosen = null
131 let stagedAddonsDir = null
132 try {
133 const bdir = RNFS.DocumentDirectoryPath + '/bundles'
134 const readJson = async (p) => { try { return JSON.parse(await RNFS.readFile(p, 'utf8')) } catch { return null } }
135 const failed = (await readJson(bdir + '/failed.json')) || []
136 // What THIS APK's jniLibs can satisfy, written at build time by scripts/stage-addons.mjs. Nothing
137 // at runtime can enumerate them — they are mapped out of the APK, never unpacked — so the list has
138 // to ship as an asset. Absent (an older APK, or a build where readelf was unavailable) reads as
139 // "not known", and chooseBundle then proceeds exactly as it did before the guard existed.
140 let apkAddons = null
141 try { apkAddons = JSON.parse(await RNFS.readFileAssets('apk-addons.json', 'utf8')) } catch {}
142
143 const choice = chooseBundle({
144 current: await readJson(bdir + '/current.json'),
145 pending: await readJson(bdir + '/pending.json'),
146 failed,
147 assetBuiltAt,
148 apkAddons
149 })
150
151 if (choice.condemn) {
152 // Last boot started this bundle and never reached ready. Remember it so it is never chosen again —
153 // otherwise every boot picks it, dies the same way, and the phone is unrecoverable from outside.
154 console.log('[worklet] bundle ' + choice.condemn.slice(0, 12) + '… failed to boot last time — condemned')
155 if (!failed.includes(choice.condemn)) failed.push(choice.condemn)
156 await RNFS.writeFile(bdir + '/failed.json', JSON.stringify(failed), 'utf8')
157 try { await RNFS.unlink(bdir + '/pending.json') } catch {}
158 }
159
160 // ⛔ SAY IT OUT LOUD. A refused update is invisible otherwise — the app just keeps running the old
161 // bundle, which is exactly what a working install looks like. The reason names the missing soname.
162 if (choice.use === 'asset' && choice.reason.includes('needs ')) {
163 console.log('[worklet] REFUSED the staged bundle: ' + choice.reason)
164 }
165
166 if (choice.use === 'staged' && await RNFS.exists(bdir + '/' + choice.file)) {
167 await RNFS.writeFile(bdir + '/pending.json', JSON.stringify({ treeHash: choice.hash }), 'utf8')
168 bundlePath = bdir + '/' + choice.file
169 bundleHashChosen = choice.hash
170 // chooseBundle owns this name — it is the one module both sides import, and the store that writes
171 // the directory lives on the other side of a bridge with no shared filesystem API.
172 if (choice.addons) stagedAddonsDir = bdir + '/' + choice.addons
173 console.log('[worklet] booting STAGED bundle ' + choice.hash.slice(0, 12) + '… (' + choice.reason + ')')
111 // ⚠ Keep this a FAITHFUL adapter. A behaviour implemented here rather than there is a behaviour with
112 // no test, which is exactly the state this replaced.
113 const t0 = Date.now()
114 const plan = await planBoot({
115 storage,
116 io: {
117 readAsset: (name) => RNFS.readFileAssets(name, 'utf8'),
118 size: async (path) => (await RNFS.stat(path)).size,
119 exists: (path) => RNFS.exists(path),
120 readJson: async (path) => { try { return JSON.parse(await RNFS.readFile(path, 'utf8')) } catch { return null } },
121 writeText: (path, text) => RNFS.writeFile(path, text, 'utf8'),
122 copyAsset: (name, dest) => RNFS.copyFileAssets(name, dest),
123 unlink: (path) => RNFS.unlink(path),
124 dnsServers,
125 log: (m) => console.log(m)
174126 }
175 } catch (e) {
176 // A broken OTA directory must never stop the app starting: the APK's bundle is always there.
177 console.log('[worklet] bundle choice failed, using the APK bundle: ' + e.message)
178 bundlePath = dest
179 }
180
181 const source = null
127 })
128 const { entryPath, startArgs } = plan
129 const bundleHash = plan.bundleHash
182130 // The worklet has NO error or exit event (react-native-bare-kit/index.js has neither), so a failure
183 // inside it is silent from here. These lines are the only evidence of what was actually handed over.
184 console.log('[worklet] bundle ' + wantSize + ' bytes at ' + dest +
185 (haveSize === wantSize ? ' (cached)' : ' (copied)') + ' in ' + (Date.now() - t0) + 'ms')
186
187 // ── WHAT THIS SIDE KNOWS AND THE NODE CANNOT ─────────────────────────────────────────────────────
188 // Three facts, written where the node will look for them (lib/shell-handoff.js explains why a file and
189 // not argv — a positional argument would be read as the storage path by any older bundle):
190 //
191 // bundle WHICH bundle is executing. The OTA store knows what is STAGED; only this side knows what
192 // was started, and without it every boot "staged" the bundle it was already running.
193 // dns the OS's resolvers for the active network. Android has no /etc/resolv.conf, so without
194 // this the node turns DNS TXT handle verification off and most handles render invalid.
195 // ota that THIS shell can service the settings page's update buttons. The shell ships in the
196 // APK and the bundle arrives by OTA, so a new bundle can find itself inside an old shell;
197 // the page renders those controls only when this says they will do something.
198 // pair the same claim for the pairing screen, which moved out of a floating overlay and into
199 // the settings page — separate flag because the two arrived in different releases.
200 //
201 // Written before every start and never appended to, so it always describes the CURRENT boot. Failure
202 // is non-fatal: the node boots with less knowledge, not not at all.
203 const bundleHash = bundlePath === dest ? null : (bundleHashChosen || null)
204 try {
205 const dns = await dnsServers()
206 await RNFS.writeFile(storage + '/' + SHELL_FILE, encodeShell({ ota: true, pair: true, bundle: bundleHash, dns }), 'utf8')
207 console.log('[worklet] shell handoff: bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') + ' dns=' + dns.length)
208 } catch (e) {
209 console.log('[worklet] could not write the shell handoff (the node boots without it): ' + e.message)
210 }
211
212 // ── THE LOADER ────────────────────────────────────────────────────────────────────────────────────
213 // A `linked:` bundle NAMES the sonames the APK must supply, so a staged bundle demanding one this APK
214 // lacks could only ever die at dlopen. lib/loader.js registers OTA-staged addons in Bare's Addon.cache
215 // under exactly those names — a cache hit short-circuits dlopen-by-soname — and then loads the chosen
216 // bundle. Anything not staged still falls through to the APK, so staging is additive.
217 //
218 // ⛔ THE LOADER CANNOT BE PART OF THE STAGED BUNDLE. Bare loads whatever file is passed here as its own
219 // entry, so there is no "before" inside it; and preload code inside the staged bundle would import
220 // bare-fs as `linked:libbare-fs.<staged version>.so`, the very soname that might be missing.
221 //
222 // ⚠ FALLING BACK TO THE OLD SHAPE IS SAFE, and is why every failure below is caught. Starting the
223 // bundle directly is exactly what this app did before the loader existed: a bundle whose addons the
224 // APK already satisfies — every release published so far — runs identically. And one that NEEDS staged
225 // addons fails at dlopen, never reaches ready, and is condemned by the watchdog on the next boot,
226 // which is the correct outcome rather than a silent downgrade.
227 let entryPath = bundlePath
228 let startArgs = [storage]
229 try {
230 const loaderDest = RNFS.DocumentDirectoryPath + '/loader.bundle'
231 // Copied natively and unconditionally: it is a few hundred KB, not the 32 MB worklet bundle, so the
232 // size-stamp cache that exists for that one would be more machinery than it saves. Copying every
233 // boot also means an APK upgrade can never leave a stale loader behind.
234 await RNFS.copyFileAssets('loader.bundle', loaderDest)
235 // ⛔ STORAGE STAYS LAST. lib/node.js reads Bare.argv[Bare.argv.length - 1], and so does every bundle
236 // staged before the loader existed — which is what makes this safe to ship to a device already
237 // holding one. The loader reads its own inputs by TAG, so it does not care where they sit.
238 const args = ['--bundle=' + bundlePath]
239 if (bundleHashChosen && stagedAddonsDir) args.push('--addons=' + stagedAddonsDir)
240 args.push(storage)
241 entryPath = loaderDest
242 startArgs = args
243 console.log('[worklet] entry is the loader' + (stagedAddonsDir ? ' with staged addons ' + stagedAddonsDir : ' (no staged addons)'))
244 } catch (e) {
245 // An APK built before pack:loader existed has no such asset. Not fatal — see above.
246 console.log('[worklet] no loader asset, starting the bundle directly: ' + e.message)
247 }
131 // inside it is silent from here. This line is the only evidence of what was actually handed over.
132 console.log('[worklet] entry=' + entryPath + ' bundle=' + (bundleHash ? bundleHash.slice(0, 12) + '…' : 'apk') +
133 ' addons=' + (plan.addonsDir || 'none') + ' in ' + (Date.now() - t0) + 'ms')
248134
249135 // Path + null source => NativeBareKit.startFile: the runtime reads it, not us.
250 worklet.start(entryPath, source, startArgs)
136 worklet.start(entryPath, null, startArgs)
251137 console.log('[worklet] start() returned')
252138
253139 // ⛔ KEEP RUNNING WITH THE SCREEN OFF. Immediately after start(), because until this returns the node
lib/node.js +4 -0
……@@ -288,6 +288,10 @@ export async function boot (storage) {
288288 // only that nothing happened, which is what several hours of guessing were spent on.
289289 const ota = await startOta({
290290 storage,
291 // What this APK already ships. Only the shell can answer it — jniLibs are mapped out of the APK,
292 // never unpacked — so the store skips downloading addons that are already on the device. An older
293 // shell sends nothing and the store then skips nothing, which costs bandwidth, never correctness.
294 apkAddons: shell.apkAddons,
291295 say: (m) => {
292296 lastOtaNote = String(m)
293297 send({ t: 'log', msg: m })
test/boot-plan.test.js +141 -0
……@@ -0,0 +1,141 @@
1// The shell's boot decision — the code path that decides what EXECUTES on the device.
2//
3// ⛔ THIS WAS THE ONLY UNCOVERED FILE, and it is the one that can brick a handset: there is no adb on a
4// user's phone and no UI left if the node cannot boot. It was uncovered because worklet.js imports
5// react-native-bare-kit and react-native-fs at module scope and cannot load under bare. The decision is
6// now in host/boot-plan.js with every filesystem touch injected, so the whole tree runs here.
7import test from 'brittle'
8import { planBoot } from '../lib/host/boot-plan.js'
9
10const STORAGE = '/data/user/0/app/files'
11const BDIR = STORAGE + '/bundles'
12
13// A fake device: assets from the APK, a filesystem, and a log.
14function device ({ assets = {}, files = {}, sizes = {} } = {}) {
15 const written = {}
16 const unlinked = []
17 const log = []
18 const io = {
19 async readAsset (n) { if (!(n in assets)) throw new Error('no asset ' + n); return assets[n] },
20 async size (p) { if (!(p in sizes)) throw new Error('ENOENT'); return sizes[p] },
21 async exists (p) { return p in files || p in written },
22 async readJson (p) { const v = p in written ? written[p] : files[p]; try { return JSON.parse(v) } catch { return null } },
23 async writeText (p, t) { written[p] = t },
24 async copyAsset (n, d) { if (!(n in assets)) throw new Error('no asset ' + n); written[d] = assets[n] },
25 async unlink (p) { unlinked.push(p) },
26 async dnsServers () { return ['1.1.1.1'] },
27 log: (m) => log.push(m)
28 }
29 return { io, written, unlinked, log }
30}
31
32const BASE = {
33 'worklet.bundle.size': '100',
34 'worklet.bundle.built': '5000',
35 'worklet.bundle': 'APK-BUNDLE-BYTES',
36 'loader.bundle': 'LOADER-BYTES',
37 'apk-addons.json': JSON.stringify(['libbare-fs.4.8.1.so'])
38}
39
40test('with nothing staged the APK bundle runs, through the loader', async (t) => {
41 const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
42 const plan = await planBoot({ storage: STORAGE, io: d.io })
43 t.is(plan.entryPath, STORAGE + '/loader.bundle', 'the loader is the entry')
44 t.alike(plan.startArgs, ['--bundle=' + STORAGE + '/worklet.bundle', STORAGE], 'storage LAST, no --addons')
45 t.is(plan.bundleHash, null, 'nothing staged')
46})
47
48test('the watchdog arms BEFORE a staged bundle starts', async (t) => {
49 // If pending.json were written after start, a bundle that hangs the runtime would never be condemned.
50 const staged = { treeHash: 'a'.repeat(64), file: 'x.bundle', builtAt: 9000, addons: ['libnew.1.0.0.so'], demands: ['libnew.1.0.0.so'] }
51 const d = device({
52 assets: BASE,
53 sizes: { [STORAGE + '/worklet.bundle']: 100 },
54 files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/x.bundle']: 'STAGED' }
55 })
56 const plan = await planBoot({ storage: STORAGE, io: d.io })
57 t.is(plan.bundleHash, 'a'.repeat(64), 'the staged bundle was chosen')
58 t.alike(JSON.parse(d.written[BDIR + '/pending.json']), { treeHash: 'a'.repeat(64) }, 'pending written')
59 t.ok(plan.startArgs.includes('--addons=' + BDIR + '/x.bundle.addons'), 'and its addons passed')
60})
61
62test('a pending marker that survived a boot is condemned before anything starts', async (t) => {
63 const d = device({
64 assets: BASE,
65 sizes: { [STORAGE + '/worklet.bundle']: 100 },
66 files: {
67 [BDIR + '/pending.json']: JSON.stringify({ treeHash: 'b'.repeat(64) }),
68 [BDIR + '/current.json']: JSON.stringify({ treeHash: 'b'.repeat(64), file: 'y.bundle', builtAt: 9000 }),
69 [BDIR + '/y.bundle']: 'STAGED'
70 }
71 })
72 const plan = await planBoot({ storage: STORAGE, io: d.io })
73 t.alike(JSON.parse(d.written[BDIR + '/failed.json']), ['b'.repeat(64)], 'recorded as failed')
74 t.is(plan.bundleHash, null, 'and it is NOT run again')
75 t.ok(d.log.some((m) => m.includes('condemned')), 'and said so')
76})
77
78test('a bundle demanding a soname nobody has is refused, out loud', async (t) => {
79 const staged = {
80 treeHash: 'c'.repeat(64), file: 'z.bundle', builtAt: 9000,
81 addons: [], demands: ['libbare-fs.4.8.1.so', 'libmissing.9.9.9.so']
82 }
83 const d = device({
84 assets: BASE,
85 sizes: { [STORAGE + '/worklet.bundle']: 100 },
86 files: { [BDIR + '/current.json']: JSON.stringify(staged), [BDIR + '/z.bundle']: 'STAGED' }
87 })
88 const plan = await planBoot({ storage: STORAGE, io: d.io })
89 t.is(plan.bundleHash, null, 'the APK bundle runs instead')
90 t.absent(d.written[BDIR + '/pending.json'], 'and the watchdog is NOT armed for a boot we declined')
91 t.ok(d.log.some((m) => m.includes('REFUSED') && m.includes('libmissing.9.9.9.so')), 'named it: ' + d.log.join(' | '))
92})
93
94test('no loader asset falls back to starting the bundle directly', async (t) => {
95 // An APK built before pack:loader existed. The in-bundle bootstrap still registers staged addons, so
96 // this is a fallback in shape only.
97 const assets = { ...BASE }
98 delete assets['loader.bundle']
99 const d = device({ assets, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
100 const plan = await planBoot({ storage: STORAGE, io: d.io })
101 t.is(plan.entryPath, STORAGE + '/worklet.bundle', 'the bundle itself is the entry')
102 t.alike(plan.startArgs, [STORAGE], 'and the old argv shape is used')
103 t.ok(d.log.some((m) => m.includes('no loader asset')))
104})
105
106test('a broken OTA directory never stops the app starting', async (t) => {
107 const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
108 d.io.readJson = async () => { throw new Error('storage is corrupt') }
109 const plan = await planBoot({ storage: STORAGE, io: d.io })
110 t.is(plan.bundleHash, null, 'falls back to the APK bundle')
111 t.ok(d.log.some((m) => m.includes('bundle choice failed')))
112})
113
114test('the bundle asset is re-copied only when its size stamp changes', async (t) => {
115 const cached = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
116 await planBoot({ storage: STORAGE, io: cached.io })
117 t.absent(cached.written[STORAGE + '/worklet.bundle'], 'sizes match — not re-copied')
118
119 const stale = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 99 } })
120 await planBoot({ storage: STORAGE, io: stale.io })
121 t.is(stale.written[STORAGE + '/worklet.bundle'], 'APK-BUNDLE-BYTES', 'size differs — re-copied')
122 t.ok(stale.unlinked.includes(STORAGE + '/worklet.bundle'), 'after unlinking the old one')
123})
124
125test('the shell handoff carries the APK addon list to the node', async (t) => {
126 // How the OTA store learns what it can skip downloading. Only this side can answer it.
127 const d = device({ assets: BASE, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
128 await planBoot({ storage: STORAGE, io: d.io })
129 const shell = JSON.parse(d.written[STORAGE + '/shell.json'])
130 t.alike(shell.apkAddons, ['libbare-fs.4.8.1.so'])
131 t.is(shell.ota, true)
132 t.alike(shell.dns, ['1.1.1.1'])
133})
134
135test('an APK with no addon list still boots, and claims nothing', async (t) => {
136 const assets = { ...BASE }
137 delete assets['apk-addons.json']
138 const d = device({ assets, sizes: { [STORAGE + '/worklet.bundle']: 100 } })
139 await planBoot({ storage: STORAGE, io: d.io })
140 t.alike(JSON.parse(d.written[STORAGE + '/shell.json']).apkAddons, [], 'empty, which the store reads as skip-nothing')
141})
test/ota.test.js +45 -0
……@@ -503,3 +503,48 @@ test('the seam survives the bundle being renamed by content hash', async (t) =>
503503 t.ok(staged.endsWith(hashOf(other) + '.bundle'), 'named by content hash')
504504 t.is(addonsDirFor('file://' + staged + '/x.js'), store.addonsFor(treeHash), 'and the pairing still holds')
505505})
506
507test('addons the APK already provides are NOT downloaded', async (t) => {
508 // ⛔ MEASURED WASTE, NOT A HYPOTHETICAL. The bundle demands 58 addons and the installed APK already
509 // carries 28 of them; fetching all 58 costs 39.7 MB where 27.8 MB is needed. The store learns the
510 // APK's list through the shell handoff, and a soname the APK provides resolves by dlopen anyway —
511 // so downloading it would buy a byte-identical library.
512 const dir = tmpdir('skip-apk')
513 const { entries, treeHash, sub, subHash } = treeWithAddons(BUNDLE, ADDONS)
514 const contents = { [hashOf(BUNDLE)]: BUNDLE }
515 for (const [, bytes] of Object.entries(ADDONS)) contents[hashOf(bytes)] = bytes
516
517 const fetched = []
518 const fetcher = {
519 async getLatestRelease () { return null },
520 async getTree (h) { return ({ entries: { [treeHash]: entries, [subHash]: sub }[h] }) },
521 async getContent (h) { fetched.push(h); return contents[h] }
522 }
523
524 // the APK already has bare-fs; sodium-native is new in this release
525 const store = bundleStore({ dir, apkAddons: ['libbare-fs.4.8.1.so'] })
526 await installTree(store, treeHash, fetcher)
527
528 t.absent(fetched.includes(hashOf(ADDONS['libbare-fs.4.8.1.so'])), 'the APK one was never fetched')
529 t.ok(fetched.includes(hashOf(ADDONS['libsodium-native.5.1.0.so'])), 'the new one was')
530
531 const adir = store.addonsFor(treeHash)
532 t.absent(fs.existsSync(path.join(adir, 'libbare-fs.4.8.1.so')), 'and not written')
533 t.ok(fs.existsSync(path.join(adir, 'libsodium-native.5.1.0.so')), 'the new one is')
534 t.ok(store.hasTree(treeHash), 'and the tree counts as COMPLETE — the marker records what was written')
535})
536
537test('with no APK list the store fetches everything, as before', async (t) => {
538 // An older shell sends no list. Skipping nothing is the only safe default: a soname wrongly assumed
539 // present is a dlopen failure with no way back in.
540 const dir = tmpdir('skip-none')
541 const { entries, treeHash, sub, subHash } = treeWithAddons(BUNDLE, ADDONS)
542 const contents = { [hashOf(BUNDLE)]: BUNDLE }
543 for (const [, bytes] of Object.entries(ADDONS)) contents[hashOf(bytes)] = bytes
544
545 const store = bundleStore({ dir })
546 await installTree(store, treeHash, treeFetcher({ trees: { [treeHash]: entries, [subHash]: sub }, contents }))
547 for (const name of Object.keys(ADDONS)) {
548 t.ok(fs.existsSync(path.join(store.addonsFor(treeHash), name)), name + ' still staged')
549 }
550})