Abrarul8541 commited on
Commit
d365785
·
verified ·
1 Parent(s): 467146a

Phase 4: PRoot rootfs installer (tar.gz extractor) and launcher

Browse files
app/src/main/java/io/github/abrarul/modularai/proot/Proot.kt ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package io.github.abrarul.modularai.proot
2
+
3
+ import android.content.Context
4
+ import android.system.Os
5
+ import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.withContext
7
+ import java.io.File
8
+ import java.io.InputStream
9
+ import java.net.URL
10
+ import java.util.zip.GZIPInputStream
11
+
12
+ /**
13
+ * Since Android 10 an app cannot execute files from its writable storage, so the proot binary and
14
+ * its loader must ship inside the APK as jniLibs/arm64-v8a/libproot.so and libproot-loader.so
15
+ * (see app/src/main/jniLibs/README.md). The root filesystem itself is only read/written, so it
16
+ * lives in filesDir and is downloaded on demand.
17
+ */
18
+ class Proot(private val ctx: Context) {
19
+ private val lib = File(ctx.applicationInfo.nativeLibraryDir)
20
+ private val bin get() = File(lib, "libproot.so")
21
+ private val loader get() = File(lib, "libproot-loader.so")
22
+ val rootfs = File(ctx.filesDir, "rootfs/alpine")
23
+
24
+ val binaryAvailable get() = bin.exists() && loader.exists()
25
+ val installed get() = File(rootfs, "bin/sh").exists() || File(rootfs, "bin/busybox").exists()
26
+
27
+ suspend fun installAlpine(onLog: (String) -> Unit) = withContext(Dispatchers.IO) {
28
+ val url = "https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/aarch64/alpine-minirootfs-3.20.3-aarch64.tar.gz"
29
+ onLog("Downloading $url")
30
+ rootfs.deleteRecursively(); rootfs.mkdirs()
31
+ var n = 0
32
+ URL(url).openStream().use { Tar.extract(it, rootfs) { n++ } }
33
+ File(rootfs, "etc/resolv.conf").apply { parentFile?.mkdirs(); writeText("nameserver 8.8.8.8\nnameserver 1.1.1.1\n") }
34
+ onLog("Extracted $n entries into ${rootfs.path}")
35
+ }
36
+
37
+ fun remove() = rootfs.deleteRecursively()
38
+
39
+ /** Runs [cmd] with /bin/sh inside the rootfs and streams combined output line by line. */
40
+ fun exec(cmd: String, onLine: (String) -> Unit): Int {
41
+ check(binaryAvailable) { "proot binary not bundled in this build (use 'termux ...' instead)" }
42
+ check(installed) { "Rootfs not installed. Run: proot install" }
43
+ val tmp = File(ctx.filesDir, "tmp").apply { mkdirs() }
44
+ val pb = ProcessBuilder(
45
+ bin.path, "-0", "-r", rootfs.path,
46
+ "-b", "/dev", "-b", "/proc", "-b", "/sys",
47
+ "-w", "/root",
48
+ "/usr/bin/env", "-i", "HOME=/root", "TERM=xterm-256color",
49
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
50
+ "/bin/sh", "-c", cmd
51
+ ).redirectErrorStream(true)
52
+ pb.environment()["PROOT_LOADER"] = loader.path
53
+ pb.environment()["PROOT_TMP_DIR"] = tmp.path
54
+ val p = pb.start()
55
+ p.inputStream.bufferedReader().forEachLine(onLine)
56
+ return p.waitFor()
57
+ }
58
+ }
59
+
60
+ /** Minimal ustar/pax/GNU-longname reader for .tar.gz rootfs archives. */
61
+ object Tar {
62
+ fun extract(gz: InputStream, dest: File, onEntry: () -> Unit = {}) {
63
+ val inp = GZIPInputStream(gz.buffered(1 shl 16))
64
+ val h = ByteArray(512)
65
+ var longName: String? = null
66
+ var paxPath: String? = null
67
+ var paxLink: String? = null
68
+ val root = dest.canonicalPath
69
+
70
+ while (readFully(inp, h)) {
71
+ if (h.all { it == 0.toByte() }) break
72
+ var name = str(h, 0, 100)
73
+ val mode = octal(h, 100, 8).toInt()
74
+ val size = octal(h, 124, 12)
75
+ val type = h[156].toInt().toChar()
76
+ var link = str(h, 157, 100)
77
+ val prefix = str(h, 345, 155)
78
+ if (prefix.isNotEmpty()) name = "$prefix/$name"
79
+ val pad = ((size + 511) / 512) * 512 - size
80
+
81
+ when (type) {
82
+ 'L' -> { longName = String(readN(inp, size)).trimEnd('\u0000', '\n'); skip(inp, pad); continue }
83
+ 'x' -> {
84
+ String(readN(inp, size)).split('\n').forEach { l ->
85
+ val kv = l.substringAfter(' ', "")
86
+ when {
87
+ kv.startsWith("path=") -> paxPath = kv.removePrefix("path=")
88
+ kv.startsWith("linkpath=") -> paxLink = kv.removePrefix("linkpath=")
89
+ }
90
+ }
91
+ skip(inp, pad); continue
92
+ }
93
+ 'g' -> { skip(inp, size + pad); continue }
94
+ }
95
+ name = longName ?: paxPath ?: name
96
+ link = paxLink ?: link
97
+ longName = null; paxPath = null; paxLink = null
98
+
99
+ val out = File(dest, name.trimStart('/')).let { File(it.parentFile?.canonicalFile ?: dest, it.name) }
100
+ if (!out.path.startsWith(root)) { skip(inp, size + pad); continue } // path traversal guard
101
+ onEntry()
102
+
103
+ when (type) {
104
+ '5' -> { out.mkdirs(); Os.chmod(out.path, (mode or 448) and 4095) }
105
+ '2' -> { out.parentFile?.mkdirs(); out.delete(); Os.symlink(link, out.path) }
106
+ '1' -> {
107
+ out.parentFile?.mkdirs()
108
+ File(dest, link.trimStart('/')).takeIf { it.exists() }?.copyTo(out, overwrite = true)
109
+ }
110
+ '0', '\u0000' -> {
111
+ out.parentFile?.mkdirs()
112
+ out.outputStream().use { o ->
113
+ var left = size
114
+ val buf = ByteArray(1 shl 16)
115
+ while (left > 0) {
116
+ val n = inp.read(buf, 0, minOf(buf.size.toLong(), left).toInt())
117
+ if (n < 0) error("Truncated archive")
118
+ o.write(buf, 0, n); left -= n
119
+ }
120
+ }
121
+ Os.chmod(out.path, (mode or 384) and 4095)
122
+ skip(inp, pad)
123
+ continue
124
+ }
125
+ else -> { /* device nodes, fifos: skipped */ }
126
+ }
127
+ skip(inp, size + pad)
128
+ }
129
+ }
130
+
131
+ private fun str(b: ByteArray, off: Int, len: Int): String {
132
+ var e = off; while (e < off + len && b[e] != 0.toByte()) e++
133
+ return String(b, off, e - off, Charsets.UTF_8)
134
+ }
135
+ private fun octal(b: ByteArray, off: Int, len: Int): Long {
136
+ if (b[off].toInt() and 0x80 != 0) { // base-256 for huge sizes
137
+ var v = (b[off].toInt() and 0x7f).toLong()
138
+ for (i in 1 until len) v = (v shl 8) or (b[off + i].toLong() and 0xff)
139
+ return v
140
+ }
141
+ val s = str(b, off, len).trim()
142
+ return if (s.isEmpty()) 0 else s.toLong(8)
143
+ }
144
+ private fun readFully(i: InputStream, b: ByteArray): Boolean {
145
+ var n = 0
146
+ while (n < b.size) { val r = i.read(b, n, b.size - n); if (r < 0) return false; n += r }
147
+ return true
148
+ }
149
+ private fun readN(i: InputStream, n: Long): ByteArray = ByteArray(n.toInt()).also { if (n > 0 && !readFully(i, it)) error("Truncated archive") }
150
+ private fun skip(i: InputStream, n: Long) {
151
+ var left = n
152
+ val buf = ByteArray(8192)
153
+ while (left > 0) { val r = i.read(buf, 0, minOf(buf.size.toLong(), left).toInt()); if (r < 0) return; left -= r }
154
+ }
155
+ }