Skip to content

Commit 3bf4f1b

Browse files
Merge pull request #16 from appdevforall/fix/phase1-security-d12-process-drain
fix(controller): drain process output to avoid deadlock/silent failures (D12)
2 parents 553e5d1 + 5e6eaca commit 3bf4f1b

3 files changed

Lines changed: 107 additions & 10 deletions

File tree

‎controller/app/src/main/java/org/iiab/controller/DeployFragment.java‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import org.iiab.controller.rootfs.presentation.RootfsViewModel;
5959
import org.iiab.controller.rootfs.presentation.RootfsViewModelFactory;
6060
import org.iiab.controller.util.ByteFormatter;
61+
import org.iiab.controller.util.ProcessRunner;
6162

6263
import org.json.JSONObject;
6364

@@ -1086,8 +1087,12 @@ public void onComplete(String destDir) {
10861087
File binDir = new File(requireContext().getFilesDir(), "usr/bin");
10871088
if (binDir.exists()) {
10881089
try {
1089-
Runtime.getRuntime().exec(new String[]{"chmod", "-R", "755", binDir.getAbsolutePath()}).waitFor();
1090-
} catch (Exception ignored) {
1090+
ProcessRunner.Result chmodResult = ProcessRunner.run(new String[]{"chmod", "-R", "755", binDir.getAbsolutePath()});
1091+
if (!chmodResult.isSuccess()) {
1092+
Log.w(TAG, "chmod on usr/bin failed (exit " + chmodResult.exitCode + "): " + chmodResult.output);
1093+
}
1094+
} catch (Exception e) {
1095+
Log.w(TAG, "chmod on usr/bin failed", e);
10911096
}
10921097
}
10931098

@@ -1137,8 +1142,12 @@ public void onError(String error) {
11371142
btnFastInstall.setEnabled(false);
11381143
new Thread(() -> {
11391144
try {
1140-
Runtime.getRuntime().exec(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()}).waitFor();
1141-
} catch (Exception ignored) {
1145+
ProcessRunner.Result wipeResult = ProcessRunner.run(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()});
1146+
if (!wipeResult.isSuccess()) {
1147+
Log.w(TAG, "rm -rf rootfs (reinstall) failed (exit " + wipeResult.exitCode + "): " + wipeResult.output);
1148+
}
1149+
} catch (Exception e) {
1150+
Log.w(TAG, "rm -rf rootfs (reinstall) failed", e);
11421151
}
11431152
mainAct.runOnUiThread(executeDownload);
11441153
}).start();
@@ -1175,8 +1184,10 @@ private void bindDeleteButtonLogic(MainActivity mainAct, File debianRootfs) {
11751184
new Thread(() -> {
11761185
enableSystemProtection();
11771186
try {
1178-
Process p = Runtime.getRuntime().exec(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()});
1179-
p.waitFor();
1187+
ProcessRunner.Result wipeResult = ProcessRunner.run(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()});
1188+
if (!wipeResult.isSuccess()) {
1189+
Log.w(TAG, "rm -rf rootfs (delete) failed (exit " + wipeResult.exitCode + "): " + wipeResult.output);
1190+
}
11801191
} catch (Exception e) {
11811192
mainAct.runOnUiThread(() -> Snackbar.make(getView(), getString(R.string.install_error_delete, e.getMessage()), Snackbar.LENGTH_LONG).show());
11821193
} finally {
@@ -1227,7 +1238,10 @@ private void bindResetButtonLogic(MainActivity mainAct, File debianRootfs) {
12271238
});
12281239

12291240
// 1. WIPE
1230-
Runtime.getRuntime().exec(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()}).waitFor();
1241+
ProcessRunner.Result wipeResult = ProcessRunner.run(new String[]{"rm", "-rf", debianRootfs.getAbsolutePath()});
1242+
if (!wipeResult.isSuccess()) {
1243+
Log.w(TAG, "rm -rf rootfs (vanilla reset) failed (exit " + wipeResult.exitCode + "): " + wipeResult.output);
1244+
}
12311245
debianRootfs.mkdirs();
12321246

