| … | … | @@ -123,6 +123,16 @@ steps: |
| 123 | 123 | # entry it writes as the bundle to boot, so a two-entry tree stages a 154 MB APK as the worklet |
| 124 | 124 | # bundle. The OTA client on a handset is exactly what cannot be fixed remotely. |
| 125 | 125 | npm run gen:apk-tree |
| 126 | # ⛔ VERIFY THE ARTIFACT, NOT THE INTENT. Every check before this point inspects a DECISION — | |
| 127 | # what stage-addons staged, what the keep-list kept, what gradle says it excluded. All three | |
| 128 | # reported success for 0.0.32 and 0.0.33 and produced an APK that crash-looped on its own bundle: | |
| 129 | # a kept library was excluded anyway by a cross-ABI pattern, and the addon that linked against it | |
| 130 | # could not dlopen. bare reports that as ADDON_NOT_FOUND against the addon that was present, so | |
| 131 | # the handset names the wrong library and there is no adb on it to say otherwise. | |
| 132 | # | |
| 133 | # This opens the APK and walks every shipped lib's ELF NEEDED entries. It is the last thing | |
| 134 | # between a build and a phone, and it fails the build rather than the device. | |
| 135 | node scripts/verify-apk-addons.mjs favela-surface-android.apk | |
| 126 | 136 | ls -l favela-surface-android.apk worklet.bundle tree.json apk.tree.json |
| 127 | 137 | timeout: 2700 |
| 128 | 138 | |
| … | … | @@ -179,7 +189,19 @@ steps: |
| 179 | 189 | const buf = fs.readFileSync("worklet.bundle") |
| 180 | 190 | const nl = buf.indexOf(10) |
| 181 | 191 | const len = parseInt(buf.subarray(0, nl).toString(), 10) |
| 182 | const want = JSON.parse(buf.subarray(nl, nl + len).toString().trim()).addons.map(a => a.replace(/^linked:/, "")) | |
| 192 | const demanded = JSON.parse(buf.subarray(nl, nl + len).toString().trim()).addons.map(a => a.replace(/^linked:/, "")) | |
| 193 | // ⛔ THE KEEP-LIST WHEN THERE IS ONE, because the header names only what the JS module graph | |
| 194 | // imports and an addon can LINK against a .so no module imports. These addons carry no RUNPATH, | |
| 195 | // so a DT_NEEDED soname is resolved out of the app namespace — the APK, plus whatever is already | |
| 196 | // loaded. lib/loader.js dlopens the staged set by absolute path before the bundle runs, so a | |
| 197 | // dependency staged here is in the process by soname when its dependent needs it; one that is | |
| 198 | // missing is a dlopen failure the device reports against the wrong library. | |
| 199 | const keepPath = "android/app/addon-keep.json" | |
| 200 | let want = demanded | |
| 201 | if (fs.existsSync(keepPath)) { | |
| 202 | const keep = JSON.parse(fs.readFileSync(keepPath, "utf8"))["arm64-v8a"] | |
| 203 | if (Array.isArray(keep) && keep.length) want = [...new Set([...demanded, ...keep])] | |
| 204 | } | |
| 183 | 205 | const src = "node_modules/react-native-bare-kit/android/src/main/addons/arm64-v8a" |
| 184 | 206 | let n = 0 |
| 185 | 207 | for (const name of want) { |
| … | … | @@ -50,7 +50,9 @@ def computeUndemandedAddons() { |
| 50 | 50 | def excess = [] as Set |
| 51 | 51 | addonsDir.eachDir { abi -> |
| 52 | 52 | abi.eachFileMatch(~/.*\.so/) { so -> |
| 53 | if (!demanded.contains(so.name)) excess << so.name | |
| 53 | // ABI-qualified for the same reason as the keep-list branch above, so both branches hand | |
| 54 | // `jniLibs.excludes` the same shape and it never has to know which one it got. | |
| 55 | if (!demanded.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name) | |
| 54 | 56 | } |
| 55 | 57 | } |
| 56 | 58 | logger.lifecycle("addons: ${demanded.size()} demanded by bundle, ${excess.size()} excluded") |
| … | … | @@ -75,7 +77,16 @@ if (keepFile.exists()) { |
| 75 | 77 | if (addonsDir.exists()) { |
| 76 | 78 | addonsDir.eachDir { abi -> |
| 77 | 79 | def keepSet = (keep[abi.name] ?: []) as Set |
| 78 | abi.eachFileMatch(~/.*\.so/) { so -> if (!keepSet.contains(so.name)) excess << so.name } | |
| 80 | // ⛔ QUALIFIED BY ABI, AND THIS IS THE WHOLE BUG. `excess` used to collect bare FILE NAMES | |
| 81 | // across all four staged ABIs, and the exclude below wrote them as `**/<name>` — a pattern | |
| 82 | // that matches every ABI. The keep-list is computed PER ABI (a library's ELF NEEDED closure | |
| 83 | // differs between them), so a lib arm64 keeps and x86 does not was excluded from arm64 too. | |
| 84 | // | |
| 85 | // Measured on 0.0.33: arm64 kept libbare-os.3.8.7.so and libbare-url.2.4.1.so as native | |
| 86 | // dependencies, the other three ABIs did not, and the APK shipped neither — so | |
| 87 | // libbare-signals.4.2.0.so and libbare-fs.4.7.2.so could not dlopen. The app crash-looped | |
| 88 | // on its own bundle, reporting ADDON_NOT_FOUND against bare-signals, which was present. | |
| 89 | abi.eachFileMatch(~/.*\.so/) { so -> if (!keepSet.contains(so.name)) excess << ("**/" + abi.name + "/" + so.name) } | |
| 79 | 90 | } |
| 80 | 91 | } |
| 81 | 92 | logger.lifecycle("addons: keep-list ${keep.values().collect { it.size() }.max() ?: 0} kept, ${excess.size()} excluded (native closure included)") |
| … | … | @@ -170,7 +181,9 @@ android { |
| 170 | 181 | pickFirst "**/libc++_shared.so" |
| 171 | 182 | |
| 172 | 183 | jniLibs { |
| 173 | excludes += undemandedAddons.collect { "**/" + it } | |
| 184 | // Already full `**/<abi>/<name>` patterns — see the note where they are built. Re-prefixing | |
| 185 | // them here is what produced a cross-ABI exclude. | |
| 186 | excludes += undemandedAddons | |
| 174 | 187 | } |
| 175 | 188 | } |
| 176 | 189 | } |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | 1 | // GENERATED by scripts/gen-version.mjs — do not edit. |
| 2 | export const VERSION = "0.0.24" | |
| 3 | export const BUILT_AT = 1789092276908 | |
| 2 | export const VERSION = "0.0.34" | |
| 3 | export const BUILT_AT = 1789162178833 | |
| 4 | 4 | export default { VERSION, BUILT_AT } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-surface-android", |
| 3 | "version": "0.0.33", | |
| 4 | "description": "favela-surface-android \u2014 a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.", | |
| 3 | "version": "0.0.34", | |
| 4 | "description": "favela-surface-android — a React Native shell over a bare-kit worklet running a full favela-peer node (markdown records, no atproto) with favela-surface rendering its pages in a WebView.", | |
| 5 | 5 | "type": "module", |
| 6 | 6 | "private": true, |
| 7 | 7 | "scripts": { |
| … | … | @@ -0,0 +1,104 @@ |
| 1 | // Verify the BUILT APK, not the intent to build it. | |
| 2 | // | |
| 3 | // ⛔ WHY THIS EXISTS. Every other check in this repo asks whether the right decision was made: | |
| 4 | // stage-addons.mjs asserts the demanded set is staged and writes a keep-list that includes the | |
| 5 | // transitive ELF closure; app/build.gradle excludes what the keep-list omits. Both reported success on | |
| 6 | // 0.0.32 and 0.0.33 — "58/58 demanded staged", "+3 kept as NATIVE dependencies", "61 kept, 10 | |
| 7 | // excluded" — and the APK they produced could not boot: | |
| 8 | // | |
| 9 | // ADDON_NOT_FOUND: Cannot find addon '.' imported from .../hyper-proto-peer/node_modules/bare-signals/binding.js | |
| 10 | // Candidates: | |
| 11 | // - linked:libbare-signals.4.2.0.so | |
| 12 | // | |
| 13 | // The candidate resolved. `libbare-signals.4.2.0.so` was in the APK. It declares | |
| 14 | // `NEEDED libbare-os.3.8.7.so`, that file was NOT in the APK, dlopen failed on the missing dependency, | |
| 15 | // and bare reports a failed load as a failed resolution. The app crash-looped on its own bundle, on a | |
| 16 | // handset, where there is no adb and no UI left to say why. | |
| 17 | // | |
| 18 | // The keep-list HAD libbare-os.3.8.7.so. build.gradle dropped it anyway, because it collected excluded | |
| 19 | // names into one flat set across all four ABIs and excluded them as `**/<name>` — so a library arm64 | |
| 20 | // keeps and x86 does not is excluded from arm64 too. Nothing that inspects a decision could have caught | |
| 21 | // that: the decision was right and the packaging disagreed with it. | |
| 22 | // | |
| 23 | // So this reads the artifact. It is the last step between a build and a phone. | |
| 24 | // | |
| 25 | // ⚠ NO ALLOWLIST OF SYSTEM LIBRARIES, deliberately — one would go stale silently, which is the failure | |
| 26 | // mode this repo keeps designing against. Both rules below are grounded in files that exist: | |
| 27 | // | |
| 28 | // 1. A NEEDED entry naming a file that IS staged in bare-kit's addons dir but is NOT in the APK. | |
| 29 | // That is a packaging error, exactly the one above. libc.so and friends are not in that dir, so | |
| 30 | // they cannot trip it. | |
| 31 | // 2. A NEEDED entry that is in neither, and carries a bare-link version soname (`lib<x>.1.2.3.so`). | |
| 32 | // That is an addon nothing ever staged. No platform library is named that way. | |
| 33 | // | |
| 34 | // Usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a] | |
| 35 | import fs from 'fs' | |
| 36 | import os from 'os' | |
| 37 | import path from 'path' | |
| 38 | import { execFileSync } from 'child_process' | |
| 39 | import { fileURLToPath } from 'url' | |
| 40 | ||
| 41 | const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') | |
| 42 | const apk = process.argv[2] | |
| 43 | const abiArg = process.argv.indexOf('--abi') | |
| 44 | const abi = abiArg === -1 ? 'arm64-v8a' : process.argv[abiArg + 1] | |
| 45 | ||
| 46 | if (!apk || !fs.existsSync(apk)) { | |
| 47 | console.error('usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a]') | |
| 48 | process.exit(1) | |
| 49 | } | |
| 50 | ||
| 51 | // The staging dir bare-kit packages FROM — the population this APK was drawn out of. A name present | |
| 52 | // here and absent from the APK was removed by packaging, which is rule 1. | |
| 53 | const stagedDir = path.join(root, 'node_modules/react-native-bare-kit/android/src/main/addons', abi) | |
| 54 | const staged = fs.existsSync(stagedDir) | |
| 55 | ? new Set(fs.readdirSync(stagedDir).filter((f) => f.endsWith('.so'))) | |
| 56 | : new Set() | |
| 57 | ||
| 58 | const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-apk-addons-')) | |
| 59 | try { | |
| 60 | execFileSync('unzip', ['-o', '-q', apk, `lib/${abi}/*`, '-d', tmp], { stdio: 'inherit' }) | |
| 61 | } catch { | |
| 62 | console.error(`no lib/${abi}/ entries in ${path.basename(apk)} — nothing to verify, which is itself wrong`) | |
| 63 | process.exit(1) | |
| 64 | } | |
| 65 | ||
| 66 | const libDir = path.join(tmp, 'lib', abi) | |
| 67 | if (!fs.existsSync(libDir)) { | |
| 68 | console.error(`no lib/${abi}/ in the APK`) | |
| 69 | process.exit(1) | |
| 70 | } | |
| 71 | const present = new Set(fs.readdirSync(libDir).filter((f) => f.endsWith('.so'))) | |
| 72 | ||
| 73 | // ⛔ readelf ABSENT MUST FAIL, not pass. stage-addons.mjs may keep everything when it cannot compute the | |
| 74 | // closure — a safe direction, because it over-ships. Here the safe direction is the opposite: a check | |
| 75 | // that cannot run has proven nothing, and saying so is the whole point of the file. | |
| 76 | try { execFileSync('readelf', ['--version'], { stdio: 'ignore' }) } catch { | |
| 77 | console.error('readelf unavailable — cannot verify the APK\'s native closure, and an unverified APK is what this step exists to stop') | |
| 78 | process.exit(1) | |
| 79 | } | |
| 80 | ||
| 81 | const VERSIONED = /^lib.+\.\d+\.\d+\.\d+\.so$/ | |
| 82 | ||
| 83 | const broken = [] | |
| 84 | for (const so of [...present].sort()) { | |
| 85 | const out = execFileSync('readelf', ['-d', path.join(libDir, so)], { encoding: 'utf8' }) | |
| 86 | for (const m of out.matchAll(/NEEDED\).*?\[([^\]]+)\]/g)) { | |
| 87 | const dep = m[1] | |
| 88 | if (present.has(dep)) continue | |
| 89 | if (staged.has(dep)) broken.push({ so, dep, why: 'staged for this build but excluded from the APK' }) | |
| 90 | else if (VERSIONED.test(dep)) broken.push({ so, dep, why: 'never staged — no prebuild for it anywhere' }) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | console.log(`verify-apk-addons: ${present.size} native libs in lib/${abi}/, ${staged.size} staged`) | |
| 95 | ||
| 96 | if (broken.length) { | |
| 97 | console.error('\nUNSATISFIED NEEDED — these WILL fail to dlopen on a device:') | |
| 98 | for (const b of broken) console.error(` ${b.so} NEEDS ${b.dep} — ${b.why}`) | |
| 99 | console.error('\nbare reports a failed dlopen as ADDON_NOT_FOUND on the addon that pulled it in, so the') | |
| 100 | console.error('symptom on the handset is a crash loop naming the WRONG library. Do not ship this APK.') | |
| 101 | process.exit(1) | |
| 102 | } | |
| 103 | ||
| 104 | console.log(' every NEEDED entry is satisfied inside the APK') |
| … | … | @@ -0,0 +1,67 @@ |
| 1 | // The APK must ship every library the libraries it ships link against. | |
| 2 | // | |
| 3 | // ⛔ THE BUG THIS PINS. app/build.gradle collected the addons to exclude into ONE FLAT SET OF FILE | |
| 4 | // NAMES across all four staged ABIs, then excluded each as `**/<name>` — a pattern that matches every | |
| 5 | // ABI. The keep-list is computed PER ABI, because a library's ELF NEEDED closure differs between them, | |
| 6 | // so a library arm64 keeps and x86 does not was excluded from arm64 as well. | |
| 7 | // | |
| 8 | // Measured on 0.0.33: the keep-list kept libbare-os.3.8.7.so and libbare-url.2.4.1.so for arm64, the | |
| 9 | // other three ABIs did not keep them, and the APK shipped neither — so libbare-signals.4.2.0.so and | |
| 10 | // libbare-fs.4.7.2.so could not dlopen. The handset crash-looped on its OWN bundle with | |
| 11 | // | |
| 12 | // ADDON_NOT_FOUND: Cannot find addon '.' imported from …/hyper-proto-peer/node_modules/bare-signals/binding.js | |
| 13 | // Candidates: | |
| 14 | // - linked:libbare-signals.4.2.0.so | |
| 15 | // | |
| 16 | // naming a library that was present, because bare reports a failed dlopen as a failed resolution. There | |
| 17 | // is no adb on a user's phone and no UI left when the node cannot boot. | |
| 18 | // | |
| 19 | // ⚠ AND IT WAS INTERMITTENT, which is the real argument for the artifact-level gate in CI. Whether the | |
| 20 | // leak fires depends on the four staging dirs DISAGREEING, and they only disagree when the unshipped | |
| 21 | // ABIs hold stale leftovers — restaging them made all four agree and the same source built a working | |
| 22 | // APK. A build that passes is not evidence the packaging is right. | |
| 23 | // | |
| 24 | // ⚠ SOURCE-LEVEL, for the reason announce.test.js and boundary.test.js give: the defect lives in Groovy | |
| 25 | // that only runs inside a gradle build, and a test that has to assemble a 160 MB APK is not a test | |
| 26 | // anyone runs. scripts/verify-apk-addons.mjs is the behavioural check and it runs in CI on the real | |
| 27 | // artifact; this one pins the shape so the regression cannot return quietly. | |
| 28 | ||
| 29 | import test from 'brittle' | |
| 30 | import fs from 'bare-fs' | |
| 31 | import { join } from 'bare-path' | |
| 32 | ||
| 33 | const ROOT = new URL('..', import.meta.url).pathname | |
| 34 | ||
| 35 | test('jniLibs exclusions are qualified by ABI', (t) => { | |
| 36 | const src = fs.readFileSync(join(ROOT, 'android/app/build.gradle'), 'utf8') | |
| 37 | ||
| 38 | t.absent(/excludes \+= undemandedAddons\.collect \{ "\*\*\/" \+ it \}/.test(src), | |
| 39 | 'the exclude list is not re-prefixed with a bare `**/` — that is what crossed ABIs') | |
| 40 | t.ok(/excludes \+= undemandedAddons\b/.test(src), | |
| 41 | 'it takes the patterns as built') | |
| 42 | ||
| 43 | // Both branches that can populate it — the keep-list one and the header-only fallback — must name the | |
| 44 | // ABI, or the shape reaching `excludes` depends on which ran. | |
| 45 | const qualified = src.match(/excess << \("\*\*\/" \+ abi\.name \+ "\/" \+ so\.name\)/g) || [] | |
| 46 | t.is(qualified.length, 2, | |
| 47 | 'both the keep-list branch and the fallback build `**/<abi>/<name>` patterns') | |
| 48 | t.absent(/excess << so\.name/.test(src), | |
| 49 | 'and neither collects a bare file name, which is shared across every ABI') | |
| 50 | }) | |
| 51 | ||
| 52 | test('the artifact gate exists and refuses to pass when it cannot check', (t) => { | |
| 53 | const p = join(ROOT, 'scripts/verify-apk-addons.mjs') | |
| 54 | t.ok(fs.existsSync(p), 'scripts/verify-apk-addons.mjs is present') | |
| 55 | ||
| 56 | const src = fs.readFileSync(p, 'utf8') | |
| 57 | // ⛔ THE OPPOSITE DIRECTION FROM stage-addons.mjs. That script keeps everything when readelf is | |
| 58 | // missing, because over-shipping is safe. A verifier that cannot read the artifact has proven | |
| 59 | // nothing, and passing would be a false green on the last step before a phone. | |
| 60 | t.ok(/readelf unavailable[\s\S]{0,200}?process\.exit\(1\)/.test(src), | |
| 61 | 'a missing readelf fails the build rather than passing unverified') | |
| 62 | t.ok(/NEEDED\\\)/.test(src), 'it reads ELF NEEDED entries') | |
| 63 | ||
| 64 | const ci = fs.readFileSync(join(ROOT, '.pear-git/ci.yaml'), 'utf8') | |
| 65 | t.ok(/node scripts\/verify-apk-addons\.mjs favela-surface-android\.apk/.test(ci), | |
| 66 | 'and CI runs it on the built APK, which is the only place it can catch this') | |
| 67 | }) |