OverviewFilesCommitsIssuesDocsReleasesPeers

celltron-node / app / src / main / java / net / celltron / node / Extractor.kt

110 lines · kt ref:
package net.celltron.node

import android.content.Context
import android.system.Os
import android.util.Log
import java.io.File

/**
 * First-run, idempotent extraction of the runtime host from APK assets into the
 * app's private filesDir. On API 24 the app_data_file domain is exec-permitted,
 * which is the whole premise of running proot no-root.
 *
 * Everything is written in place and never tar-moved afterwards — that is what
 * sidesteps the device-file / CORESTORE breakage the Termux relocation approach
 * hit (a tar-copy of a live rootfs invalidates every rocksdb corestore marker).
 *
 * Assets expected (staged at build time — see README):
 *   assets/proot        static aarch64 proot binary
 *   assets/rootfs.tar   glibc Ubuntu rootfs (minimal base for the harness smoke
 *                       test; later, the full base with runtime libs + the pgh
 *                       bundle baked in). Unpacked on-device with the platform's
 *                       /system/bin/toybox — verified rc=0 with symlinks + perms
 *                       intact — so no static tar binary needs bundling.
 */
object Extractor {
    const val TAG = "celltron-node"

    // Bump when the shipped rootfs/host changes, to force a clean re-extract.
    private const val STAMP = "host.v1"

    fun ensure(ctx: Context): Layout {
        val root = ctx.filesDir
        val layout = Layout(root)
        val stamp = File(root, ".staged-$STAMP")
        if (stamp.exists()) {
            Log.i(TAG, "runtime host already staged ($STAMP)")
            layout.tmp.mkdirs()
            return layout
        }
        Log.i(TAG, "staging runtime host into ${root.absolutePath}")
        layout.tmp.mkdirs()

        // 1. proot -> filesDir, executable.
        val proot = copyAsset(ctx, "proot", layout.proot)
        chmodExec(proot)

        // 2. unpack the rootfs with the platform toybox (present on every
        //    Android). Verified on-device: rc=0, symlinks + perms intact. The
        //    chown-to-0 warnings are harmless — the app can't chown, exactly like
        //    proot -0 fakes root.
        layout.rootfs.mkdirs()
        val rootfsTar = copyAsset(ctx, "rootfs.tar", File(root, "rootfs.tar"))
        val rc = run(
            "/system/bin/toybox", "tar", "-x", "-f", rootfsTar.absolutePath,
            "-C", layout.rootfs.absolutePath
        )
        // toybox tar returns non-zero on no-root Android because it can't
        // chown-to-0 or create hardlinks — both SELinux-denied and both
        // harmless here (proot -0 fakes root; hardlinked aliases like
        // perl5.34.0 are non-essential). Validate by sentinel, not by rc.
        val sentinel = File(layout.rootfs, "usr/bin/env")
        if (!sentinel.exists()) {
            throw RuntimeException("rootfs extraction incomplete (rc=$rc, missing ${sentinel.path})")
        }
        if (rc != 0) {
            Log.w(TAG, "toybox tar rc=$rc (expected no-root chown/hardlink denials) — sentinel present, continuing")
        }
        rootfsTar.delete() // reclaim disk; the asset stays in the APK

        stamp.writeText(System.currentTimeMillis().toString())
        Log.i(TAG, "runtime host staging complete")
        return layout
    }

    private fun copyAsset(ctx: Context, name: String, dest: File): File {
        ctx.assets.open(name).use { input ->
            dest.outputStream().use { out -> input.copyTo(out, 1 shl 16) }
        }
        return dest
    }

    private fun chmodExec(f: File) {
        try {
            Os.chmod(f.absolutePath, 493) // 0755
        } catch (e: Exception) {
            f.setExecutable(true, false)
            Log.w(TAG, "Os.chmod failed for ${f.name}, fell back to setExecutable: ${e.message}")
        }
    }

    private fun run(vararg cmd: String): Int {
        val p = ProcessBuilder(*cmd).redirectErrorStream(true).start()
        // Drain output (required, else the pipe buffer fills and the child
        // blocks) but DON'T mirror it to logcat — toybox emits thousands of
        // harmless chown/hardlink denials that would flood and rotate the buffer.
        var lines = 0
        p.inputStream.bufferedReader().forEachLine { lines++ }
        val rc = p.waitFor()
        Log.i(TAG, "extract: rc=$rc ($lines output lines suppressed)")
        return rc
    }
}

/** On-device paths under the app's private filesDir. */
class Layout(val root: File) {
    val rootfs = File(root, "rootfs")
    val tmp = File(root, "tmp")
    val proot = File(root, "proot")
}