12331247
// 2. DOWNLOAD
@@ -1880,8 +1894,13 @@ private void bindBackupButtonLogic(MainActivity mainAct, File backupsDir, File i
18801894
String gzipBin = staticGzip.exists() ? staticGzip.getAbsolutePath() : "gzip";
18811895

18821896
String cmd = tarBin + " -cf - -C " + iiabRootDir.getAbsolutePath() + " installed-rootfs | " + gzipBin + " > " + backupFile.getAbsolutePath();
1883-
Process p = Runtime.getRuntime().exec(new String[]{"/system/bin/sh", "-c", cmd});
1884-
int exitCode = p.waitFor();
1897+
// D12: ProcessRunner drains stderr so a large backup with tar warnings
1898+
// cannot deadlock on a full pipe buffer.
1899+
ProcessRunner.Result backupResult = ProcessRunner.run(new String[]{"/system/bin/sh", "-c", cmd});
1900+
int exitCode = backupResult.exitCode;
1901+
if (exitCode != 0) {
1902+
Log.w(TAG, "Backup pipe failed (exit " + exitCode + "): " + backupResult.output);
1903+
}
18851904

18861905
mainAct.runOnUiThread(() -> {
18871906
if (isBackupInProgress) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* ============================================================================
3+
* Name : ProcessRunner.java
4+
* Author : IIAB Project
5+
* Copyright : Copyright (c) 2026 IIAB Project
6+
* Description : Small helper to run an external process safely: it always
7+
* drains the output so the child cannot deadlock on a full pipe
8+
* buffer, and returns the exit code instead of letting callers
9+
* swallow failures. Addresses tech-debt item D12.
10+
* ============================================================================
11+
*/
12+
package org.iiab.controller.util;
13+
14+
import java.io.BufferedReader;
15+
import java.io.IOException;
16+
import java.io.InputStreamReader;
17+
18+
/**
19+
* Runs an external command and <strong>always reads its output</strong>.
20+
*
21+
* <p>Calling {@code Runtime.exec(...).waitFor()} without reading the child's
22+
* stdout/stderr risks a deadlock: once the OS pipe buffer (~64&nbsp;KB) fills,
23+
* the child blocks writing while the parent blocks in {@code waitFor()} — see
24+
* tech-debt item <b>D12</b>. This helper merges stderr into stdout
25+
* ({@link ProcessBuilder#redirectErrorStream(boolean)}) so a single read drains
26+
* everything, then returns the exit code and captured output so callers can log
27+
* or react to failures instead of ignoring them.
28+
*
29+
* <p>Process I/O only (no {@code android.*}); call it off the main thread.
30+
*/
31+
public final class ProcessRunner {
32+
33+
/** Outcome of a process run: its exit code and combined stdout+stderr. */
34+
public static final class Result {
35+
public final int exitCode;
36+
public final String output;
37+
38+
Result(int exitCode, String output) {
39+
this.exitCode = exitCode;
40+
this.output = output;
41+
}
42+
43+
public boolean isSuccess() {
44+
return exitCode == 0;
45+
}
46+
}
47+
48+
private ProcessRunner() {
49+
// Static utility; not instantiable.
50+
}
51+
52+
/**
53+
* Start {@code command}, drain its merged stdout+stderr to completion, wait
54+
* for it to exit, and return the exit code together with the captured output.
55+
*/
56+
public static Result run(String[] command) throws IOException, InterruptedException {
57+
ProcessBuilder pb = new ProcessBuilder(command);
58+
pb.redirectErrorStream(true); // merge stderr into stdout: one drain can't deadlock
59+
Process process = pb.start();
60+
61+
StringBuilder out = new StringBuilder();
62+
try (BufferedReader reader =
63+
new BufferedReader(new InputStreamReader(process.getInputStream()))) {
64+
String line;
65+
while ((line = reader.readLine()) != null) {
66+
out.append(line).append('\n');
67+
}
68+
}
69+
70+
int exitCode = process.waitFor();
71+
return new Result(exitCode, out.toString());
72+
}
73+
}

‎controller/docs/TECH_DEBT_PLAN.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I
5252
- New pure domain rule `org.iiab.controller.deploy.domain.ModuleName.isAllowed(name, known)`: the name must be a known catalog key (allowlist) AND match `[a-z0-9_-]` (no quotes/`;`/`&&`/`$()`). `ModuleRegistry.validYamlKeys()` is the single allowlist source. Unit-tested (`ModuleNameTest`).
5353
- The guard fails closed: an unrecognized/unsafe module is logged and skipped before the command is built; the command structure is unchanged for legitimate modules. Lower-risk on-device `sh -c` pipes (extract/backup, app-internal paths) are a documented follow-up.
5454

55-
**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4**, **S3** (PR #10), **D6** (PR #12), **D2**. Remaining: **D12**, **S4**, **D11**.
55+
**D12 — Undrained process pipes / swallowed exec errors (Phase 1 security): DONE** (PR `fix/phase1-security-d12-process-drain`)
56+
- Closes **D12**: several `Runtime.exec(...).waitFor()` calls in `DeployFragment` never read the child's output (deadlock risk once the ~64 KB pipe buffer fills — notably the backup `tar | gzip` pipe on a large rootfs) and some swallowed failures in empty `catch (Exception ignored) {}`.
57+
- New shared `util/ProcessRunner.run(cmd)`: `redirectErrorStream(true)` + full drain + returns `{exitCode, output}`, so a single read cannot deadlock and callers can log/handle failures.
58+
- Migrated the raw `exec().waitFor()` sites (backup, `chmod -R`, the three `rm -rf` wipes); empty catches now log. Left the extraction path (already drains stderr) and the `getprop` read (reads stdout) as-is. No new unit test (process glue, not pure logic — fragile to run a shell in unit tests on a Windows dev box); verified by inspection + CI compile.
59+
60+
**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4**, **S3** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12**. Remaining: **D11**, **S4**, **F15**.
5661
## 1. Executive summary
5762

5863
The Controller is functional and shows real security intent (it SHA256-audits native binaries at build time, scrubs the keystore in CI, and scopes most broadcasts). But it carries debt on four fronts that scale badly toward the README's "millions of users" goal:

0 commit comments

Comments
 (0)