OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid / android / scripts / verify-apk-addons.mjs

106 lines · javascript ref:
// Copied from favela-surface-android/scripts/verify-apk-addons.mjs (2026-10-02), unchanged.
// Verify the BUILT APK, not the intent to build it.
//
// ⛔ WHY THIS EXISTS. Every other check in this repo asks whether the right decision was made:
// stage-addons.mjs asserts the demanded set is staged and writes a keep-list that includes the
// transitive ELF closure; app/build.gradle excludes what the keep-list omits. Both reported success on
// 0.0.32 and 0.0.33 — "58/58 demanded staged", "+3 kept as NATIVE dependencies", "61 kept, 10
// excluded" — and the APK they produced could not boot:
//
//   ADDON_NOT_FOUND: Cannot find addon '.' imported from .../hyper-proto-peer/node_modules/bare-signals/binding.js
//   Candidates:
//   - linked:libbare-signals.4.2.0.so
//
// The candidate resolved. `libbare-signals.4.2.0.so` was in the APK. It declares
// `NEEDED libbare-os.3.8.7.so`, that file was NOT in the APK, dlopen failed on the missing dependency,
// and bare reports a failed load as a failed resolution. The app crash-looped on its own bundle, on a
// handset, where there is no adb and no UI left to say why.
//
// The keep-list HAD libbare-os.3.8.7.so. build.gradle dropped it anyway, because it collected excluded
// names into one flat set across all four ABIs and excluded them as `**/<name>` — so a library arm64
// keeps and x86 does not is excluded from arm64 too. Nothing that inspects a decision could have caught
// that: the decision was right and the packaging disagreed with it.
//
// So this reads the artifact. It is the last step between a build and a phone.
//
// ⚠ NO ALLOWLIST OF SYSTEM LIBRARIES, deliberately — one would go stale silently, which is the failure
// mode this repo keeps designing against. Both rules below are grounded in files that exist:
//
//   1. A NEEDED entry naming a file that IS staged in bare-kit's addons dir but is NOT in the APK.
//      That is a packaging error, exactly the one above. libc.so and friends are not in that dir, so
//      they cannot trip it.
//   2. A NEEDED entry that is in neither, and carries a bare-link version soname (`lib<x>.1.2.3.so`).
//      That is an addon nothing ever staged. No platform library is named that way.
//
// Usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a]
import fs from 'fs'
import os from 'os'
import path from 'path'
import { execFileSync } from 'child_process'
import { fileURLToPath } from 'url'

const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const apk = process.argv[2]
const abiArg = process.argv.indexOf('--abi')
const abi = abiArg === -1 ? 'arm64-v8a' : process.argv[abiArg + 1]

if (!apk || !fs.existsSync(apk)) {
  console.error('usage: node scripts/verify-apk-addons.mjs <apk> [--abi arm64-v8a]')
  process.exit(1)
}

// The staging dir bare-kit packages FROM — the population this APK was drawn out of. A name present
// here and absent from the APK was removed by packaging, which is rule 1.
const stagedDir = path.join(root, 'node_modules/react-native-bare-kit/android/src/main/addons', abi)
const staged = fs.existsSync(stagedDir)
  ? new Set(fs.readdirSync(stagedDir).filter((f) => f.endsWith('.so')))
  : new Set()

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-apk-addons-'))
try {
  execFileSync('unzip', ['-o', '-q', apk, `lib/${abi}/*`, '-d', tmp], { stdio: 'inherit' })
} catch {
  console.error(`no lib/${abi}/ entries in ${path.basename(apk)} — nothing to verify, which is itself wrong`)
  process.exit(1)
}

const libDir = path.join(tmp, 'lib', abi)
if (!fs.existsSync(libDir)) {
  console.error(`no lib/${abi}/ in the APK`)
  process.exit(1)
}
const present = new Set(fs.readdirSync(libDir).filter((f) => f.endsWith('.so')))

// ⛔ readelf ABSENT MUST FAIL, not pass. stage-addons.mjs may keep everything when it cannot compute the
// closure — a safe direction, because it over-ships. Here the safe direction is the opposite: a check
// that cannot run has proven nothing, and saying so is the whole point of the file.
try { execFileSync('readelf', ['--version'], { stdio: 'ignore' }) } catch {
  console.error('readelf unavailable — cannot verify the APK\'s native closure, and an unverified APK is what this step exists to stop')
  process.exit(1)
}

const VERSIONED = /^lib.+\.\d+\.\d+\.\d+\.so$/

const broken = []
for (const so of [...present].sort()) {
  const out = execFileSync('readelf', ['-d', path.join(libDir, so)], { encoding: 'utf8' })
  for (const m of out.matchAll(/NEEDED\).*?\[([^\]]+)\]/g)) {
    const dep = m[1]
    if (present.has(dep)) continue
    if (staged.has(dep)) broken.push({ so, dep, why: 'staged for this build but excluded from the APK' })
    else if (VERSIONED.test(dep)) broken.push({ so, dep, why: 'never staged — no prebuild for it anywhere' })
  }
}

console.log(`verify-apk-addons: ${present.size} native libs in lib/${abi}/, ${staged.size} staged`)

if (broken.length) {
  console.error('\nUNSATISFIED NEEDED — these WILL fail to dlopen on a device:')
  for (const b of broken) console.error(`  ${b.so}  NEEDS  ${b.dep}  — ${b.why}`)
  console.error('\nbare reports a failed dlopen as ADDON_NOT_FOUND on the addon that pulled it in, so the')
  console.error('symptom on the handset is a crash loop naming the WRONG library. Do not ship this APK.')
  process.exit(1)
}

console.log('  every NEEDED entry is satisfied inside the APK')