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")
}