diff --git a/.github/workflows/test-bootstrap.yml b/.github/workflows/test-bootstrap.yml new file mode 100644 index 00000000..4f77e5f7 --- /dev/null +++ b/.github/workflows/test-bootstrap.yml @@ -0,0 +1,43 @@ +name: Host validation + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +permissions: + contents: read + +jobs: + host-validation: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + + - name: Bootstrap filesystem tests + run: sh tests/run.sh + + - name: Java logging tests + run: sh tests/run-java.sh + + - name: Process runner tests + run: sh tests/run-process.sh + + - name: Native safety tests + run: sh tests/run-native-safety.sh + + - name: Log collector tests + run: python3 -m unittest discover -s tests -p 'test_collect_logs.py' diff --git a/.gitignore b/.gitignore index e09ea513..568a3e4b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ lkm/Module.symvers out/ app/src/main/jni/splicehelper app/src/main/jni/ko/*.ko + +# Python test and collector bytecode +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 4198915a..8072c574 100644 --- a/README.md +++ b/README.md @@ -11,24 +11,55 @@ DFRoot is an Android rooting tool that exploits CVE-2026-43284 (DirtyFrag) — a ## FAQ -**Q: Log says patching failed and that my device isn't vulnerable** +**Q: Log says patching failed** -A: Sorry, there is no fix. Either: - - Your device's kernel is too new - - Your device manufacturer backported the official mitigation +A: A patch failure alone does not establish the cause. Save the complete log and check the preceding errors. Kernel mitigations can prevent the exploit, including: + + - A newer kernel containing the official mitigation + - A manufacturer backport of the official mitigation - [Accidental mitigation](https://github.com/V4bel/dirtyfrag/issues/23#issuecomment-4405314290) (typically seen on kernel 6.1) -**Q: Log says "SUCCESS" or "ksud exited with error", but I don't have root** +**Q: The launcher returned successfully, but I don't have root** + +A: A successful launcher exit means the launch command returned zero; it does not verify usable root. Older versions called this "SUCCESS". Check the selected SU manager's status. Turn off **Auto Soft Reboot** while investigating and save the logs before another attempt. See also [the troubleshooting discussion](https://github.com/diabl0w/DFRoot/discussions/69). + +**Q: The log says the previous attempt's outcome is unknown** -A: First, try disabling "Auto Soft Reboot" in DFRoot settings if you have it enabled, otherwise https://github.com/diabl0w/DFRoot/discussions/69 +A: DFRoot found an unfinished run record after the app process stopped. A reboot, process termination, or another interruption may leave such a record. It does not identify a kernel panic or prove root is available. Save the logs and collect additional diagnostics below. **Q: I installed a module and now I can't launch root without crashing** -A: Toggle "Disable KSU Modules" in DFRoot settings +A: Enable **Disable KSU Modules** before the next run. DFRoot will mark installed modules disabled before launching the SU daemon, and stop startup if a module cannot be disabled. This may help with a broken module; it does not prevent failures earlier in startup or establish the cause of a crash. **Q: I installed a module and have "Start at Boot" enabled and now I am bootlooping** -A: Typically Android will detect bootloops and boot you into Safe Mode, otherwise you can usually hold the Volume Down button during boot to enter safe mode. From there, disable "Start on Boot" and enable "Disable KSU Modules" in DFRoot settings or just uninstall DFRoot until you stabilize. +A: Follow your device manufacturer's instructions for entering Safe Mode. If you can open DFRoot in normal mode, turn off **Start at Boot** before further testing. Downloaded apps may be unavailable in Safe Mode; use Android Settings to uninstall DFRoot if needed to stop automatic startup. Uninstalling also removes its saved logs. Restore stable startup before trying again. **Start at Boot** is disabled by default. + +## Saving and collecting logs + +DFRoot keeps the current and previous run in private, device-protected storage, including manual and boot attempts. Open the app and choose **Save logs** to export a text snapshot through Android's file picker. The export includes the saved app output and available native bootstrap logs. It needs neither root nor storage permission. + +A foreground service owns active runs independently of the app screen. If notifications are disabled, Android may show the running service only in Task Manager instead of the notification drawer. Interrupted work is not automatically restarted. **Start at Boot** makes at most one automatic attempt per boot, with a wake lock capped at two minutes; unlocking does not retry a failed attempt. + +App output is limited to 128 KiB per run and bootstrap output to 64 KiB per run. Save the failed run before further attempts rotate it out. Startup stops before native work if it cannot prepare separate logs for the new attempt, preserving the previous record. Later logging failures are reported without aborting native execution or cleanup. A force-stop or abrupt reset can leave the final messages unwritten; an incomplete record is reported as an unknown outcome, not a diagnosis of the interruption. + +For additional diagnostics, connect a computer with Python 3 and ADB, enable USB debugging, and authorize the computer. Capture the available DFRoot logcat messages and device metadata: + +```sh +python3 tools/collect_logs.py --serial DEVICE --output dfroot-logs +``` + +To capture messages while reproducing a problem, start a timed capture first, then run DFRoot yourself: + +```sh +python3 tools/collect_logs.py --serial DEVICE --watch --duration 120 --output dfroot-watch +``` + +Replace `DEVICE` with the serial shown by `adb devices`. Use `--adb PATH` if ADB is not on your PATH. Each capture creates a local session directory under `--output` (default: `out/diagnostics`). Stop a watch early with Ctrl+C to keep the partial capture. + +The default capture filters logcat to DFRoot's tag and includes selected version and boot metadata. On a device with working root, add `--root` to request current `dmesg`, available pstore/last-kmsg records, and saved private app logs. Unavailable or denied sources are recorded as collection failures; their absence does not rule out a kernel panic. + +The collector reads diagnostics without clearing logs, changing settings, rebooting, or starting DFRoot. It does not upload anything. Review the files for personal information before sharing them, and include the selected SU manager/version, DFRoot settings, and steps that led to the failure. ## Usage @@ -48,11 +79,12 @@ A: Typically Android will detect bootloops and boot you into Safe Mode, otherwis ## Features -- Start on Boot -- Automatic soft reboot (if `late-load --soft-reboot` supported by your SU Manager) +- Optional Start at Boot, disabled by default +- Optional soft reboot when supported by your SU Manager - RO Partition Protection -- Disable all KernelSU modules, to recover from a broken one -- Shizuku not needed — regain root without WiFi! +- Disable KernelSU modules before daemon launch to help recover from a broken module +- Saved run logs and export through Android's file picker +- No Shizuku or Wi-Fi connection required ## How it works diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff5bb127..a2f1c048 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,10 +30,12 @@ android { signingConfigs { create("release") { - storeFile = file(signingProps.getProperty("KEYSTORE_FILE")) - storePassword = signingProps.getProperty("KEYSTORE_PASSWORD") - keyAlias = signingProps.getProperty("KEY_ALIAS") - keyPassword = signingProps.getProperty("KEY_PASSWORD") + signingProps.getProperty("KEYSTORE_FILE")?.let { keystoreFile -> + storeFile = file(keystoreFile) + storePassword = signingProps.getProperty("KEYSTORE_PASSWORD") + keyAlias = signingProps.getProperty("KEY_ALIAS") + keyPassword = signingProps.getProperty("KEY_PASSWORD") + } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ed266c0c..5e44f2fd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,11 +1,17 @@ + xmlns:tools="http://schemas.android.com/tools"> - + + + + + + + + ACTIVE_EXPORTS = ConcurrentHashMap.newKeySet(); + private static RunCoordinator coordinator; + + private AppRuns() {} + + static synchronized RunCoordinator get(Context context) { + if (coordinator == null) { + Context app = context.getApplicationContext().createDeviceProtectedStorageContext(); + coordinator = new RunCoordinator(Executors.newSingleThreadExecutor(), + new RunLogStore(app.getFilesDir()), message -> Log.i("dfroot", message.trim())); + } + return coordinator; + } + + static boolean start(Context context, String origin, Runnable completion) { + Context app = context.getApplicationContext().createDeviceProtectedStorageContext(); + return get(app).start(origin, reporter -> { + try (RunService.Lease ignored = RunService.acquire(app)) { + return ExploitRunner.run(app, reporter); + } + }, completion); + } + + /** Only our own abandoned snapshots; active picker snapshots are retained. */ + static void pruneExports(File cache) { + File[] files = cache.listFiles(file -> file.isFile() + && file.getName().matches("dfroot-export-[a-zA-Z0-9-]+\\.txt")); + if (files == null) return; + Arrays.sort(files, Comparator.comparingLong(File::lastModified).reversed()); + long oldest = System.currentTimeMillis() - 24 * 60 * 60 * 1000L; + int retained = 0; + for (File file : files) { + if (ACTIVE_EXPORTS.contains(file.getName())) continue; + if (file.lastModified() < oldest || retained >= 4) file.delete(); + else retained++; + } + } +} diff --git a/app/src/main/java/df/root/BootReceiver.java b/app/src/main/java/df/root/BootReceiver.java index adbd631a..d3504db4 100644 --- a/app/src/main/java/df/root/BootReceiver.java +++ b/app/src/main/java/df/root/BootReceiver.java @@ -4,38 +4,70 @@ import android.content.Context; import android.content.Intent; import android.os.PowerManager; +import android.provider.Settings; import android.util.Log; import java.io.File; -public class BootReceiver extends BroadcastReceiver implements IReporter { +public class BootReceiver extends BroadcastReceiver { private static final String TAG = "dfroot"; - @Override - public void report(String msg) { - Log.i(TAG, msg.trim()); - } - @Override public void onReceive(Context context, Intent intent) { + String action = intent == null ? null : intent.getAction(); + if (!Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_BOOT_COMPLETED.equals(action)) return; if (new File("/dev/df").exists()) { - Log.i(TAG, "boot: already hooked, skipping"); + Log.i(TAG, "boot: an attempt already exists; skipping without inferring root status"); return; } - Log.i(TAG, "boot: " + intent.getAction()); final Context deCtx = context.createDeviceProtectedStorageContext(); + // Both broadcasts can arrive during the same boot. A failed attempt is + // not permission to silently retry at unlock or after process restart. + synchronized (BootReceiver.class) { + int bootCount; + try { + bootCount = Settings.Global.getInt(context.getContentResolver(), + Settings.Global.BOOT_COUNT, -1); + } catch (RuntimeException e) { + Log.e(TAG, "boot: cannot identify this boot; automatic launch skipped", e); + return; + } + if (bootCount < 0) { + Log.e(TAG, "boot: boot count unavailable; automatic launch skipped"); + return; + } + var attempts = deCtx.getSharedPreferences("boot_attempt", Context.MODE_PRIVATE); + if (attempts.getInt("boot_count", -1) == bootCount) { + Log.i(TAG, "boot: request already handled this boot"); + return; + } + if (!attempts.edit().putInt("boot_count", bootCount).commit()) { + Log.e(TAG, "boot: cannot record attempt; automatic launch skipped"); + return; + } + } + Log.i(TAG, "boot: " + action); PowerManager pm = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (pm == null) { + Log.e(TAG, "boot: PowerManager unavailable"); + return; + } PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "dfroot:boot"); - wl.acquire(); - new Thread(() -> { + wl.acquire(120_000L); + Runnable release = () -> { try { - int rc = ExploitRunner.run(deCtx, this); - Log.i(TAG, "boot: exploit rc=" + rc); - } catch (Exception e) { - Log.e(TAG, "boot: exploit exception", e); - } finally { - wl.release(); + if (wl.isHeld()) wl.release(); + } catch (RuntimeException e) { + Log.w(TAG, "boot: wake lock already released", e); } - }, "dfroot-boot").start(); + }; + try { + AppRuns.start(deCtx, Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action) + ? "locked boot" : "boot", release); + } catch (RuntimeException e) { + release.run(); + Log.e(TAG, "boot: could not schedule launch", e); + } } } diff --git a/app/src/main/java/df/root/ExploitRunner.java b/app/src/main/java/df/root/ExploitRunner.java index ebbee1f0..7e85d921 100644 --- a/app/src/main/java/df/root/ExploitRunner.java +++ b/app/src/main/java/df/root/ExploitRunner.java @@ -7,10 +7,8 @@ import android.net.IpSecTransform; import android.os.Build; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.StandardCopyOption; import java.net.DatagramSocket; @@ -24,6 +22,9 @@ public class ExploitRunner { static final String PREFS_NAME = "dfroot"; static final String PREF_SOFT_REBOOT = "soft_reboot"; static final String PREF_SU_MANAGER = "su_manager"; + static final String PREF_DISABLE_MODULES = "disable_modules"; + static final int ALREADY_ATTEMPTED = 10; + static final int PREFLIGHT_FAILED = 11; private static final String NATIVE_BIN = "libdfroot.so"; // The exploit stages nativeLibraryDir/libksud.so from the manager app, so a @@ -34,65 +35,77 @@ static boolean hasKsud(android.content.pm.ApplicationInfo app) { } public static synchronized int run(Context ctx, IReporter reporter) throws Exception { + reportDeviceInfo(reporter); + // Native staging paths currently refer to the primary Android user. + if (!android.os.Process.myUserHandle().equals( + android.os.UserHandle.getUserHandleForUid(0))) { + reporter.report("Startup not attempted: secondary Android users are not supported.\n"); + return PREFLIGHT_FAILED; + } if (new File("/dev/df").exists()) { - reporter.report("already hooked, skipping\n"); - return 0; + reporter.report("A launch was already attempted this boot; completion is unknown. Check your SU manager.\n"); + return ALREADY_ATTEMPTED; } - reportDeviceInfo(reporter); File bin = new File(ctx.getApplicationInfo().nativeLibraryDir, NATIVE_BIN); SharedPreferences prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + // Bootstrap reads the XML directly, not SharedPreferences' in-memory + // cache. Wait for earlier apply() writes before staging native work. + if (!prefs.edit().commit()) { + reporter.report("Startup not attempted: could not save settings for bootstrap.\n"); + return PREFLIGHT_FAILED; + } String suManagerPackage = prefs.getString(PREF_SU_MANAGER, null); if (suManagerPackage == null) { reporter.report("no SU manager selected\n"); - return 2; + return PREFLIGHT_FAILED; } android.content.pm.ApplicationInfo app; try { app = ctx.getPackageManager().getApplicationInfo(suManagerPackage, 0); } catch (android.content.pm.PackageManager.NameNotFoundException e) { reporter.report(suManagerPackage + " is no longer installed\n"); - return 2; + return PREFLIGHT_FAILED; } if (!hasKsud(app)) { reporter.report("libksud.so not found in " + suManagerPackage + "\n"); - return 2; + return PREFLIGHT_FAILED; } + var managerInfo = ctx.getPackageManager().getPackageInfo(suManagerPackage, 0); + reporter.report("* SU manager: " + suManagerPackage + " " + + managerInfo.versionName + " (" + managerInfo.getLongVersionCode() + ")\n"); + reporter.report("* requested settings: soft_reboot=" + + prefs.getBoolean(PREF_SOFT_REBOOT, false) + ", disable_modules=" + + prefs.getBoolean(PREF_DISABLE_MODULES, false) + "\n"); stageAssets(ctx, suManagerPackage); IpSecManager ipsec = (IpSecManager) ctx.getSystemService(Context.IPSEC_SERVICE); if (ipsec == null) throw new IllegalStateException("IpSecManager unavailable (requires API 28+)"); - IpSecManager.UdpEncapsulationSocket encapSock = ipsec.openUdpEncapsulationSocket(); InetAddress loopback = InetAddress.getByName("127.0.0.1"); - IpSecManager.SecurityParameterIndex spiObj = ipsec.allocateSecurityParameterIndex(loopback); - byte[] aesKey = new byte[32]; new SecureRandom().nextBytes(aesKey); - - DatagramSocket senderSock = new DatagramSocket(); - int senderPort = senderSock.getLocalPort(); - senderSock.close(); - - IpSecTransform transform = new IpSecTransform.Builder(ctx) - .setEncryption(new IpSecAlgorithm(IpSecAlgorithm.CRYPT_AES_CBC, aesKey)) - .setIpv4Encapsulation(encapSock, senderPort) - .buildTransportModeTransform(loopback, spiObj); - - try { - return runBinary(bin, reporter, encapSock.getPort(), spiObj.getSpi(), - aesKey, senderPort); - } finally { - transform.close(); - spiObj.close(); - encapSock.close(); + int senderPort; + try (DatagramSocket senderSock = new DatagramSocket()) { + senderPort = senderSock.getLocalPort(); + } + // Own each resource immediately, including partial setup failures. + try (IpSecManager.UdpEncapsulationSocket encapSock = ipsec.openUdpEncapsulationSocket(); + IpSecManager.SecurityParameterIndex spiObj = ipsec.allocateSecurityParameterIndex(loopback); + IpSecTransform transform = new IpSecTransform.Builder(ctx) + .setEncryption(new IpSecAlgorithm(IpSecAlgorithm.CRYPT_AES_CBC, aesKey)) + .setIpv4Encapsulation(encapSock, senderPort) + .buildTransportModeTransform(loopback, spiObj)) { + return runBinary(bin, reporter, encapSock.getPort(), spiObj.getSpi(), aesKey, senderPort); } } private static void reportDeviceInfo(IReporter reporter) { - reporter.report("* app version: " + BuildConfig.VERSION_NAME + "\n"); + reporter.report("* app version: " + BuildConfig.VERSION_NAME + " (" + + BuildConfig.VERSION_CODE + ", " + BuildConfig.BUILD_TYPE + ")\n"); reporter.report("* manufacturer: " + Build.MANUFACTURER + "\n"); reporter.report("* model: " + Build.MODEL + "\n"); - reporter.report("* android version: " + Build.VERSION.RELEASE + "\n"); + reporter.report("* android version: " + Build.VERSION.RELEASE + + " (API " + Build.VERSION.SDK_INT + ")\n"); reporter.report("* security patch: " + Build.VERSION.SECURITY_PATCH + "\n"); reporter.report("* kernel version: " + System.getProperty("os.version", "unknown") + "\n\n"); } @@ -123,10 +136,11 @@ private static int runBinary(File bin, IReporter reporter, ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); + reporter.report("* launching native process\n"); Process p = pb.start(); - BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream())); - for (String line; (line = r.readLine()) != null; ) reporter.report(line + "\n"); - return p.waitFor(); + // This helper never kills the native child. Its cleanup still needs the + // IPsec resources above, even if logging or the Java waiter fails. + return ProcessOutput.waitFor(p, reporter); } private static String hex(byte[] b) { diff --git a/app/src/main/java/df/root/MainActivity.java b/app/src/main/java/df/root/MainActivity.java index 28f8c0a6..a98c9e98 100644 --- a/app/src/main/java/df/root/MainActivity.java +++ b/app/src/main/java/df/root/MainActivity.java @@ -7,10 +7,10 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -20,51 +20,65 @@ import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.ScrollView; import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -public class MainActivity extends Activity implements IReporter { - - private static final String TAG = "dfroot"; +public class MainActivity extends Activity { + private static final int SAVE_LOGS = 1; + private static final String PENDING_EXPORT = "pendingExport"; private Button btnRun; private ScrollView outputScroll; private TextView outputView; private Spinner spinnerSuManager; + private TextView statusView; + private ProgressBar runProgress; private Context mDeCtx; + private RunCoordinator mRuns; private final Handler mMain = new Handler(Looper.getMainLooper()); - private final Executor mExec = Executors.newSingleThreadExecutor(); private int mValidSuManagerPos = 0; - - @Override - public void report(String msg) { - Log.i(TAG, msg.trim()); - mMain.post(() -> { - outputView.append(msg); - outputScroll.post(() -> outputScroll.fullScroll(View.FOCUS_DOWN)); - }); - } + private boolean mVisible; + private boolean mRefreshPending; + private boolean mExportPreparing; + private File mPendingExport; + private String mRenderedLog = ""; + private final Runnable mRefresh = this::refreshLog; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mDeCtx = createDeviceProtectedStorageContext(); + mRuns = AppRuns.get(this); setContentView(R.layout.activity_main); - - getActionBar().setSubtitle("@diabl0w github/xda"); + if (getActionBar() != null) getActionBar().setSubtitle("@diabl0w github/xda"); btnRun = findViewById(R.id.btnRun); outputScroll = findViewById(R.id.outputScroll); outputView = findViewById(R.id.outputView); spinnerSuManager = findViewById(R.id.spinnerSuManager); + statusView = findViewById(R.id.statusView); + runProgress = findViewById(R.id.runProgress); + if (savedInstanceState != null) { + String name = savedInstanceState.getString(PENDING_EXPORT); + if (name != null && name.matches("dfroot-export-[a-zA-Z0-9-]+\\.txt")) { + mPendingExport = new File(getCacheDir(), name); + AppRuns.ACTIVE_EXPORTS.add(name); + } + } + File cache = getCacheDir(); + AppRuns.IO.execute(() -> AppRuns.pruneExports(cache)); PackageManager pm = getPackageManager(); List entries = new ArrayList<>(); @@ -90,7 +104,7 @@ protected void onCreate(Bundle savedInstanceState) { } } if (!savedFound && saved != null) { // Saved manager was uninstalled - prefs.edit().remove(ExploitRunner.PREF_SU_MANAGER).apply(); + mRuns.whileIdle(() -> prefs.edit().remove(ExploitRunner.PREF_SU_MANAGER).apply()); } spinnerSuManager.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @@ -98,8 +112,12 @@ protected void onCreate(Bundle savedInstanceState) { public void onItemSelected(AdapterView parent, View view, int pos, long id) { SuManagerEntry e = entries.get(pos); if (e.packageName == null) return; + if (!mRuns.whileIdle(() -> + prefs.edit().putString(ExploitRunner.PREF_SU_MANAGER, e.packageName).apply())) { + spinnerSuManager.setSelection(mValidSuManagerPos); + return; + } mValidSuManagerPos = pos; - prefs.edit().putString(ExploitRunner.PREF_SU_MANAGER, e.packageName).apply(); updateRunButton(); } @Override @@ -109,12 +127,38 @@ public void onNothingSelected(AdapterView parent) {} updateRunButton(); btnRun.setOnClickListener(v -> { - btnRun.setEnabled(false); - outputView.setText(""); - mExec.execute(this::runExploit); + AppRuns.start(this, "manual", null); + updateRunButton(); + refreshLog(); }); } + @Override + protected void onStart() { + super.onStart(); + mVisible = true; + refreshLog(); + } + + @Override + protected void onStop() { + mVisible = false; + mMain.removeCallbacks(mRefresh); + super.onStop(); + } + + @Override + protected void onSaveInstanceState(Bundle state) { + if (mPendingExport != null) state.putString(PENDING_EXPORT, mPendingExport.getName()); + super.onSaveInstanceState(state); + } + + @Override + protected void onDestroy() { + if (isFinishing()) discardExport(); + super.onDestroy(); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.main_menu, menu); @@ -124,30 +168,168 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.action_settings) { - startActivity(new Intent(this, SettingsActivity.class)); + if (!mRuns.isRunning()) startActivity(new Intent(this, SettingsActivity.class)); + return true; + } + if (item.getItemId() == R.id.action_save_logs) { + prepareExport(); return true; } return super.onOptionsItemSelected(item); } private void updateRunButton() { - btnRun.setEnabled(mValidSuManagerPos >= 1 && !new File("/dev/df").exists()); - } - - private void runExploit() { - try { - int rc = ExploitRunner.run(mDeCtx, this); - String msg = rc == 0 ? "DFRoot: SUCCESS" - : rc == 1 ? "DFRoot: Error - ksud nonzero exit" - : rc == 2 ? "DFRoot: Error - check logcat & dmesg" - : "DFRoot: Error - failed to patch files"; - mMain.post(() -> Toast.makeText(this, msg, Toast.LENGTH_LONG).show()); - } catch (Exception e) { - Log.e(TAG, "exploit exception", e); - report("\nexception: " + e + "\n"); - } finally { - mMain.post(this::updateRunButton); + boolean running = mRuns.isRunning(); + btnRun.setEnabled(!running && mValidSuManagerPos >= 1 && !new File("/dev/df").exists()); + spinnerSuManager.setEnabled(!running); + runProgress.setVisibility(running ? View.VISIBLE : View.GONE); + invalidateOptionsMenu(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.action_settings).setEnabled(!mRuns.isRunning()); + menu.findItem(R.id.action_save_logs).setEnabled( + !mExportPreparing && mPendingExport == null && !AppRuns.EXPORT_BUSY.get()); + return super.onPrepareOptionsMenu(menu); + } + + private void refreshLog() { + if (!mVisible || mRefreshPending) return; + mRefreshPending = true; + AppRuns.IO.execute(() -> { + RunCoordinator.Snapshot loaded = null; + String error = null; + try { + loaded = mRuns.snapshot(); + } catch (RuntimeException e) { + error = "Could not read saved logs: " + e.getMessage(); + } + RunCoordinator.Snapshot snapshot = loaded; + String readError = error; + mMain.post(() -> { + mRefreshPending = false; + if (!mVisible || isDestroyed()) return; + boolean follow = outputScroll.getScrollY() + outputScroll.getHeight() + >= outputView.getHeight() - 32; + if (snapshot != null && !snapshot.text.equals(mRenderedLog)) { + mRenderedLog = snapshot.text; + outputView.setText(snapshot.text); + if (follow) outputScroll.post(() -> outputScroll.fullScroll(View.FOCUS_DOWN)); + } + statusView.setText(snapshot == null ? readError : snapshot.status); + updateRunButton(); + mMain.removeCallbacks(mRefresh); + mMain.postDelayed(mRefresh, 500); + }); + }); + } + + private void prepareExport() { + if (mExportPreparing || mPendingExport != null || AppRuns.EXPORT_BUSY.get()) return; + mExportPreparing = true; + invalidateOptionsMenu(); + Context app = getApplicationContext(); + AppRuns.EXPORT.execute(() -> { + File staged = null; + try { + String text = mRuns.exportText(); + if (text.isEmpty()) throw new IOException("No logs to save"); + // Export owns a frozen snapshot, independent of later runs or + // activity recreation while the system document picker is open. + staged = File.createTempFile("dfroot-export-", ".txt", app.getCacheDir()); + AppRuns.ACTIVE_EXPORTS.add(staged.getName()); + AppRuns.pruneExports(app.getCacheDir()); + try (FileOutputStream out = new FileOutputStream(staged)) { + out.write(text.getBytes(StandardCharsets.UTF_8)); + } + File ready = staged; + mMain.post(() -> { + mExportPreparing = false; + if (!mVisible || isFinishing() || isDestroyed()) { + deleteExport(ready); + return; + } + mPendingExport = ready; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("text/plain") + .putExtra(Intent.EXTRA_TITLE, "DFRoot-logs-" + System.currentTimeMillis() + ".txt"); + try { + startActivityForResult(intent, SAVE_LOGS); + } catch (RuntimeException e) { + discardExport(); + Toast.makeText(app, "Could not open the document picker: " + e.getMessage(), + Toast.LENGTH_LONG).show(); + } + invalidateOptionsMenu(); + }); + } catch (Exception e) { + if (staged != null) deleteExport(staged); + mMain.post(() -> { + mExportPreparing = false; + if (!isDestroyed()) invalidateOptionsMenu(); + Toast.makeText(app, "Could not prepare logs: " + e.getMessage(), + Toast.LENGTH_LONG).show(); + }); + } + }); + } + + private void discardExport() { + if (mPendingExport != null) deleteExport(mPendingExport); + mPendingExport = null; + } + + private static void deleteExport(File file) { + AppRuns.ACTIVE_EXPORTS.remove(file.getName()); + file.delete(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != SAVE_LOGS) return; + File staged = mPendingExport; + Uri destination = data == null ? null : data.getData(); + if (resultCode != RESULT_OK) { + discardExport(); + invalidateOptionsMenu(); + return; + } + mPendingExport = null; + Context app = getApplicationContext(); + if (!AppRuns.EXPORT_BUSY.compareAndSet(false, true)) { + if (staged != null) deleteExport(staged); + Toast.makeText(app, "Another log export is still in progress", Toast.LENGTH_LONG).show(); + invalidateOptionsMenu(); + return; } + invalidateOptionsMenu(); + AppRuns.EXPORT.execute(() -> { + String result; + try { + if (staged == null || !staged.isFile()) + throw new IOException("Saved snapshot is unavailable; choose Save logs again"); + if (destination == null || !"content".equals(destination.getScheme())) + throw new IOException("No document destination was provided"); + try (OutputStream out = app.getContentResolver().openOutputStream(destination, "wt")) { + if (out == null) throw new IOException("The document provider did not open the file"); + Files.copy(staged.toPath(), out); + } + result = "Logs saved"; + } catch (Exception e) { + result = "Could not save logs: " + e.getMessage(); + } finally { + if (staged != null) deleteExport(staged); + AppRuns.EXPORT_BUSY.set(false); + } + String message = result; + mMain.post(() -> { + if (!isDestroyed()) invalidateOptionsMenu(); + Toast.makeText(app, message, Toast.LENGTH_LONG).show(); + }); + }); } private static class SuManagerEntry { diff --git a/app/src/main/java/df/root/ProcessOutput.java b/app/src/main/java/df/root/ProcessOutput.java new file mode 100644 index 00000000..0b3e6174 --- /dev/null +++ b/app/src/main/java/df/root/ProcessOutput.java @@ -0,0 +1,154 @@ +package df.root; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Keeps ownership of a child until it exits, even when reporting its output fails. + * This deliberately has no execution timeout: killing the child can bypass its + * native cleanup. Only output draining after a confirmed exit is time limited. + */ +final class ProcessOutput { + private static final int MAX_MESSAGE_CHARS = 4096; + private static final long DRAIN_NANOS = TimeUnit.SECONDS.toNanos(2); + + private ProcessOutput() {} + + static final class Failure extends Exception { + private static final long serialVersionUID = 1L; + final int exitCode; + + Failure(int exitCode, Exception cause) { + super("Child exited with status " + exitCode + + ", but process supervision failed (" + + cause.getClass().getSimpleName() + ")", cause); + this.exitCode = exitCode; + } + } + + static int waitFor(Process process, IReporter reporter) throws Exception { + OutputState state = new OutputState(reporter); + InputStream output = process.getInputStream(); + Thread reader = new Thread(() -> drain(output, state), "dfroot-output"); + reader.setDaemon(true); + reader.start(); + closeLater(process.getOutputStream(), "stdin"); + + boolean interrupted = false; + int exitCode; + for (;;) { + try { + exitCode = process.waitFor(); + break; + } catch (InterruptedException e) { + if (!interrupted) state.fail(e); + interrupted = true; + } + } + + long deadline = System.nanoTime() + DRAIN_NANOS; + while (reader.isAlive()) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + try { + TimeUnit.NANOSECONDS.timedJoin(reader, remaining); + } catch (InterruptedException e) { + if (!interrupted) state.fail(e); + interrupted = true; + } + } + if (reader.isAlive()) { + state.fail(new IOException("Child exited with status " + exitCode + + ", but output did not finish draining within 2 seconds")); + state.stopped.set(true); + } + state.reporter.set(null); + + // A reader or stream implementation may hold a lock while blocked. + // Neither close nor an unlimited join belongs on the owning thread. + closeLater(output, "stdout"); + closeLater(process.getErrorStream(), "stderr"); + + if (interrupted) Thread.currentThread().interrupt(); + Exception failure = state.failure.get(); + if (failure != null) throw new Failure(exitCode, failure); + return exitCode; + } + + private static void drain(InputStream output, OutputState state) { + Reader reader = new InputStreamReader(output, StandardCharsets.UTF_8); + char[] buffer = new char[1024]; + StringBuilder message = new StringBuilder(MAX_MESSAGE_CHARS); + try { + int count; + while (!state.stopped.get() && (count = reader.read(buffer)) != -1) { + for (int i = 0; i < count; i++) { + if (state.stopped.get()) return; + char c = buffer[i]; + message.append(c); + if (c == '\n' || message.length() == MAX_MESSAGE_CHARS) { + state.report(message.toString()); + message.setLength(0); + } + } + } + if (!state.stopped.get() && message.length() != 0) { + state.report(message.toString()); + } + } catch (IOException | RuntimeException e) { + if (!state.stopped.get()) state.fail(e); + } + // The owner closes the stream after child exit. Closing it here while + // the child is alive could make a native stdout write fail or signal. + } + + private static void closeLater(Closeable stream, String name) { + Thread closer = new Thread(() -> { + try { + stream.close(); + } catch (IOException | RuntimeException e) { + // Closing stdin is best effort, and close errors must not hide + // a child exit status or an earlier reporting/read failure. + System.err.println("ProcessOutput: could not close child " + name + + " (" + e.getClass().getSimpleName() + ")"); + } + }, "dfroot-close-" + name); + closer.setDaemon(true); + closer.start(); + } + + private static final class OutputState { + final AtomicReference reporter; + final AtomicReference failure = new AtomicReference<>(); + final AtomicBoolean stopped = new AtomicBoolean(); + + OutputState(IReporter reporter) { + this.reporter = new AtomicReference<>(reporter); + } + + void report(String message) { + IReporter target = reporter.get(); + if (target == null) return; + try { + target.report(message); + } catch (RuntimeException e) { + reporter.compareAndSet(target, null); + fail(e); + // Continue draining so a failed reporter cannot fill the pipe. + } + } + + void fail(Exception error) { + if (failure.compareAndSet(null, error)) return; + Exception first = failure.get(); + if (first != error) first.addSuppressed(error); + } + } +} diff --git a/app/src/main/java/df/root/RunCoordinator.java b/app/src/main/java/df/root/RunCoordinator.java new file mode 100644 index 00000000..b763b874 --- /dev/null +++ b/app/src/main/java/df/root/RunCoordinator.java @@ -0,0 +1,242 @@ +package df.root; + +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Process-wide admission and results; this class owns no Android component. */ +public final class RunCoordinator { + public interface Work { + /** Return only after native work and resource cleanup have completed. */ + int run(IReporter reporter) throws Exception; + } + + public static final class Snapshot { + public final boolean running; + public final String text; + public final String status; + public final long revision; + + Snapshot(boolean running, String text, String status, long revision) { + this.running = running; + this.text = text; + this.status = status; + this.revision = revision; + } + } + + private final Executor worker; + private final RunLogStore logs; + private final IReporter fallback; + private volatile boolean running; + private Session active; + private long generation; + private long revision; + private String admissionFailure; + private String lastSnapshotText; + private String lastSnapshotStatus; + private boolean lastSnapshotRunning; + + private static final class Session { + final long id; + volatile boolean accepting = true; + volatile boolean begun; + + Session(long id) { this.id = id; } + } + + public RunCoordinator(Executor worker, RunLogStore logs, IReporter fallback) { + if (worker == null || logs == null) throw new NullPointerException(); + this.worker = worker; + this.logs = logs; + this.fallback = fallback; + } + + /** + * Never queues a duplicate. Completion runs exactly once for every request, + * including rejected requests, and is invoked outside this object's monitor. + * Production callers should pass only resource cleanup, never an Activity. + */ + public boolean start(String origin, Work work, Runnable completion) { + AtomicBoolean delivered = new AtomicBoolean(); + Runnable finishRequest = () -> { + if (delivered.compareAndSet(false, true)) complete(completion); + }; + final Session session; + synchronized (this) { + if (running) { + session = null; + } else { + running = true; + admissionFailure = null; + session = new Session(++generation); + active = session; + revision++; + } + } + if (session == null) { + finishRequest.run(); + return false; + } + try { + worker.execute(() -> execute(session, origin, work, finishRequest)); + return true; + } catch (Throwable rejected) { + synchronized (this) { + if (active == session && running) { + session.accepting = false; + running = false; + admissionFailure = "Unable to start run: " + rejected; + revision++; + } + } + safeFallback("Unable to start run: " + rejected + "\n"); + finishRequest.run(); + return false; + } + } + + public boolean isRunning() { + return running; + } + + /** Serializes settings mutations with run admission. */ + public synchronized boolean whileIdle(Runnable mutation) { + if (running) return false; + mutation.run(); + return true; + } + + /** May read native output and restored logs; invoke on a background thread. */ + public Snapshot snapshot() { + for (;;) { + Session observed; + boolean busy; + boolean queued; + synchronized (this) { + observed = active; + busy = running; + queued = busy && !observed.begun; + } + RunLogStore.View view = queued + ? new RunLogStore.View("Run queued; waiting for the worker.\n", "Starting") + : logs.read(); + synchronized (this) { + if (observed != active || busy != running + || (queued && observed.begun)) continue; + String text = view.text; + String status = view.status; + if (admissionFailure != null) { + text += "\n" + admissionFailure + "\n"; + status = admissionFailure; + } + if (!text.equals(lastSnapshotText) || !status.equals(lastSnapshotStatus) + || running != lastSnapshotRunning) { + revision++; + lastSnapshotText = text; + lastSnapshotStatus = status; + lastSnapshotRunning = running; + } + return new Snapshot(running, text, status, revision); + } + } + } + + /** Includes both Java and native output from the current and previous runs. */ + public String exportText() { + String text = logs.exportText(); + synchronized (this) { + if (running && !active.begun) text = "Run queued; waiting for the worker.\n" + text; + if (admissionFailure != null) text += "\n" + admissionFailure + "\n"; + } + return text; + } + + private void execute(Session session, String origin, Work work, Runnable completion) { + String result; + try { + String diagnostic = logs.begin(origin); + synchronized (this) { + session.begun = true; + revision++; + } + safeFallback(diagnostic); + if (diagnostic != null) { + result = "Startup not attempted: could not prepare this run’s log files" + + " (exit code 11)"; + } else { + int code = work.run(message -> report(session, message)); + result = resultFor(code); + } + } catch (Throwable failure) { + result = "Run failed: " + failure; + safeFallback(result + "\n"); + } + try { + String diagnostic; + synchronized (session) { + session.accepting = false; + diagnostic = logs.finish(result); + } + synchronized (this) { + revision++; + } + safeFallback(result + "\n"); + safeFallback(diagnostic); + } catch (Throwable loggingFailure) { + safeFallback("Unable to record terminal result: " + loggingFailure + "\n"); + } finally { + synchronized (this) { + if (active == session) { + session.accepting = false; + running = false; + revision++; + } + } + complete(completion); + } + } + + private void report(Session session, String message) { + String diagnostic; + synchronized (session) { + if (!session.accepting) return; + diagnostic = logs.append(message); + } + synchronized (this) { + revision++; + } + safeFallback(message); + safeFallback(diagnostic); + } + + private void complete(Runnable callback) { + if (callback == null) return; + try { + callback.run(); + } catch (Throwable failure) { + safeFallback("Run completion callback failed: " + failure + "\n"); + } + } + + private void safeFallback(String message) { + if (fallback == null || message == null || message.isEmpty()) return; + try { + fallback.report(message); + } catch (Throwable ignored) { + // Diagnostics must not interrupt native execution or its cleanup. + } + } + + static String resultFor(int code) { + switch (code) { + case 0: return "Launch completed; root unverified (exit code 0)"; + case 1: return "Bootstrap failed (exit code 1)"; + case 2: return "Run incomplete (exit code 2)"; + case 3: return "Patch failure (exit code 3)"; + case 4: return "Cleanup failed (exit code 4)"; + case 10: return "Already attempted; outcome unverified (exit code 10)"; + case 11: return "Preflight failed (exit code 11)"; + default: return "Run exited with code " + code + "; outcome unverified"; + } + } +} diff --git a/app/src/main/java/df/root/RunLogStore.java b/app/src/main/java/df/root/RunLogStore.java new file mode 100644 index 00000000..901343f9 --- /dev/null +++ b/app/src/main/java/df/root/RunLogStore.java @@ -0,0 +1,544 @@ +package df.root; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +/** + * Two private diagnostic generations. Construction does no filesystem work. + * Call read/export on a background thread; live Java output remains authoritative + * even when a disk write fails or an older file is shorter. + */ +public final class RunLogStore { + static final int JAVA_LIMIT = 128 * 1024; + static final int NATIVE_LIMIT = 64 * 1024; + private static final int TERMINAL_RESERVE = 4096; + private static final int FLUSH_BYTES = 8192; + private static final long FLUSH_NANOS = 1_000_000_000L; + private static final String TRUNCATED = "[Earlier Java output omitted: 128 KiB limit]\n"; + private static final String COMPLETION = "[DFRoot completion] "; + static final String UNFINISHED = + "Previous run has no completion record; outcome unknown"; + + private final Path root; + private boolean loaded; + private boolean writable; + private boolean currentBelongsToRun; + private boolean truncated; + private long pendingBytes; + private long lastFlush; + private String header = ""; + private String body = ""; + private String diagnostic = ""; + private String terminal = ""; + private String status = "No run recorded"; + + public RunLogStore(File filesDir) { + root = filesDir.toPath().resolve("diagnostics"); + } + + static final class View { + final String text; + final String status; + + View(String text, String status) { + this.text = text; + this.status = status; + } + } + + /** + * Called only for an admitted run, on its worker. A non-null diagnostic means + * preparation failed: the caller must not launch native work using the fixed + * bootstrap.log path, which may still belong to the previous disk generation. + */ + synchronized String begin(String origin) { + loaded = true; + writable = false; + currentBelongsToRun = false; + header = "[DFRoot run]\nOrigin: " + singleLine(origin, 256) + + "\nStarted: " + Instant.now() + "\n"; + body = ""; + diagnostic = ""; + terminal = ""; + truncated = false; + pendingBytes = 0; + status = "Running; outcome not yet known"; + try { + prepareGeneration(); + writable = true; + currentBelongsToRun = true; + lastFlush = System.nanoTime(); + return null; + } catch (Exception e) { + return noteFailure("create diagnostic logs", e); + } + } + + synchronized String append(String message) { + if (message == null || message.isEmpty()) return null; + int limit = JAVA_LIMIT - TERMINAL_RESERVE - utf8Length(header) - utf8Length(TRUNCATED); + String boundedMessage = tailUtf8(message, JAVA_LIMIT); + String combined = body + boundedMessage; + truncated |= !boundedMessage.equals(message) || utf8Length(combined) > limit; + body = tailUtf8(combined, limit); + pendingBytes += utf8Length(boundedMessage); + boolean checkpoint = message.startsWith("* launching native process") + || message.startsWith("=== ") || message.contains(": ERROR"); + // Every actual write is durable. Ordinary progress is batched; buffered + // output after the last checkpoint may be lost on an abrupt reset. + if (!checkpoint && pendingBytes < FLUSH_BYTES + && System.nanoTime() - lastFlush < FLUSH_NANOS) return null; + return persist(); + } + + synchronized String finish(String result) { + status = singleLine(result, 1800); + terminal = "\n" + COMPLETION + status + "\n"; + return persist(); + } + + synchronized View read() { + restoreOnce(); + String nativeText = currentBelongsToRun + ? readText(root.resolve("current/bootstrap.log"), NATIVE_LIMIT) + : "(No native log is available for this run.)\n"; + String javaSection = currentBelongsToRun + ? section("Current Java", "current/run.log", javaText()) + : "\n=== Current attempt Java log (memory; not saved) ===\n" + javaText(); + return new View(javaSection + + section("Current native bootstrap", "current/bootstrap.log", nativeText), + status); + } + + public synchronized String exportText() { + View current = read(); + String retainedCurrent = !currentBelongsToRun + ? section("Retained disk current Java", "current/run.log", + readText(root.resolve("current/run.log"), JAVA_LIMIT)) + + section("Retained disk current native bootstrap", "current/bootstrap.log", + readText(root.resolve("current/bootstrap.log"), NATIVE_LIMIT)) + : ""; + return current.text + retainedCurrent + + section("Previous Java", "previous/run.log", + readText(root.resolve("previous/run.log"), JAVA_LIMIT)) + + section("Previous native bootstrap", "previous/bootstrap.log", + readText(root.resolve("previous/bootstrap.log"), NATIVE_LIMIT)); + } + + private void restoreOnce() { + if (loaded) return; + loaded = true; + Path currentLog = root.resolve("current/run.log"); + try { + if (Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) recoverGenerations(); + } catch (Exception recoveryFailure) { + noteFailure("recover retained logs", recoveryFailure); + } + try { + if (!Files.exists(currentLog, LinkOption.NOFOLLOW_LINKS)) return; + body = readBounded(currentLog, JAVA_LIMIT); + currentBelongsToRun = true; + status = completionStatus(body); + } catch (Exception e) { + status = UNFINISHED; + noteFailure("read existing Java log", e); + currentBelongsToRun = true; + } + } + + private static String completionStatus(String text) { + if (!text.endsWith("\n")) return UNFINISHED; + int index = text.lastIndexOf("\n" + COMPLETION); + if (index < 0 && text.startsWith(COMPLETION)) index = -1; + else if (index < 0) return UNFINISHED; + int start = index + 1 + COMPLETION.length(); + String result = text.substring(start).trim(); + return result.isEmpty() || result.indexOf('\n') >= 0 ? UNFINISHED : result; + } + + private String javaText() { + String notice = truncated ? TRUNCATED : ""; + int budget = JAVA_LIMIT - utf8Length(header) + - utf8Length(diagnostic) - utf8Length(terminal) - utf8Length(notice); + return header + notice + tailUtf8(body, Math.max(0, budget)) + diagnostic + terminal; + } + + private String persist() { + if (!writable) return null; + Path temporary = null; + try { + Path directory = root.resolve("current"); + temporary = privateTempFile(directory, ".run-", ".tmp"); + durableWrite(temporary, javaText()); + move(temporary, directory.resolve("run.log"), true); + syncDirectory(directory); + lastFlush = System.nanoTime(); + pendingBytes = 0; + return null; + } catch (Exception e) { + writable = false; + return noteFailure("save Java log", e); + } finally { + deleteQuietly(temporary); + } + } + + private String noteFailure(String operation, Exception failure) { + if (!diagnostic.isEmpty()) return null; + String message = "[Diagnostic logging failed: " + operation + ": " + + singleLine(failure.toString(), 1400) + "]\n"; + // One visible warning remains available even if every subsequent write fails. + diagnostic = "\n" + message; + return message; + } + + private void prepareGeneration() throws IOException { + Files.createDirectories(root); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Expected a private directory: " + root); + privateDirectory(root); + syncDirectory(root.getParent()); + recoverGenerations(); + Path current = root.resolve("current"); + Path previous = root.resolve("previous"); + validateDirectory(current); + validateDirectory(previous); + + Path staged = privateTempDirectory(root, ".next-"); + Path savedCurrent = root.resolve(".current-" + UUID.randomUUID()); + Path savedPrevious = root.resolve(".previous-" + UUID.randomUUID()); + boolean movedCurrent = false; + boolean installed = false; + boolean movedPrevious = false; + boolean archived = false; + try { + // Finish creating both files before changing either retained generation. + privateFile(staged.resolve("run.log"), javaText()); + privateFile(staged.resolve("bootstrap.log"), ""); + syncDirectory(staged); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + move(current, savedCurrent, false); + movedCurrent = true; + syncDirectory(root); + } + move(staged, current, false); + installed = true; + syncDirectory(root); + if (movedCurrent) { + if (Files.exists(previous, LinkOption.NOFOLLOW_LINKS)) { + move(previous, savedPrevious, false); + movedPrevious = true; + syncDirectory(root); + } + move(savedCurrent, previous, false); + archived = true; + syncDirectory(root); + } + } catch (IOException | RuntimeException failure) { + // Roll back in reverse order; never delete the only retained copy. + try { + if (archived) move(previous, savedCurrent, false); + if (movedPrevious) move(savedPrevious, previous, false); + if (installed) move(current, staged, false); + if (movedCurrent) move(savedCurrent, current, false); + syncDirectory(root); + } catch (IOException | RuntimeException rollback) { + failure.addSuppressed(rollback); + } + throw failure; + } finally { + deleteQuietly(staged); + } + deleteQuietly(savedPrevious); + syncDirectory(root); + } + + /** + * Complete an interrupted rename sequence without discarding its newest + * retained evidence. Staging directories contain no launched native work. + * Once current/previous are installed, older backups and unused stages can + * be removed; a failed recovery leaves the remaining copies in place. + */ + private void recoverGenerations() throws IOException { + Path current = root.resolve("current"); + Path previous = root.resolve("previous"); + validateDirectory(current); + validateDirectory(previous); + List backups = new ArrayList<>(); + List stages = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(root)) { + for (Path path : entries) { + String name = path.getFileName().toString(); + if (name.startsWith(".current-") || name.startsWith(".previous-")) backups.add(path); + else if (name.startsWith(".next-")) stages.add(path); + } + } + // Rename roles encode transaction order even if the wall clock moves + // backwards: saved current is newer than existing/saved previous. + Comparator newest = Comparator.comparingInt(RunLogStore::recoveryPriority) + .thenComparing(Comparator.comparingLong(RunLogStore::evidenceTime).reversed()) + .thenComparing(path -> path.getFileName().toString()); + backups.sort(newest); + stages.sort(newest); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + List candidates = new ArrayList<>(backups); + if (Files.exists(previous, LinkOption.NOFOLLOW_LINKS)) candidates.add(previous); + candidates.sort(newest); + if (candidates.isEmpty()) candidates.addAll(stages); + Path best = firstEvidence(candidates); + if (best != null) { + move(best, current, false); + syncDirectory(root); + backups.remove(best); + stages.remove(best); + } + } + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) return; + List historical = new ArrayList<>(backups); + if (Files.exists(previous, LinkOption.NOFOLLOW_LINKS)) historical.add(previous); + historical.sort(newest); + Path best = firstEvidence(historical); + if (best != null && !best.equals(previous)) { + Path displaced = root.resolve(".previous-" + UUID.randomUUID()); + if (Files.exists(previous, LinkOption.NOFOLLOW_LINKS)) { + move(previous, displaced, false); + syncDirectory(root); + backups.add(displaced); + } + move(best, previous, false); + syncDirectory(root); + backups.remove(best); + } + for (Path path : backups) deleteTree(path); + for (Path path : stages) deleteTree(path); + // An interrupted atomic log rewrite is not a separate run generation. + pruneWriteTemps(current); + pruneWriteTemps(previous); + syncDirectory(root); + } + + private static Path firstEvidence(List candidates) { + for (Path path : candidates) { + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) + && (Files.isRegularFile(path.resolve("run.log"), LinkOption.NOFOLLOW_LINKS) + || Files.isRegularFile(path.resolve("bootstrap.log"), LinkOption.NOFOLLOW_LINKS))) + return path; + } + return null; + } + + private static long evidenceTime(Path path) { + try { + return Math.max(Files.exists(path.resolve("run.log")) + ? Files.getLastModifiedTime(path.resolve("run.log")).toMillis() : 0, + Files.exists(path.resolve("bootstrap.log")) + ? Files.getLastModifiedTime(path.resolve("bootstrap.log")).toMillis() : 0); + } catch (IOException | RuntimeException ignored) { + return 0; + } + } + + private static int recoveryPriority(Path path) { + String name = path.getFileName().toString(); + if (name.startsWith(".current-")) return 0; + if (name.equals("previous")) return 1; + if (name.startsWith(".previous-")) return 2; + return 3; + } + + private static void pruneWriteTemps(Path directory) throws IOException { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) return; + try (DirectoryStream entries = Files.newDirectoryStream(directory, ".run-*.tmp")) { + for (Path path : entries) deleteTree(path); + } + } + + private static void validateDirectory(Path path) throws IOException { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return; + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Expected a private directory: " + path); + privateDirectory(path); + } + + private static Path privateTempDirectory(Path parent, String prefix) throws IOException { + Path path; + try { + path = Files.createTempDirectory(parent, prefix, + PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rwx------"))); + } catch (UnsupportedOperationException unsupported) { + path = Files.createTempDirectory(parent, prefix); + } + privateDirectory(path); + return path; + } + + private static Path privateTempFile(Path parent, String prefix, String suffix) + throws IOException { + Path path; + try { + path = Files.createTempFile(parent, prefix, suffix, + PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rw-------"))); + } catch (UnsupportedOperationException unsupported) { + path = Files.createTempFile(parent, prefix, suffix); + } + privatePermissions(path, false); + return path; + } + + private static void privateFile(Path path, String text) throws IOException { + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute( + PosixFilePermissions.fromString("rw-------"))); + } catch (UnsupportedOperationException unsupported) { + Files.createFile(path); + } + privatePermissions(path, false); + durableWrite(path, text); + } + + private static void durableWrite(Path path, String text) throws IOException { + try (FileOutputStream output = new FileOutputStream(path.toFile())) { + output.write(text.getBytes(StandardCharsets.UTF_8)); + output.getFD().sync(); + } + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static void privateDirectory(Path path) throws IOException { + privatePermissions(path, true); + } + + private static void privatePermissions(Path path, boolean directory) throws IOException { + try { + Files.setPosixFilePermissions(path, + PosixFilePermissions.fromString(directory ? "rwx------" : "rw-------")); + } catch (UnsupportedOperationException unsupported) { + File file = path.toFile(); + if (!file.setReadable(false, false) || !file.setWritable(false, false) + || !file.setExecutable(false, false) + || !file.setReadable(true, true) || !file.setWritable(true, true) + || (directory && !file.setExecutable(true, true))) + throw new IOException("Cannot make diagnostic path private: " + path); + } + } + + private static void move(Path from, Path to, boolean replace) throws IOException { + try { + if (replace) Files.move(from, to, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + else Files.move(from, to, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + if (replace) Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); + else Files.move(from, to); + } + } + + private static void deleteQuietly(Path path) { + if (path == null) return; + try { + deleteTree(path); + } catch (IOException | RuntimeException ignored) { + // Failed cleanup must not hide a result or destroy retained logs. + } + } + + private static void deleteTree(Path path) throws IOException { + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + try (DirectoryStream entries = Files.newDirectoryStream(path)) { + for (Path entry : entries) deleteTree(entry); + } + } + Files.deleteIfExists(path); + } + + private static String section(String label, String path, String text) { + return "\n=== " + label + " log: diagnostics/" + path + " ===\n" + + text + (text.endsWith("\n") ? "" : "\n"); + } + + private static String readText(Path path, int limit) { + try { + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) + return "(No log recorded.)\n"; + return readBounded(path, limit); + } catch (Exception e) { + return "[Diagnostic logging failed: read " + path.getFileName() + ": " + + singleLine(e.toString(), 1400) + "]\n"; + } + } + + private static String readBounded(Path path, int limit) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Expected a regular diagnostic file: " + path); + try (InputStream input = Files.newInputStream(path); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(Math.min(limit, 8192))) { + byte[] block = new byte[4096]; + while (bytes.size() < limit) { + int count = input.read(block, 0, Math.min(block.length, limit - bytes.size())); + if (count < 0) break; + bytes.write(block, 0, count); + } + byte[] value = bytes.toByteArray(); + int end = value.length; + // A concurrent native append may leave the final UTF-8 sequence incomplete. + if (end > 0) { + int lead = end - 1; + while (lead >= 0 && (value[lead] & 0xc0) == 0x80) lead--; + if (lead >= 0) { + int first = value[lead] & 0xff; + int expected = first < 0x80 ? 1 : first < 0xe0 ? 2 : first < 0xf0 ? 3 : 4; + if (end - lead < expected) end = lead; + } + } + return new String(value, 0, end, StandardCharsets.UTF_8); + } + } + + static String tailUtf8(String value, int limit) { + if (limit <= 0) return ""; + if (value.length() > limit) { + int start = value.length() - limit; + if (Character.isLowSurrogate(value.charAt(start))) start++; + value = value.substring(start); + } + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= limit) return value; + int start = bytes.length - limit; + while (start < bytes.length && (bytes[start] & 0xc0) == 0x80) start++; + return new String(bytes, start, bytes.length - start, StandardCharsets.UTF_8); + } + + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + private static String singleLine(String value, int limit) { + if (value == null) return "unknown"; + return tailUtf8(value.replace('\n', ' ').replace('\r', ' '), limit); + } +} diff --git a/app/src/main/java/df/root/RunService.java b/app/src/main/java/df/root/RunService.java new file mode 100644 index 00000000..e71a1017 --- /dev/null +++ b/app/src/main/java/df/root/RunService.java @@ -0,0 +1,213 @@ +package df.root; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.Log; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** Foreground lifetime for one coordinator-owned run; never starts native work. */ +public final class RunService extends Service { + private static final String TAG = "dfroot"; + private static final String CHANNEL_ID = "dfroot_run"; + private static final String EXTRA_TOKEN = "df.root.RUN_TOKEN"; + private static final int NOTIFICATION_ID = 1; + private static final long READY_TIMEOUT_SECONDS = 5; + private static final Object LOCK = new Object(); + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + + // These references never leave the process. A redelivered intent cannot + // create a lease or restart work after process death. + private static Lease active; + private static RunService currentService; + private Lease ownedLease; + private int latestStartId; + private boolean destroyed; + + /** Called on the coordinator worker, before creating any native resources. */ + static Lease acquire(Context app) throws Exception { + if (Looper.myLooper() == Looper.getMainLooper()) + throw new IllegalStateException("Foreground readiness must not block the main thread"); + Lease lease = new Lease(); + synchronized (LOCK) { + if (active != null) + throw new IllegalStateException("A foreground run is already owned"); + active = lease; + } + boolean acquired = false; + try { + Intent intent = new Intent(app, RunService.class).putExtra(EXTRA_TOKEN, lease.token); + if (app.startForegroundService(intent) == null) + throw new IllegalStateException("Foreground service could not be started"); + if (!lease.ready.await(READY_TIMEOUT_SECONDS, TimeUnit.SECONDS)) + throw new TimeoutException("Foreground service was not ready; launch skipped"); + synchronized (LOCK) { + if (lease.failure != null) throw lease.failure; + if (active != lease || lease.closed || lease.owner == null + || lease.owner.destroyed) + throw new IllegalStateException("Foreground service stopped before launch"); + acquired = true; + return lease; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } finally { + if (!acquired) lease.close(); + } + } + + /** Close only when the runner has confirmed that its native child exited. */ + static final class Lease implements AutoCloseable { + private final String token = UUID.randomUUID().toString(); + private final CountDownLatch ready = new CountDownLatch(1); + // All fields below are guarded by LOCK. + private RunService owner; + private Exception failure; + private boolean closed; + + private Lease() {} + + @Override + public void close() { + final RunService service; + synchronized (LOCK) { + if (closed) return; + closed = true; + if (active == this) active = null; + service = currentService; + ready.countDown(); + } + // Main-thread cleanup rechecks both the instance and active lease. + // An old close must not stop a newly acquired run on the same service. + if (service != null) MAIN.post(service::stopIfIdle); + } + } + + @Override + public void onCreate() { + super.onCreate(); + synchronized (LOCK) { + currentService = this; + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + final Lease lease; + synchronized (LOCK) { + latestStartId = startId; + String token = intent == null ? null : intent.getStringExtra(EXTRA_TOKEN); + lease = active != null && active.token.equals(token) ? active : null; + if (lease != null) { + ownedLease = lease; + lease.owner = this; + } + } + if (lease == null) { + // Cancelled, stale or redelivered starts never execute work. + stopIfIdle(); + return START_NOT_STICKY; + } + try { + Notification notification = createNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE); + } else { + startForeground(NOTIFICATION_ID, notification); + } + synchronized (LOCK) { + if (active == lease && !lease.closed && !destroyed) { + lease.ready.countDown(); + Log.i(TAG, "run service: foreground owner ready"); + } + } + // Readiness might have timed out while Android promoted the service. + stopIfIdle(); + } catch (RuntimeException e) { + synchronized (LOCK) { + lease.failure = e; + lease.ready.countDown(); + } + Log.e(TAG, "run service: foreground promotion failed; launch skipped", e); + lease.close(); + stopIfIdle(); + } + return START_NOT_STICKY; + } + + private Notification createNotification() { + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager == null) throw new IllegalStateException("NotificationManager unavailable"); + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, + "DFRoot startup", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("Shown while DFRoot is starting"); + manager.createNotificationChannel(channel); + Intent open = new Intent(this, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent content = PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_monochrome) + .setContentTitle("DFRoot is starting") + .setContentText("Open DFRoot to view progress") + .setContentIntent(content) + .setCategory(Notification.CATEGORY_PROGRESS) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + .build(); + } + + /** Main thread only; stop IDs also protect starts not delivered to us yet. */ + private void stopIfIdle() { + synchronized (LOCK) { + if (destroyed || currentService != this || active != null) return; + if (ownedLease != null && !ownedLease.closed) return; + if (latestStartId != 0 && stopSelfResult(latestStartId)) { + stopForeground(STOP_FOREGROUND_REMOVE); + ownedLease = null; + Log.i(TAG, "run service: foreground owner released"); + } + } + } + + @Override + public void onDestroy() { + synchronized (LOCK) { + destroyed = true; + if (currentService == this) currentService = null; + if (ownedLease != null && ownedLease.owner == this) { + ownedLease.owner = null; + if (!ownedLease.closed) { + ownedLease.failure = new IllegalStateException("Foreground owner was destroyed"); + ownedLease.ready.countDown(); + Log.w(TAG, "run service: owner destroyed during a run; no automatic retry"); + } + ownedLease = null; + } + } + // Android removes this service's notification. Do not cancel by shared + // notification ID here: a newer service instance may already own it. + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/df/root/SettingsFragment.java b/app/src/main/java/df/root/SettingsFragment.java index 7497e0c9..a712859d 100644 --- a/app/src/main/java/df/root/SettingsFragment.java +++ b/app/src/main/java/df/root/SettingsFragment.java @@ -1,5 +1,6 @@ package df.root; +import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.Context; import android.content.SharedPreferences; @@ -7,8 +8,11 @@ import android.os.Bundle; import android.preference.PreferenceFragment; import android.preference.SwitchPreference; +import android.widget.Toast; public class SettingsFragment extends PreferenceFragment { + private RunCoordinator runs; + private SharedPreferences dePrefs; @Override public void onCreate(Bundle savedInstanceState) { @@ -16,7 +20,8 @@ public void onCreate(Bundle savedInstanceState) { addPreferencesFromResource(R.xml.preferences); Context ctx = getActivity(); - SharedPreferences dePrefs = ctx.createDeviceProtectedStorageContext() + runs = AppRuns.get(ctx); + dePrefs = ctx.createDeviceProtectedStorageContext() .getSharedPreferences(ExploitRunner.PREFS_NAME, Context.MODE_PRIVATE); ComponentName bootReceiver = new ComponentName(ctx, BootReceiver.class); @@ -25,27 +30,68 @@ public void onCreate(Bundle savedInstanceState) { int state = ctx.getPackageManager().getComponentEnabledSetting(bootReceiver); bootPref.setChecked(state == PackageManager.COMPONENT_ENABLED_STATE_ENABLED); bootPref.setOnPreferenceChangeListener((pref, value) -> { - ctx.getPackageManager().setComponentEnabledSetting(bootReceiver, - (Boolean) value ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED - : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP); - return true; + try { + if ((Boolean) value) { + String manager = dePrefs.getString(ExploitRunner.PREF_SU_MANAGER, null); + if (manager == null || !ExploitRunner.hasKsud( + ctx.getPackageManager().getApplicationInfo(manager, 0))) { + throw new IllegalStateException("Select an available SU manager first"); + } + } + boolean changed = runs.whileIdle(() -> + ctx.getPackageManager().setComponentEnabledSetting(bootReceiver, + (Boolean) value ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED + : PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP)); + if (!changed) showError("Settings cannot change during a run"); + return changed; + } catch (Exception e) { + showError("Could not change Start at Boot: " + e.getMessage()); + return false; + } }); SwitchPreference softRebootPref = (SwitchPreference) findPreference("soft_reboot"); softRebootPref.setPersistent(false); softRebootPref.setChecked(dePrefs.getBoolean("soft_reboot", false)); - softRebootPref.setOnPreferenceChangeListener((pref, value) -> { - dePrefs.edit().putBoolean("soft_reboot", (Boolean) value).apply(); - return true; - }); + softRebootPref.setOnPreferenceChangeListener((pref, value) -> + saveBoolean("soft_reboot", (Boolean) value)); SwitchPreference disableModulesPref = (SwitchPreference) findPreference("disable_modules"); disableModulesPref.setPersistent(false); disableModulesPref.setChecked(dePrefs.getBoolean("disable_modules", false)); - disableModulesPref.setOnPreferenceChangeListener((pref, value) -> { - dePrefs.edit().putBoolean("disable_modules", (Boolean) value).apply(); - return true; + disableModulesPref.setOnPreferenceChangeListener((pref, value) -> + saveBoolean("disable_modules", (Boolean) value)); + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().setEnabled(!runs.isRunning()); + ((SwitchPreference) findPreference("soft_reboot")).setChecked( + dePrefs.getBoolean("soft_reboot", false)); + ((SwitchPreference) findPreference("disable_modules")).setChecked( + dePrefs.getBoolean("disable_modules", false)); + } + + // Bootstrap reads the XML directly; report persistence failure to the user. + @SuppressLint("ApplySharedPref") + private boolean saveBoolean(String key, boolean value) { + boolean oldValue = dePrefs.getBoolean(key, false); + boolean[] saved = {false}; + boolean idle = runs.whileIdle(() -> { + saved[0] = dePrefs.edit().putBoolean(key, value).commit(); + if (!saved[0]) { + // commit() can update memory even if disk persistence failed. + dePrefs.edit().putBoolean(key, oldValue).commit(); + } }); + if (!idle) showError("Settings cannot change during a run"); + else if (!saved[0]) showError("Could not save this setting"); + return idle && saved[0]; + } + + private void showError(String text) { + Toast.makeText(getActivity(), text, Toast.LENGTH_LONG).show(); } } diff --git a/app/src/main/jni/CMakeLists.txt b/app/src/main/jni/CMakeLists.txt index a6a0bc79..3cf574f6 100644 --- a/app/src/main/jni/CMakeLists.txt +++ b/app/src/main/jni/CMakeLists.txt @@ -50,6 +50,7 @@ target_link_libraries(dfroot log) target_link_options(dfroot PRIVATE -Wl,-z,max-page-size=16384) add_executable(bootstrap bootstrap.c) +target_link_libraries(bootstrap log) set_target_properties(bootstrap PROPERTIES PREFIX "lib" SUFFIX ".so" diff --git a/app/src/main/jni/bootstrap.c b/app/src/main/jni/bootstrap.c index b05447c4..5bb6465b 100644 --- a/app/src/main/jni/bootstrap.c +++ b/app/src/main/jni/bootstrap.c @@ -1,5 +1,8 @@ +#include #include +#include #include +#include #include #include #include @@ -8,6 +11,8 @@ #include #include +#include "bootstrap_log.h" + #define BLKROSET 0x125d #define KSUD_STAGE "/data/user_de/0/df.root/ksud" #define KSUD "/data/adb/ksud" @@ -25,54 +30,142 @@ static int pref_true(const char *buf, const char *key) return v && tag_end && v < tag_end; } -static int read_prefs(char *su_manager, size_t su_manager_size, int *soft_reboot, +/* Read through EOF, including a probe beyond the buffer limit. A successful + * short read does not establish that a file is complete. */ +static int read_bounded(int fd, char *buf, size_t capacity, size_t *length) +{ + size_t used = 0; + for (;;) { + char extra; + ssize_t n = read(fd, used < capacity - 1 ? buf + used : &extra, + used < capacity - 1 ? capacity - 1 - used : 1); + if (n < 0 && errno == EINTR) + continue; + if (n < 0) + return -1; + if (!n) + break; + if (used == capacity - 1) { + errno = EOVERFLOW; + return -1; + } + used += (size_t)n; + } + buf[used] = '\0'; + *length = used; + return 0; +} + +static int read_prefs(const char *path, char *su_manager, size_t su_manager_size, int *soft_reboot, int *disable_modules) { - int fd = open(PREFS_PATH, O_RDONLY); - if (fd < 0) return -1; + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + if (fd < 0) { + bootstrap_log_error("prefs", "open", errno); + return -1; + } char buf[4096]; - int n = read(fd, buf, sizeof(buf) - 1); - close(fd); - if (n <= 0) return -1; - buf[n] = '\0'; + size_t n = 0; + struct stat st; + int error = 0; + if (fstat(fd, &st) < 0) + error = errno; + else if (!S_ISREG(st.st_mode)) + error = EINVAL; + else if (read_bounded(fd, buf, sizeof(buf), &n) < 0) + error = errno; + if (close(fd) < 0 && !error) + error = errno; + if (error) { + bootstrap_log_error("prefs", "read_complete_file", error); + return -1; + } + if (!n || memchr(buf, '\0', n) || !strstr(buf, "")) { + bootstrap_log(ANDROID_LOG_ERROR, "prefs", "invalid or incomplete preferences"); + return -1; + } char *p = strstr(buf, "name=\"su_manager\">"); - if (!p) return -1; + if (!p) { + bootstrap_log(ANDROID_LOG_ERROR, "prefs", "missing su_manager"); + return -1; + } p += strlen("name=\"su_manager\">"); char *end = strchr(p, '<'); - if (!end) return -1; - size_t len = end - p; - if (len == 0 || len >= su_manager_size) return -1; + size_t len = end ? (size_t)(end - p) : 0; + if (!end || len == 0 || len >= su_manager_size) { + bootstrap_log(ANDROID_LOG_ERROR, "prefs", "invalid su_manager length"); + return -1; + } memcpy(su_manager, p, len); su_manager[len] = '\0'; *soft_reboot = pref_true(buf, "soft_reboot"); *disable_modules = pref_true(buf, "disable_modules"); + bootstrap_log(ANDROID_LOG_INFO, "prefs", "complete soft_reboot=%d disable_modules=%d", + *soft_reboot, *disable_modules); return 0; } static int adopt_zygote_env(void) { FILE *f = popen("pidof zygote64 zygote", "r"); - if (!f) return -1; + if (!f) { + bootstrap_log_error("environment", "popen", errno); + return -1; + } int pid = 0; - fscanf(f, "%d", &pid); - pclose(f); - if (!pid) return -1; + errno = 0; + int parsed = fscanf(f, "%d", &pid); + int stream_error = ferror(f) ? (errno ? errno : EIO) : 0; + int status = pclose(f); + if (status < 0) { + bootstrap_log_error("environment", "pclose", errno); + return -1; + } + if (stream_error) { + bootstrap_log_error("environment", "read_pid", stream_error); + return -1; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + bootstrap_log(ANDROID_LOG_WARN, "environment", "pidof exit=%d signal=%d", + WIFEXITED(status) ? WEXITSTATUS(status) : -1, + WIFSIGNALED(status) ? WTERMSIG(status) : 0); + return -1; + } + if (parsed != 1 || pid <= 0) { + bootstrap_log(ANDROID_LOG_WARN, "environment", "pidof returned no valid pid"); + return -1; + } char path[32]; snprintf(path, sizeof(path), "/proc/%d/environ", pid); - int fd = open(path, O_RDONLY); - if (fd < 0) return -1; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + bootstrap_log_error("environment", "open_environ", errno); + return -1; + } static char buf[16384]; - int n = read(fd, buf, sizeof(buf) - 1); - close(fd); - if (n <= 0) return -1; - buf[n] = '\0'; + size_t n = 0; + int error = read_bounded(fd, buf, sizeof(buf), &n) < 0 ? errno : 0; + if (close(fd) < 0 && !error) + error = errno; + if (error) { + bootstrap_log_error("environment", "read_environ", error); + return -1; + } + if (!n || buf[n - 1] != '\0') { + bootstrap_log(ANDROID_LOG_WARN, "environment", "empty or incomplete environment"); + return -1; + } for (char *p = buf, *end = buf + n; p < end; p += strlen(p) + 1) - putenv(p); + if (*p && putenv(p) < 0) { + bootstrap_log_error("environment", "putenv", errno); + return -1; + } + bootstrap_log(ANDROID_LOG_INFO, "environment", "complete"); return 0; } @@ -88,147 +181,373 @@ static int should_ro(const char *name) return 0; } -static int set_partitions_ro(void) +static int set_partitions_ro(const char *directory) { - DIR *dir = opendir("/dev/block/by-name"); - if (!dir) + DIR *dir = opendir(directory); + if (!dir) { + bootstrap_log_error("partitions", "opendir", errno); return -1; + } struct dirent *ent; - while ((ent = readdir(dir))) { + unsigned attempted = 0, succeeded = 0, failed = 0; + for (;;) { + errno = 0; + ent = readdir(dir); + if (!ent) { + if (errno) { + bootstrap_log_error("partitions", "readdir", errno); + ++failed; + } + break; + } if (!should_ro(ent->d_name)) continue; - - char path[128]; - snprintf(path, sizeof(path), "/dev/block/by-name/%s", ent->d_name); - - int fd = open(path, O_RDONLY); - if (fd < 0) - continue; - + ++attempted; + /* Entries in by-name are intentionally symlinks to block devices. */ + int fd = openat(dirfd(dir), ent->d_name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + int error = fd < 0 ? errno : 0; struct stat st; - if (fstat(fd, &st) == 0 && S_ISBLK(st.st_mode)) { - int on = 1; - ioctl(fd, BLKROSET, &on); + const char *operation = "open"; + if (fd >= 0) { + operation = "fstat"; + if (fstat(fd, &st) < 0) + error = errno; + else if (!S_ISBLK(st.st_mode)) + error = ENOTBLK; + else { + int on = 1; + operation = "ioctl"; + if (ioctl(fd, BLKROSET, &on) < 0) + error = errno; + } + if (close(fd) < 0 && !error) { + error = errno; + operation = "close"; + } } - close(fd); + if (error) { + ++failed; + bootstrap_log(ANDROID_LOG_WARN, "partitions", "entry=%s op=%s errno=%d (%s)", + ent->d_name, operation, error, strerror(error)); + } else + ++succeeded; } - - closedir(dir); - return 0; + if (closedir(dir) < 0) { + bootstrap_log_error("partitions", "closedir", errno); + ++failed; + } + bootstrap_log(failed ? ANDROID_LOG_WARN : ANDROID_LOG_INFO, "partitions", + "complete attempted=%u succeeded=%u failed=%u", attempted, succeeded, failed); + return failed ? -1 : 0; } -static int late_load_running(void) +/* Return -1 for an incomplete scan; it must not be mistaken for completion. */ +static int late_load_running_at(const char *proc_dir) { - DIR *d = opendir("/proc"); - if (!d) - return 0; + DIR *d = opendir(proc_dir); + if (!d) { + bootstrap_log_error("late_load_wait", "opendir", errno); + return -1; + } struct dirent *e; - char path[64], buf[256]; - int found = 0; - while ((e = readdir(d)) != NULL) { + char buf[4096]; + int found = 0, error = 0; + for (;;) { + errno = 0; + e = readdir(d); + if (!e) { + if (errno) + error = errno; + break; + } char *end; strtol(e->d_name, &end, 10); - if (*end) continue; - snprintf(path, sizeof(path), "/proc/%s/cmdline", e->d_name); - int fd = open(path, O_RDONLY | O_CLOEXEC); - if (fd < 0) continue; - int n = read(fd, buf, sizeof(buf) - 1); - close(fd); - if (n <= 0) continue; - buf[n] = '\0'; - for (int i = 0; i < n; i++) + if (end == e->d_name || *end) + continue; + int process = openat(dirfd(d), e->d_name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (process < 0) { + if (errno != ENOENT && errno != ESRCH) + error = errno; + continue; + } + int fd = openat(process, "cmdline", O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + int open_error = fd < 0 ? errno : 0; + if (close(process) < 0) + error = errno; + if (fd < 0) { + if (open_error != ENOENT && open_error != ESRCH) + error = open_error; + continue; + } + size_t n = 0; + int read_error = read_bounded(fd, buf, sizeof(buf), &n) < 0 ? errno : 0; + if (close(fd) < 0) + error = errno; + if (read_error) { + if (read_error != ENOENT && read_error != ESRCH) + error = read_error; + continue; + } + for (size_t i = 0; i < n; i++) if (!buf[i]) buf[i] = ' '; if (strstr(buf, "ksud") && strstr(buf, "late-load")) { found = 1; break; } } - closedir(d); - return found; + if (closedir(d) < 0) + error = errno; + if (error) + bootstrap_log_error("late_load_wait", "scan_incomplete", error); + return found ? 1 : error ? -1 : 0; +} + +static int late_load_running(void) +{ + return late_load_running_at("/proc"); +} + +static int wait_for_late_load(int (*scan)(void)) +{ + struct timespec start, now; + if (clock_gettime(CLOCK_MONOTONIC, &start) < 0) { + bootstrap_log_error("late_load_wait", "clock", errno); + return -1; + } + bootstrap_log(ANDROID_LOG_INFO, "late_load_wait", "begin timeout_seconds=60"); + for (;;) { + int running = scan(); + if (clock_gettime(CLOCK_MONOTONIC, &now) < 0) { + bootstrap_log_error("late_load_wait", "clock", errno); + return -1; + } + if (now.tv_sec - start.tv_sec >= 60) { + bootstrap_log(ANDROID_LOG_WARN, "late_load_wait", + "timeout; soft reboot skipped"); + return -1; + } + if (running <= 0) { + bootstrap_log(running ? ANDROID_LOG_WARN : ANDROID_LOG_INFO, + "late_load_wait", running ? "unknown; soft reboot skipped" : "complete"); + return running; + } + struct timespec delay = { .tv_sec = 1 }; + /* An interruption returns to the monotonic deadline check. */ + if (nanosleep(&delay, NULL) < 0 && errno != EINTR) { + bootstrap_log_error("late_load_wait", "sleep", errno); + return -1; + } + } } -static int run_ctx(const char *ctx, char *const argv[]) +static int run_ctx(const char *stage, const char *ctx, char *const argv[]) { + bootstrap_log(ANDROID_LOG_INFO, stage, "launch executable=%s", argv[0]); + bootstrap_log_sync(); pid_t pid = fork(); - if (pid < 0) + if (pid < 0) { + bootstrap_log_error(stage, "fork", errno); return -1; + } if (pid == 0) { if (ctx) { - int fd = open("/proc/self/attr/exec", O_WRONLY); - if (fd >= 0) { write(fd, ctx, strlen(ctx)); close(fd); } + int fd = open("/proc/self/attr/exec", O_WRONLY | O_CLOEXEC); + if (fd < 0) { + bootstrap_log_error(stage, "context_open", errno); + _exit(126); + } + ssize_t n; + size_t length = strlen(ctx); + do { + n = write(fd, ctx, length); + } while (n < 0 && errno == EINTR); + int error = n < 0 ? errno : (size_t)n != length ? EIO : 0; + if (close(fd) < 0 && !error) + error = errno; + if (error) { + bootstrap_log_error(stage, "context_write_or_close", error); + _exit(126); + } } execv(argv[0], argv); + bootstrap_log_error(stage, "exec", errno); _exit(127); } - int status; - waitpid(pid, &status, 0); - return WIFEXITED(status) ? WEXITSTATUS(status) : -1; + int status = 0; + pid_t waited; + do { + waited = waitpid(pid, &status, 0); + } while (waited < 0 && errno == EINTR); + if (waited < 0) { + bootstrap_log_error(stage, "waitpid", errno); + return -1; + } + if (WIFEXITED(status)) { + int result = WEXITSTATUS(status); + bootstrap_log(result ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO, + stage, "child=%ld launcher_exit=%d", (long)pid, result); + return result; + } + if (WIFSIGNALED(status)) { + bootstrap_log(ANDROID_LOG_ERROR, stage, "child=%ld signal=%d", + (long)pid, WTERMSIG(status)); + return 128 + WTERMSIG(status); + } + bootstrap_log(ANDROID_LOG_ERROR, stage, "child=%ld unexpected_status=%d", + (long)pid, status); + return -1; } static void touch(const char *path) { - int fd = open(path, O_CREAT | O_WRONLY, 0666); - if (fd >= 0) - close(fd); + bootstrap_log_sync(); + int fd = open(path, O_CREAT | O_WRONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, 0666); + if (fd < 0) + bootstrap_log_error("marker", path, errno); + else if (close(fd) < 0) + bootstrap_log_error("marker", "close", errno); } /* Mark every installed module disabled before ksud runs. A broken module * otherwise loads on the next boot and bootloops the device. */ -static int disable_modules(void) +static int disable_modules(const char *modules_dir) { - DIR *dir = opendir(MODULES_DIR); - if (!dir) + int root = open(modules_dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (root < 0) { + int error = errno; + if (error == ENOENT) { + bootstrap_log(ANDROID_LOG_INFO, "modules", "directory absent; no modules to disable"); + return 0; + } + bootstrap_log_error("modules", "cannot open directory", error); + return -1; + } + DIR *dir = fdopendir(root); + if (!dir) { + int error = errno; + close(root); + bootstrap_log_error("modules", "fdopendir", error); return -1; + } struct dirent *ent; - while ((ent = readdir(dir))) { + int result = 0; + unsigned disabled = 0; + for (;;) { + errno = 0; + ent = readdir(dir); + if (!ent) { + int error = errno; + if (error) { + bootstrap_log_error("modules", "readdir", error); + result = -1; + } + break; + } if (ent->d_name[0] == '.') continue; - char path[256]; - snprintf(path, sizeof(path), MODULES_DIR "/%s/disable", ent->d_name); - touch(path); + + struct stat st; + if (fstatat(dirfd(dir), ent->d_name, &st, AT_SYMLINK_NOFOLLOW) < 0) { + int error = errno; + bootstrap_log_error("modules", "inspect directory entry", error); + result = -1; + break; + } + if (!S_ISDIR(st.st_mode)) + continue; + + int module = openat(dirfd(dir), ent->d_name, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (module < 0) { + bootstrap_log_error("modules", "open module directory", errno); + result = -1; + break; + } + int fd = openat(module, "disable", + O_CREAT | O_WRONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, 0666); + int error = fd < 0 ? errno : 0; + if (fd >= 0) { + if (fstat(fd, &st) < 0) + error = errno; + else if (!S_ISREG(st.st_mode) || st.st_nlink != 1) + error = EINVAL; + if (close(fd) < 0 && !error) + error = errno; + } + if (close(module) < 0 && !error) + error = errno; + if (error) { + bootstrap_log(ANDROID_LOG_ERROR, "modules", + "cannot disable entry=%s errno=%d (%s)", + ent->d_name, error, strerror(error)); + result = -1; + break; + } + ++disabled; } - closedir(dir); - return 0; + if (closedir(dir) < 0) { + bootstrap_log_error("modules", "closedir", errno); + result = -1; + } + bootstrap_log(result ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO, "modules", + "complete disabled=%u result=%d", disabled, result); + return result; } int main(void) { + bootstrap_log_open(BOOTSTRAP_APP_DIR); + bootstrap_log(ANDROID_LOG_INFO, "start", "begin"); + bootstrap_log(ANDROID_LOG_INFO, "prefs", "begin"); touch("/dev/dfm1"); char su_manager[256]; int soft_reboot, disable_mods; - if (read_prefs(su_manager, sizeof(su_manager), &soft_reboot, &disable_mods) != 0) { + if (read_prefs(PREFS_PATH, su_manager, sizeof(su_manager), &soft_reboot, &disable_mods) != 0) { touch("/dev/dfme0"); - return 1; + return bootstrap_finish(1); } + bootstrap_log(ANDROID_LOG_INFO, "environment", "begin"); touch("/dev/dfm2"); if (adopt_zygote_env()) touch("/dev/dfmw0"); + bootstrap_log(ANDROID_LOG_INFO, "partitions", "begin"); touch("/dev/dfm3"); - if (set_partitions_ro()) + if (set_partitions_ro("/dev/block/by-name")) touch("/dev/dfmw1"); if (disable_mods) { + bootstrap_log(ANDROID_LOG_INFO, "modules", "begin"); touch("/dev/dfm4"); - if (disable_modules()) { + if (disable_modules(MODULES_DIR)) { touch("/dev/dfme1"); - return 1; + return bootstrap_finish(1); } - } + } else + bootstrap_log(ANDROID_LOG_INFO, "modules", "skipped"); - touch("/dev/dfm5"); - if (run_ctx(NULL, (char *[]){ KSUD_STAGE, "late-load", "--package-name", su_manager, NULL }) == 0) { - touch("/dev/dfm6"); - if (soft_reboot) { - while (late_load_running()) sleep(1); - run_ctx("u:r:ksu:s0", (char *[]){ KSUD, "soft-reboot", NULL }); - } - } else { + touch("/dev/dfm5"); + if (run_ctx("late_load", NULL, + (char *[]){ KSUD_STAGE, "late-load", "--package-name", su_manager, NULL }) != 0) { touch("/dev/dfme2"); + return bootstrap_finish(1); } - - return 0; + /* Legacy marker reports launcher exit only, not daemon/root health. */ + bootstrap_log(ANDROID_LOG_INFO, "late_load", "launcher completed; daemon health unverified"); + touch("/dev/dfm6"); + if (soft_reboot) { + if (wait_for_late_load(late_load_running) != 0) + return bootstrap_finish(1); + bootstrap_log(ANDROID_LOG_INFO, "soft_reboot", "requesting reboot; outcome unverified"); + bootstrap_log_sync(); + if (run_ctx("soft_reboot", "u:r:ksu:s0", (char *[]){ KSUD, "soft-reboot", NULL }) != 0) + return bootstrap_finish(1); + } else + bootstrap_log(ANDROID_LOG_INFO, "soft_reboot", "skipped"); + + return bootstrap_finish(0); } diff --git a/app/src/main/jni/bootstrap_log.h b/app/src/main/jni/bootstrap_log.h new file mode 100644 index 00000000..ddb5c5f6 --- /dev/null +++ b/app/src/main/jni/bootstrap_log.h @@ -0,0 +1,207 @@ +#ifndef DFROOT_BOOTSTRAP_LOG_H +#define DFROOT_BOOTSTRAP_LOG_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BOOTSTRAP_LOG_LIMIT (64 * 1024) +#define BOOTSTRAP_APP_DIR "/data/user_de/0/df.root" +#define BOOTSTRAP_LOG_TRUNCATED \ + "bootstrap stage=diagnostics TRUNCATED: 64 KiB limit; further records in logcat\n" + +static int bootstrap_log_fd = -1; +static int bootstrap_log_capped = 0; + +/* The app creates this private file. Never create or follow a privileged + * logging target, and never inherit its descriptor into another executable. */ +static int bootstrap_log_open(const char *app_dir) +{ + int saved_errno = errno, error = 0; + if (bootstrap_log_fd >= 0) { + close(bootstrap_log_fd); + bootstrap_log_fd = -1; + } + bootstrap_log_capped = 0; + int directory = open(app_dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + int file = -1; + struct stat st; + uid_t owner = 0; + if (directory < 0) { + error = errno; + goto done; + } + if (fstat(directory, &st) < 0) { + error = errno; + goto done; + } + owner = st.st_uid; + if (!S_ISDIR(st.st_mode) || owner == 0) { + error = EPERM; + goto done; + } + const char *parts[] = { "files", "diagnostics", "current" }; + for (size_t i = 0; i < sizeof(parts) / sizeof(parts[0]); ++i) { + int next = openat(directory, parts[i], + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) { + error = errno; + goto done; + } + close(directory); + directory = next; + if (fstat(directory, &st) < 0) { + error = errno; + goto done; + } + if (!S_ISDIR(st.st_mode) || st.st_uid != owner) { + error = EPERM; + goto done; + } + } + file = openat(directory, "bootstrap.log", + O_WRONLY | O_APPEND | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (file < 0) { + error = errno; + goto done; + } + if (fstat(file, &st) < 0) { + error = errno; + goto done; + } + if (!S_ISREG(st.st_mode) || st.st_uid != owner || st.st_nlink != 1 || + (st.st_mode & 07777) != 0600) { + error = EPERM; + goto done; + } + if (st.st_size < 0 || st.st_size >= BOOTSTRAP_LOG_LIMIT) { + error = EFBIG; + goto done; + } + bootstrap_log_fd = file; + file = -1; +done: + if (directory >= 0) + close(directory); + if (file >= 0) + close(file); + if (error) + __android_log_print(ANDROID_LOG_WARN, "dfroot", + "bootstrap stage=diagnostics op=open errno=%d (%s); logcat only", + error, strerror(error)); + errno = saved_errno; + return error ? -1 : 0; +} + +static void bootstrap_log_sink_failed(const char *operation, int error) +{ + if (bootstrap_log_fd >= 0) { + close(bootstrap_log_fd); + bootstrap_log_fd = -1; + } + __android_log_print(ANDROID_LOG_WARN, "dfroot", + "bootstrap stage=diagnostics op=%s errno=%d (%s); logcat only", + operation, error, strerror(error)); +} + +static int bootstrap_log_write(const char *bytes, size_t length) +{ + size_t written = 0; + while (written < length) { + ssize_t n = write(bootstrap_log_fd, bytes + written, length - written); + if (n < 0 && errno == EINTR) + continue; + if (n <= 0) { + bootstrap_log_sink_failed("write", n < 0 ? errno : EIO); + return -1; + } + written += (size_t)n; + } + return 0; +} + +static void bootstrap_log(int priority, const char *stage, const char *format, ...) +{ + int saved_errno = errno; + char message[768], line[1024]; + va_list args; + va_start(args, format); + vsnprintf(message, sizeof(message), format, args); + va_end(args); + /* Keep one bounded record per line, including names supplied by files. */ + for (char *p = message; *p; ++p) + if ((unsigned char)*p < 32 || (unsigned char)*p == 127) + *p = ' '; + struct timespec now = {0}; + clock_gettime(CLOCK_MONOTONIC, &now); + snprintf(line, sizeof(line), "bootstrap t=%lld.%03ld pid=%ld level=%d stage=%s %s\n", + (long long)now.tv_sec, now.tv_nsec / 1000000, + (long)getpid(), priority, stage, message); + __android_log_print(priority, "dfroot", "%s", line); + if (bootstrap_log_fd >= 0 && !bootstrap_log_capped) { + struct stat st; + size_t length = strlen(line); + const size_t reserve = sizeof(BOOTSTRAP_LOG_TRUNCATED) - 1; + if (fstat(bootstrap_log_fd, &st) < 0) { + bootstrap_log_sink_failed("stat", errno); + } else if (st.st_size < 0 || st.st_size > BOOTSTRAP_LOG_LIMIT || + length + reserve > BOOTSTRAP_LOG_LIMIT - (size_t)st.st_size) { + bootstrap_log_capped = 1; + if (st.st_size >= 0 && st.st_size <= BOOTSTRAP_LOG_LIMIT && + reserve <= BOOTSTRAP_LOG_LIMIT - (size_t)st.st_size) + bootstrap_log_write(BOOTSTRAP_LOG_TRUNCATED, reserve); + __android_log_print(ANDROID_LOG_WARN, "dfroot", + "bootstrap stage=diagnostics limit reached; logcat only"); + /* Keep the descriptor so the last records can still be fsynced. */ + } else { + bootstrap_log_write(line, length); + } + } + errno = saved_errno; +} + +static void bootstrap_log_error(const char *stage, const char *operation, int error) +{ + int saved_errno = errno; + bootstrap_log(ANDROID_LOG_ERROR, stage, "op=%s errno=%d (%s)", + operation, error, strerror(error)); + errno = saved_errno; +} + +static void bootstrap_log_sync(void) +{ + int saved_errno = errno; + if (bootstrap_log_fd >= 0) { + int rc; + do { + rc = fsync(bootstrap_log_fd); + } while (rc < 0 && errno == EINTR); + if (rc < 0) + bootstrap_log_sink_failed("sync", errno); + } + errno = saved_errno; +} + +static int bootstrap_finish(int result) +{ + int saved_errno = errno; + bootstrap_log(result ? ANDROID_LOG_ERROR : ANDROID_LOG_INFO, + "finish", "exit=%d", result); + bootstrap_log_sync(); + if (bootstrap_log_fd >= 0) { + int fd = bootstrap_log_fd; + bootstrap_log_fd = -1; + if (close(fd) < 0) + bootstrap_log_error("diagnostics", "close", errno); + } + errno = saved_errno; + return result; +} + +#endif diff --git a/app/src/main/jni/exp.c b/app/src/main/jni/exp.c index a91c3b16..15852cf3 100644 --- a/app/src/main/jni/exp.c +++ b/app/src/main/jni/exp.c @@ -16,8 +16,7 @@ #include #include #include "aes256.h" - -#define eos(s) (s + strlen(s)) +#include "native_safety.h" static const char kCrashDump[] = "/apex/com.android.runtime/bin/crash_dump64"; static char *libcxx_ko_target; @@ -54,7 +53,7 @@ static void compute_iv(const uint8_t old_content[16], const uint8_t desired[16], /* vfork + exec crash_dump64 (splicehelper); dup2 write_fd onto target_fd in child. * Pass mode="r" for read, NULL for write-splice. - * Returns waitpid status, or -1 if clone fails (already logged). + * Returns waitpid status, or -1 if clone or waiting fails (already logged). */ static int spawn_crash_dump(off_t offset, int write_fd, int target_fd, const char *mode) { @@ -70,9 +69,9 @@ static int spawn_crash_dump(off_t offset, int write_fd, int target_fd, const cha execl(kCrashDump, "crashdump64", offstr, libcxx_ko_target, NULL); _exit(1); } - int st = 0; - TEMP_FAILURE_RETRY(waitpid(pid, &st, 0)); - return st; + int status = native_wait_status(pid, waitpid); + if (status < 0) printf("waitpid failed: %s\n", strerror(errno)); + return status; } /* Read 16 bytes from vendor file at offset using crash_dump bridge (read mode). @@ -303,25 +302,10 @@ static const struct KoImage *select_ko_image(int andr, int major, int minor) { static int read_device_versions(int *andr, int *major, int *minor) { struct utsname u; if (uname(&u) != 0) return -1; - if (sscanf(u.release, "%d.%d", major, minor) != 2) return -1; - const char *m = strstr(u.release, "android"); - if (!m) { + if (native_kernel_version(u.release, major, minor)) return -1; + if (!strstr(u.release, "android")) printf("uname -r is missing Android version, trying getprop\n"); - - char vendor_ver[5]; - char release[15]; - - FILE *f = popen("getprop ro.vendor.build.version.release", "r"); - fgets(vendor_ver, sizeof(vendor_ver), f); - pclose(f); - - sprintf(release, "android"); - sprintf(eos(release), "%s", vendor_ver); - - m = release; - } - *andr = atoi(m + 7); - return (*andr > 0) ? 0 : -1; + return native_android_version(u.release, andr, popen, fgets, pclose); } /* Pad payload to a multiple of 16 bytes in a heap buffer. Caller must free(). */ @@ -381,15 +365,33 @@ static int patch_ko(void) { return ret; } -static void cleanup(void) { - if (!g_libcxx_r.valid) return; +static int restore_trampoline(void *context) { + struct PatchRestore *restore = context; + printf("restoring trampoline in %s\n", restore->lib); + int result = patch_file_cbc(restore->lib, (char *)restore->tramp_orig, 16, + (size_t)restore->tramp_aligned, 0); + if (result) + printf("cleanup: ERROR - trampoline restore failed (%d); " + "skipping dependent payload restore\n", result); + return result; +} + +static int restore_payload(void *context) { + struct PatchRestore *restore = context; + printf("restoring shellcode in %s\n", restore->lib); + int result = patch_file_cbc(restore->lib, restore->shell_orig, restore->shell_padded, + (size_t)restore->shell_off, 0); + if (result) + printf("cleanup: ERROR - payload restore failed (%d)\n", result); + return result; +} + +static int cleanup(void) { + if (!g_libcxx_r.valid) return NATIVE_RESTORE_OK; printf("\n=== cleanup ===\n"); - printf("restoring trampoline in %s\n", g_libcxx_r.lib); - patch_file_cbc(g_libcxx_r.lib, (char *)g_libcxx_r.tramp_orig, 16, - (size_t)g_libcxx_r.tramp_aligned, 0); - printf("restoring shellcode in %s\n", g_libcxx_r.lib); - patch_file_cbc(g_libcxx_r.lib, g_libcxx_r.shell_orig, g_libcxx_r.shell_padded, - (size_t)g_libcxx_r.shell_off, 0); + int result = native_restore_in_order(restore_trampoline, restore_payload, &g_libcxx_r); + if (result == NATIVE_RESTORE_OK) g_libcxx_r.valid = 0; + return result; } int find_hook_target(const char *lib, const char *sym, @@ -462,10 +464,16 @@ static int createOrphanProcess(void) { if (pid < 0) { printf("Failed to create orphan: %s\n", strerror(errno)); return -1; } if (pid == 0) { int pid2 = fork(); + if (pid2 < 0) _exit(1); if (pid2 == 0) { sleep(1); _exit(0); } _exit(0); } - TEMP_FAILURE_RETRY(waitpid(pid, NULL, 0)); + int status = native_wait_status(pid, waitpid); + if (status < 0) { + printf("Failed to wait for orphan launcher: %s\n", strerror(errno)); + return -1; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return -1; return 0; } @@ -552,7 +560,10 @@ static int exploit(void) { usleep(500000); printf("\n=== init ===\n"); - createOrphanProcess(); + if (createOrphanProcess()) { + printf("bootstrap: ERROR - launcher process failed\n"); + goto done; + } static const struct { const char *path; @@ -570,8 +581,8 @@ static int exploit(void) { { "/dev/dfm4", "bootstrap: disabling ksu modules", -1 }, { "/dev/dfme1", "bootstrap: ERROR - disable modules failed", 1 }, { "/dev/dfm5", "bootstrap: starting SU daemon", -1 }, - { "/dev/dfm6", "ksud start: SUCCESS", 0 }, - { "/dev/dfme2", "ksud start: ERROR - ksud nonzero exit", 1 }, + { "/dev/dfm6", "bootstrap: launcher returned; root unverified", 0 }, + { "/dev/dfme2", "bootstrap: ERROR - launcher failed", 1 }, }; int seen[sizeof(markers)/sizeof(markers[0])] = {0}; @@ -595,6 +606,8 @@ int main(int argc, char **argv) { setvbuf(stdout, NULL, _IONBF, 0); if (setup(argc, argv) != 0) return 2; int rc = exploit(); - cleanup(); - return rc; + int restore_status = cleanup(); + if (restore_status != NATIVE_RESTORE_OK) + printf("cleanup: ERROR - restoration incomplete; exiting with status 4\n"); + return native_exit_status(rc, restore_status); } diff --git a/app/src/main/jni/native_safety.h b/app/src/main/jni/native_safety.h new file mode 100644 index 00000000..4f4f6333 --- /dev/null +++ b/app/src/main/jni/native_safety.h @@ -0,0 +1,99 @@ +#ifndef DFROOT_NATIVE_SAFETY_H +#define DFROOT_NATIVE_SAFETY_H + +#include +#include +#include +#include +#include +#include +#include + +/* These helpers are independent of patching and accept callbacks for host tests. */ +static inline int native_decimal(const char *text, const char **end, int *value) +{ + if (*text < '0' || *text > '9') return -1; + errno = 0; + char *tail; + long parsed = strtol(text, &tail, 10); + if (errno == ERANGE || parsed > INT_MAX) return -1; + *end = tail; + *value = (int)parsed; + return 0; +} + +static inline int native_kernel_version(const char *release, int *major, int *minor) +{ + const char *end; + int parsed_major, parsed_minor; + if (native_decimal(release, &end, &parsed_major) || *end != '.' || + native_decimal(end + 1, &end, &parsed_minor) || parsed_major == 0) + return -1; + *major = parsed_major; + *minor = parsed_minor; + return 0; +} + +static inline int native_android_version( + const char *release, int *android_version, + FILE *(*open_command)(const char *, const char *), + char *(*read_line)(char *, int, FILE *), + int (*close_command)(FILE *)) +{ + const char *version = strstr(release, "android"); + char vendor_version[64]; + if (version) { + version += strlen("android"); + } else { + FILE *stream = open_command("getprop ro.vendor.build.version.release", "r"); + if (!stream) return -1; + char *read_result = read_line(vendor_version, sizeof(vendor_version), stream); + int close_result = close_command(stream); + if (!read_result || close_result != 0) return -1; + version = vendor_version; + } + const char *end; + int parsed; + if (native_decimal(version, &end, &parsed) || parsed == 0) return -1; + *android_version = parsed; + return 0; +} + +/* Return a real child status only after the requested child has been reaped. */ +static inline int native_wait_status( + pid_t pid, pid_t (*wait_child)(pid_t, int *, int)) +{ + int status; + pid_t result; + do { + result = wait_child(pid, &status, 0); + } while (result < 0 && errno == EINTR); + if (result < 0) return -1; + if (result != pid) { + errno = ECHILD; + return -1; + } + return status; +} + +enum native_restore_result { + NATIVE_RESTORE_OK = 0, + NATIVE_RESTORE_FIRST_FAILED, + NATIVE_RESTORE_SECOND_FAILED +}; + +/* The second restoration is safe only after the first has succeeded. */ +static inline int native_restore_in_order( + int (*restore_first)(void *), int (*restore_second)(void *), void *context) +{ + if (restore_first(context)) return NATIVE_RESTORE_FIRST_FAILED; + if (restore_second(context)) return NATIVE_RESTORE_SECOND_FAILED; + return NATIVE_RESTORE_OK; +} + +static inline int native_exit_status(int operation_status, int restore_status) +{ + return restore_status == NATIVE_RESTORE_OK ? operation_status : 4; +} + +#endif diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ad7aee6e..27659f4f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -33,6 +33,26 @@ + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 1d78e06b..e4c82402 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -1,5 +1,9 @@ + + + Save logs + Loading saved logs… + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index efdd2699..e44a4c86 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -4,18 +4,21 @@ + android:summary="Off by default. Attempt DFRoot once per device boot using the selected SU manager." + android:persistent="false" /> diff --git a/tests/ProcessOutputTest.java b/tests/ProcessOutputTest.java new file mode 100644 index 00000000..1f34c68d --- /dev/null +++ b/tests/ProcessOutputTest.java @@ -0,0 +1,377 @@ +package df.root; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** Host-only tests: no native executable, Android APIs, or device access. */ +public final class ProcessOutputTest { + private static final long TEST_TIMEOUT_SECONDS = 8; + + public static void main(String[] args) throws Exception { + reporterFailureStillDrains(); + eofBeforeExitStillWaits(); + interruptedWaitStillWaits(); + readerFailureStillWaits(); + delayedTrailingOutputIsDelivered(); + postExitDrainAndCloseAreBounded(); + newlineFreeOutputIsBounded(); + stdinCloseFailureDoesNotHideExit(); + System.out.println("ProcessOutput tests passed (8 cases)"); + } + + private static void reporterFailureStillDrains() throws Exception { + CountingInputStream output = new CountingInputStream(bytes("first\nsecond\nthird\n")); + FakeProcess child = new FakeProcess(output); + RuntimeException problem = new IllegalStateException("reporter failed"); + CountDownLatch reported = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + Invocation invocation = start(child, message -> { + calls.incrementAndGet(); + reported.countDown(); + throw problem; + }); + await(reported, "reporter called"); + await(output.eof, "output drained after reporter failure"); + check(!invocation.finished(), "reporter failure returned before child exit"); + check(output.closes.get() == 0, "stdout closed while child alive"); + child.exit(4); + invocation.finish(); + checkFailure(invocation, 4, problem); + check(calls.get() == 1, "failed reporter called again"); + check(output.count.get() == bytes("first\nsecond\nthird\n").length, "output lost"); + child.assertNotDestroyed(); + } + + private static void eofBeforeExitStillWaits() throws Exception { + CountingInputStream output = new CountingInputStream(bytes("done\n")); + FakeProcess child = new FakeProcess(output); + Invocation invocation = start(child, message -> {}); + await(output.eof, "output EOF"); + await(child.waitStarted, "child wait"); + check(!invocation.finished(), "EOF was mistaken for process exit"); + check(output.closes.get() == 0, "stdout closed at EOF before child exit"); + child.exit(23); + invocation.finish(); + check(invocation.failure.get() == null, "unexpected EOF failure"); + check(invocation.exitCode == 23, "nonzero child exit was lost"); + child.assertNotDestroyed(); + } + + private static void interruptedWaitStillWaits() throws Exception { + FakeProcess child = new FakeProcess(new ByteArrayInputStream(new byte[0])); + Invocation invocation = start(child, message -> {}); + await(child.waitStarted, "initial wait"); + invocation.thread.interrupt(); + await(child.waitInterrupted, "wait interruption"); + await(child.waitRetried, "wait retried after interruption"); + invocation.thread.interrupt(); + await(child.waitInterruptedAgain, "second wait interruption"); + await(child.waitRetriedAgain, "wait retried after second interruption"); + check(!invocation.finished(), "interruption released child ownership"); + child.exit(4); + invocation.finish(); + Throwable cause = checkFailure(invocation, 4, null).getCause(); + check(cause instanceof InterruptedException, + "interruption was not reported"); + check(cause.getSuppressed().length == 0, + "repeated interrupts accumulated redundant exceptions"); + check(invocation.interruptedAtReturn, "interrupt flag was not restored"); + child.assertNotDestroyed(); + } + + private static void readerFailureStillWaits() throws Exception { + IOException problem = new IOException("read failed"); + CountDownLatch read = new CountDownLatch(1); + AtomicInteger closes = new AtomicInteger(); + InputStream output = new InputStream() { + @Override public int read() throws IOException { + read.countDown(); + throw problem; + } + @Override public void close() { + closes.incrementAndGet(); + } + }; + FakeProcess child = new FakeProcess(output); + Invocation invocation = start(child, message -> {}); + await(read, "failing read"); + await(child.waitStarted, "child wait after read failure"); + check(!invocation.finished(), "reader failure returned before child exit"); + check(closes.get() == 0, "read failure closed stdout while child alive"); + child.exit(4); + invocation.finish(); + checkFailure(invocation, 4, problem); + child.assertNotDestroyed(); + } + + private static void delayedTrailingOutputIsDelivered() throws Exception { + GatedInputStream output = new GatedInputStream(bytes("trailing output")); + FakeProcess child = new FakeProcess(output); + StringBuffer messages = new StringBuffer(); + Invocation invocation = start(child, messages::append); + await(output.readStarted, "trailing read"); + child.exit(4); + await(child.exitObserved, "child exit observed"); + check(!invocation.finished(), "did not allow trailing output to drain"); + output.release.countDown(); + invocation.finish(); + check(invocation.failure.get() == null, "trailing output failed"); + check(invocation.exitCode == 4, "exit status changed while draining"); + check(messages.toString().equals("trailing output"), "trailing output missing"); + child.assertNotDestroyed(); + } + + private static void postExitDrainAndCloseAreBounded() throws Exception { + BlockingCloseInputStream output = new BlockingCloseInputStream(); + FakeProcess child = new FakeProcess(output); + Invocation invocation = start(child, message -> {}); + await(output.readStarted, "blocked read"); + long start = System.nanoTime(); + child.exit(9); + try { + invocation.finish(); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + check(elapsedMs >= 1500, "post-exit drain did not get its allowance"); + check(elapsedMs < 5000, "blocked read or close exceeded drain budget"); + ProcessOutput.Failure failure = checkFailure(invocation, 9, null); + check(failure.getCause() instanceof IOException, "incomplete output not reported"); + check(failure.getCause().getMessage().contains("did not finish draining"), + "incomplete-output diagnostic was lost"); + await(output.closeStarted, "asynchronous close"); + check(output.closeFinished.getCount() == 1, "close was not actually blocked"); + child.assertNotDestroyed(); + } finally { + output.releaseRead.countDown(); + output.releaseClose.countDown(); + await(output.closeFinished, "blocked close released"); + } + } + + private static void newlineFreeOutputIsBounded() throws Exception { + String text = "x".repeat(40_000) + "\n" + "y".repeat(9_000); + FakeProcess child = new FakeProcess(new ByteArrayInputStream(bytes(text))); + StringBuffer messages = new StringBuffer(); + AtomicInteger largest = new AtomicInteger(); + Invocation invocation = start(child, message -> { + largest.accumulateAndGet(message.length(), Math::max); + messages.append(message); + }); + child.exit(0); + invocation.finish(); + check(invocation.failure.get() == null, "long output failed"); + check(messages.toString().equals(text), "chunking changed output"); + check(largest.get() <= 4096, "unbounded reporter message"); + child.assertNotDestroyed(); + } + + private static void stdinCloseFailureDoesNotHideExit() throws Exception { + FakeProcess child = new FakeProcess(new ByteArrayInputStream(new byte[0])); + CountDownLatch closed = new CountDownLatch(1); + child.stdin = new OutputStream() { + @Override public void write(int value) {} + @Override public void close() throws IOException { + closed.countDown(); + throw new IOException("stdin close failed"); + } + }; + Invocation invocation = start(child, message -> {}); + await(closed, "stdin close attempt"); + child.exit(5); + invocation.finish(); + check(invocation.failure.get() == null, "stdin close hid native exit"); + check(invocation.exitCode == 5, "stdin close changed native exit"); + child.assertNotDestroyed(); + } + + private static Invocation start(FakeProcess child, IReporter reporter) { + Invocation invocation = new Invocation(child, reporter); + invocation.thread.start(); + return invocation; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static void await(CountDownLatch latch, String description) throws InterruptedException { + check(latch.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), "timed out: " + description); + } + + private static void check(boolean condition, String description) { + if (!condition) throw new AssertionError(description); + } + + private static ProcessOutput.Failure checkFailure( + Invocation invocation, int exitCode, Throwable expectedCause) { + Throwable error = invocation.failure.get(); + check(error instanceof ProcessOutput.Failure, "missing typed process failure"); + ProcessOutput.Failure failure = (ProcessOutput.Failure) error; + check(failure.exitCode == exitCode, "failure lost observed native exit status"); + check(failure.getMessage().contains("status " + exitCode), + "failure message omitted native exit status"); + if (expectedCause != null) { + check(failure.getCause() == expectedCause, "original failure cause was replaced"); + } + return failure; + } + + private static final class Invocation { + final AtomicReference failure = new AtomicReference<>(); + final Thread thread; + volatile int exitCode = Integer.MIN_VALUE; + volatile boolean interruptedAtReturn; + + Invocation(Process child, IReporter reporter) { + thread = new Thread(() -> { + try { + exitCode = ProcessOutput.waitFor(child, reporter); + } catch (Throwable error) { + failure.set(error); + } finally { + interruptedAtReturn = Thread.currentThread().isInterrupted(); + } + }, "test-process-owner"); + // A broken implementation must fail the test, not hang its JVM. + thread.setDaemon(true); + } + + boolean finished() { + return !thread.isAlive(); + } + + void finish() throws InterruptedException { + thread.join(TimeUnit.SECONDS.toMillis(TEST_TIMEOUT_SECONDS)); + check(finished(), "process owner did not finish"); + } + } + + private static final class FakeProcess extends Process { + final InputStream stdout; + OutputStream stdin = new ByteArrayOutputStream(); + final CountDownLatch exit = new CountDownLatch(1); + final CountDownLatch waitStarted = new CountDownLatch(1); + final CountDownLatch waitInterrupted = new CountDownLatch(1); + final CountDownLatch waitRetried = new CountDownLatch(1); + final CountDownLatch waitInterruptedAgain = new CountDownLatch(1); + final CountDownLatch waitRetriedAgain = new CountDownLatch(1); + final CountDownLatch exitObserved = new CountDownLatch(1); + final AtomicInteger waits = new AtomicInteger(); + final AtomicInteger destroys = new AtomicInteger(); + volatile int status; + + FakeProcess(InputStream stdout) { + this.stdout = stdout; + } + + void exit(int status) { + this.status = status; + exit.countDown(); + } + + @Override public OutputStream getOutputStream() { return stdin; } + @Override public InputStream getInputStream() { return stdout; } + @Override public InputStream getErrorStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override public int waitFor() throws InterruptedException { + int attempt = waits.incrementAndGet(); + if (attempt > 1) waitRetried.countDown(); + if (attempt > 2) waitRetriedAgain.countDown(); + waitStarted.countDown(); + try { + exit.await(); + } catch (InterruptedException e) { + waitInterrupted.countDown(); + if (attempt > 1) waitInterruptedAgain.countDown(); + throw e; + } + exitObserved.countDown(); + return status; + } + + @Override public int exitValue() { + if (exit.getCount() != 0) throw new IllegalThreadStateException("child is alive"); + return status; + } + + @Override public void destroy() { destroys.incrementAndGet(); } + @Override public Process destroyForcibly() { + destroys.incrementAndGet(); + return this; + } + + void assertNotDestroyed() { + check(destroys.get() == 0, "child was destroyed"); + } + } + + private static final class CountingInputStream extends InputStream { + final ByteArrayInputStream delegate; + final AtomicInteger count = new AtomicInteger(); + final AtomicInteger closes = new AtomicInteger(); + final CountDownLatch eof = new CountDownLatch(1); + + CountingInputStream(byte[] data) { delegate = new ByteArrayInputStream(data); } + + @Override public int read() { + int result = delegate.read(); + if (result == -1) eof.countDown(); + else count.incrementAndGet(); + return result; + } + + @Override public void close() { closes.incrementAndGet(); } + } + + private static final class GatedInputStream extends InputStream { + final CountDownLatch readStarted = new CountDownLatch(1); + final CountDownLatch release = new CountDownLatch(1); + final ByteArrayInputStream delegate; + + GatedInputStream(byte[] data) { delegate = new ByteArrayInputStream(data); } + + @Override public int read() throws IOException { + readStarted.countDown(); + waitForRelease(release); + return delegate.read(); + } + } + + private static final class BlockingCloseInputStream extends InputStream { + final CountDownLatch readStarted = new CountDownLatch(1); + final CountDownLatch releaseRead = new CountDownLatch(1); + final CountDownLatch closeStarted = new CountDownLatch(1); + final CountDownLatch releaseClose = new CountDownLatch(1); + final CountDownLatch closeFinished = new CountDownLatch(1); + + @Override public int read() throws IOException { + readStarted.countDown(); + waitForRelease(releaseRead); + return -1; + } + + @Override public void close() throws IOException { + closeStarted.countDown(); + waitForRelease(releaseClose); + closeFinished.countDown(); + } + } + + private static void waitForRelease(CountDownLatch latch) throws IOException { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } +} diff --git a/tests/RunCoordinatorTest.java b/tests/RunCoordinatorTest.java new file mode 100644 index 00000000..a6635976 --- /dev/null +++ b/tests/RunCoordinatorTest.java @@ -0,0 +1,594 @@ +package df.root; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public final class RunCoordinatorTest { + private static int assertions; + private static final List temporary = new ArrayList<>(); + private static final List failedAssertions = new ArrayList<>(); + + public static void main(String[] args) throws Exception { + try { + lazyConstructionAndQueuedAdmission(); + concurrentAdmissionAndCompletion(); + settingsAreSerializedWithAdmission(); + executorRejectionAndCallbacks(); + lateReportersCannotCorruptRuns(); + retentionExportAndReopen(); + unicodeBoundsAndTerminalReserve(); + liveMemorySurvivesShortOrFailedDiskWrites(); + generationFailurePreservesPrevious(); + failedGenerationDoesNotTouchPreviousNativeLog(); + incompleteTerminalAndLaunchCheckpoint(); + interruptedRotationRecovery(); + admissionDoesNotWaitForDiskReads(); + terminalStatusesAndFailures(); + check(failedAssertions.isEmpty(), + "assertions inside asynchronous work must also pass: " + failedAssertions); + System.out.println("RunCoordinator tests passed (" + assertions + " assertions)"); + } finally { + for (Path root : temporary) deleteTree(root); + } + } + + private static void lazyConstructionAndQueuedAdmission() throws Exception { + Path files = directory(); + QueuedExecutor executor = new QueuedExecutor(); + RunLogStore store = new RunLogStore(files.toFile()); + RunCoordinator runs = new RunCoordinator(executor, store, null); + check(!Files.exists(files.resolve("diagnostics")), "constructors must do no IO"); + AtomicInteger calls = new AtomicInteger(); + AtomicInteger completions = new AtomicInteger(); + check(runs.start("activity", log -> { + calls.incrementAndGet(); + log.report("first output\n"); + return 0; + }, completions::incrementAndGet), "first request admitted"); + check(runs.isRunning(), "busy before worker starts"); + check(!runs.start("boot", log -> { + throw new AssertionError("duplicate work ran"); + }, completions::incrementAndGet), "queued duplicate rejected"); + check(!runs.whileIdle(() -> { throw new AssertionError("busy mutation"); }), + "settings rejected while queued"); + check(calls.get() == 0 && executor.tasks.size() == 1, "only one task queued"); + check(completions.get() == 1, "rejected request completes once"); + check(runs.snapshot().status.equals("Starting"), "queued state is explicit"); + check(!Files.exists(files.resolve("diagnostics")), "queued snapshot creates no logs"); + executor.runNext(); + check(calls.get() == 1 && completions.get() == 2, "accepted request completes once"); + check(!runs.isRunning(), "gate released after completion"); + RunCoordinator.Snapshot view = runs.snapshot(); + contains(view.text, "first output", "completed output retained"); + contains(view.status, "root unverified", "launch is not root verification"); + check(runs.snapshot().revision == view.revision, "unchanged snapshots are stable"); + } + + private static void concurrentAdmissionAndCompletion() throws Exception { + Path files = directory(); + ExecutorService worker = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(12); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch submitted = new CountDownLatch(12); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(12); + AtomicInteger admitted = new AtomicInteger(); + AtomicInteger executions = new AtomicInteger(); + AtomicInteger completions = new AtomicInteger(); + AtomicInteger cleaned = new AtomicInteger(); + RunCoordinator runs = new RunCoordinator(worker, new RunLogStore(files.toFile()), null); + try { + for (int i = 0; i < 12; i++) { + callers.execute(() -> { + if (runs.start("concurrent", log -> { + executions.incrementAndGet(); + log.report("still running\n"); + entered.countDown(); + await(release); + cleaned.incrementAndGet(); + return 2; + }, () -> { + completions.incrementAndGet(); + finished.countDown(); + })) admitted.incrementAndGet(); + submitted.countDown(); + }); + } + await(submitted); + await(entered); + check(admitted.get() == 1 && executions.get() == 1, + "simultaneous callers execute one run"); + check(completions.get() == 11, "duplicates finish immediately"); + RunCoordinator.Snapshot oldUi = runs.snapshot(); + RunCoordinator.Snapshot recreatedUi = runs.snapshot(); + check(recreatedUi.running && oldUi.text.equals(recreatedUi.text), + "new UI receives the same running state and output"); + release.countDown(); + await(finished); + check(cleaned.get() == 1 && !runs.isRunning(), "completion follows work cleanup"); + check(completions.get() == 12, "all request completions run once"); + } finally { + release.countDown(); + worker.shutdownNow(); + callers.shutdownNow(); + worker.awaitTermination(5, TimeUnit.SECONDS); + callers.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static void settingsAreSerializedWithAdmission() throws Exception { + QueuedExecutor executor = new QueuedExecutor(); + RunCoordinator runs = new RunCoordinator(executor, + new RunLogStore(directory().toFile()), null); + CountDownLatch mutationEntered = new CountDownLatch(1); + CountDownLatch releaseMutation = new CountDownLatch(1); + CountDownLatch requestEntered = new CountDownLatch(1); + CountDownLatch requestReturned = new CountDownLatch(1); + AtomicReference setting = new AtomicReference<>("before"); + Thread mutation = new Thread(() -> runs.whileIdle(() -> { + check(!runs.isRunning(), "idle mutation may call coordinator reentrantly"); + mutationEntered.countDown(); + await(releaseMutation); + setting.set("after"); + })); + Thread request = new Thread(() -> { + requestEntered.countDown(); + runs.start("boot", log -> { + check(setting.get().equals("after"), "work sees completed settings mutation"); + return 0; + }, null); + requestReturned.countDown(); + }); + mutation.start(); + await(mutationEntered); + request.start(); + await(requestEntered); + check(requestReturned.getCount() == 1, "admission waits for idle mutation"); + releaseMutation.countDown(); + mutation.join(5000); + request.join(5000); + check(!mutation.isAlive() && !request.isAlive(), "settings/admission do not deadlock"); + executor.runNext(); + } + + private static void executorRejectionAndCallbacks() throws Exception { + Path files = directory(); + AtomicInteger completions = new AtomicInteger(); + RunCoordinator rejected = new RunCoordinator(command -> { + throw new RejectedExecutionException("executor unavailable"); + }, new RunLogStore(files.toFile()), message -> { + throw new IllegalStateException("fallback unavailable"); + }); + check(!rejected.start("boot", log -> 0, () -> { + completions.incrementAndGet(); + throw new IllegalStateException("completion unavailable"); + }), "executor rejection is returned"); + check(completions.get() == 1 && !rejected.isRunning(), + "rejection and broken callbacks release the gate exactly once"); + contains(rejected.snapshot().status, "executor unavailable", "rejection visible"); + check(!Files.exists(files.resolve("diagnostics")), "rejection does not rotate logs"); + + RunCoordinator direct = direct(directory(), message -> { + throw new IllegalStateException("fallback unavailable"); + }); + check(direct.start("activity", log -> { + log.report("report survived fallback\n"); + return 0; + }, () -> { + completions.incrementAndGet(); + throw new AssertionError("callback failure"); + }), "callback failures do not reject completed runs"); + check(!direct.isRunning() && completions.get() == 2, "broken callback remains nonfatal"); + contains(direct.snapshot().text, "report survived fallback", "fallback cannot break log"); + RunCoordinator error = new RunCoordinator(command -> { + throw new AssertionError("executor failed"); + }, new RunLogStore(directory().toFile()), null); + check(!error.start("boot", log -> 0, completions::incrementAndGet), + "executor Error also rejects safely"); + check(!error.isRunning() && completions.get() == 3, + "executor Error cannot strand gate or completion"); + RunCoordinator unusual = new RunCoordinator(command -> { + command.run(); + throw new RejectedExecutionException("threw after execution"); + }, new RunLogStore(directory().toFile()), null); + unusual.start("inline", log -> 0, completions::incrementAndGet); + check(completions.get() == 4, "completion remains once if executor throws after running"); + } + + private static void lateReportersCannotCorruptRuns() throws Exception { + RunCoordinator runs = direct(directory(), null); + AtomicReference stale = new AtomicReference<>(); + runs.start("first", log -> { + stale.set(log); + log.report("first generation\n"); + return 0; + }, null); + stale.get().report("LATE AFTER FINISH\n"); + excludes(runs.snapshot().text, "LATE AFTER FINISH", "late terminal output dropped"); + runs.start("second", log -> { + stale.get().report("LATE DURING NEXT RUN\n"); + log.report("second generation\n"); + return 2; + }, null); + excludes(runs.snapshot().text, "LATE DURING NEXT RUN", "old reporter cannot cross run"); + contains(runs.snapshot().text, "second generation", "current reporter still works"); + } + + private static void retentionExportAndReopen() throws Exception { + Path files = directory(); + RunCoordinator runs = direct(files, null); + for (int i = 1; i <= 3; i++) { + final int number = i; + runs.start("run-" + number, log -> { + Path nativeLog = files.resolve("diagnostics/current/bootstrap.log"); + check(Files.isRegularFile(nativeLog), "native log precreated"); + check(Files.getPosixFilePermissions(nativeLog).equals( + PosixFilePermissions.fromString("rw-------")), "native log is 0600"); + check(Files.getPosixFilePermissions(nativeLog.getParent()).equals( + PosixFilePermissions.fromString("rwx------")), "current directory is 0700"); + log.report("java-" + number + "\n"); + write(nativeLog, "native-" + number + "\n"); + return number; + }, null); + } + String exported = runs.exportText(); + contains(exported, "java-3", "current Java exported"); + contains(exported, "native-3", "current native exported"); + contains(exported, "java-2", "previous Java exported"); + contains(exported, "native-2", "previous native exported"); + excludes(exported, "java-1", "older Java discarded"); + excludes(exported, "native-1", "older native discarded"); + contains(exported, "diagnostics/previous/bootstrap.log", "export source headers"); + try (java.util.stream.Stream paths = Files.list(files.resolve("diagnostics"))) { + check(paths.count() == 2, "only current and previous generations retained"); + } + check(Files.getPosixFilePermissions(files.resolve("diagnostics/previous")).equals( + PosixFilePermissions.fromString("rwx------")), "previous directory is 0700"); + + RunCoordinator restored = direct(files, null); + contains(restored.snapshot().status, "Patch failure", "terminal status restored"); + contains(restored.snapshot().text, "java-3", "logs survive process recreation"); + write(files.resolve("diagnostics/current/run.log"), "Started but never completed\n"); + RunCoordinator unfinished = direct(files, null); + check(unfinished.snapshot().status.equals(RunLogStore.UNFINISHED), + "unfinished restored run has honest outcome"); + check(!unfinished.isRunning(), "old process is not represented as running"); + } + + private static void unicodeBoundsAndTerminalReserve() throws Exception { + Path files = directory(); + RunCoordinator runs = direct(files, null); + String huge = repeat("😀漢字é", 30000); + runs.start("unicode", log -> { + log.report("old output\n"); + log.report(huge); + log.report("\nLATEST OUTPUT\n"); + String nativeText = repeat("n", RunLogStore.NATIVE_LIMIT - 2) + "😀OUTSIDE_CAP"; + write(files.resolve("diagnostics/current/bootstrap.log"), nativeText); + return 4; + }, null); + byte[] persisted = Files.readAllBytes(files.resolve("diagnostics/current/run.log")); + check(persisted.length <= RunLogStore.JAVA_LIMIT, "Java log capped at 128 KiB"); + String javaLog = new String(persisted, StandardCharsets.UTF_8); + contains(javaLog, "LATEST OUTPUT", "most recent output retained"); + contains(javaLog, "Earlier Java output omitted", "bounded output has truncation notice"); + contains(javaLog, "[DFRoot completion] Cleanup failed", "terminal reserve retained"); + excludes(javaLog, "\ufffd", "UTF-8 is not split at Java cap"); + excludes(runs.snapshot().text, "\ufffd", "UTF-8 is not split at native read cap"); + excludes(runs.snapshot().text, "OUTSIDE_CAP", "native read capped at 64 KiB"); + contains(direct(files, null).snapshot().status, "Cleanup failed", "capped terminal restored"); + } + + private static void liveMemorySurvivesShortOrFailedDiskWrites() throws Exception { + Path files = directory(); + StringBuilder fallback = new StringBuilder(); + RunCoordinator runs = direct(files, fallback::append); + runs.start("disk-shortening", log -> { + log.report("important live output\n"); + write(files.resolve("diagnostics/current/run.log"), "short"); + contains(runs.snapshot().text, "important live output", + "short disk file cannot clear live memory"); + log.report("later live output\n"); + return 0; + }, null); + contains(direct(files, null).snapshot().text, "important live output", + "next write repairs shortened disk log"); + + runs.start("write-failure", log -> { + log.report("before disk failure\n"); + Path logPath = files.resolve("diagnostics/current/run.log"); + Files.delete(logPath); + Files.createDirectory(logPath); + write(logPath.resolve("obstruction"), "do not replace"); + log.report(repeat("x", 9000) + "\nafter disk failure\n"); + log.report(repeat("y", 9000) + "\nafter repeated disk failure\n"); + contains(runs.snapshot().text, "after disk failure", "failure retains memory output"); + return 1; + }, null); + RunCoordinator.Snapshot terminal = runs.snapshot(); + contains(terminal.text, "before disk failure", "older live output survives write failure"); + contains(terminal.text, "Diagnostic logging failed", "write failure visible"); + contains(terminal.status, "Bootstrap failed", "terminal retained despite disk failure"); + contains(fallback.toString(), "Diagnostic logging failed", "write failure reaches fallback"); + check(occurrences(fallback.toString(), "Diagnostic logging failed: save Java log") == 1, + "failed disk sink is disabled and warning emitted once"); + RunCoordinator restored = direct(files, null); + check(restored.snapshot().status.equals(RunLogStore.UNFINISHED), + "failed terminal persistence is not falsely restored as success"); + contains(restored.snapshot().text, "Diagnostic logging failed", "read failure visible"); + } + + private static void generationFailurePreservesPrevious() throws Exception { + Path files = directory(); + RunCoordinator runs = direct(files, null); + runs.start("first", log -> { log.report("KEEP_PREVIOUS\n"); return 0; }, null); + runs.start("second", log -> { log.report("KEEP_CURRENT\n"); return 0; }, null); + Path previous = files.resolve("diagnostics/previous/run.log"); + byte[] before = Files.readAllBytes(previous); + Path current = files.resolve("diagnostics/current"); + Files.move(current, files.resolve("saved-current")); + write(current, "obstruction"); + AtomicInteger workCalls = new AtomicInteger(); + runs.start("cannot-create", log -> { + workCalls.incrementAndGet(); + log.report("still captured in memory\n"); + return 11; + }, null); + check(java.util.Arrays.equals(before, Files.readAllBytes(previous)), + "generation creation failure preserves previous logs"); + check(workCalls.get() == 0, "fresh log creation failure prevents native work"); + contains(runs.snapshot().text, "Origin: cannot-create", "failed begin retains new memory"); + contains(runs.snapshot().text, "Diagnostic logging failed", "begin failure is visible"); + contains(runs.snapshot().status, "Startup not attempted", "terminal survives begin failure"); + } + + private static void failedGenerationDoesNotTouchPreviousNativeLog() throws Exception { + Path files = directory(); + RunCoordinator runs = direct(files, null); + runs.start("existing", log -> { + log.report("existing Java output\n"); + write(files.resolve("diagnostics/current/bootstrap.log"), "existing native output\n"); + return 0; + }, null); + Path javaLog = files.resolve("diagnostics/current/run.log"); + Path nativeLog = files.resolve("diagnostics/current/bootstrap.log"); + byte[] beforeJava = Files.readAllBytes(javaLog); + byte[] beforeNative = Files.readAllBytes(nativeLog); + write(files.resolve("diagnostics/previous"), "obstruction"); + AtomicInteger completions = new AtomicInteger(); + AtomicInteger calls = new AtomicInteger(); + runs.start("blocked", log -> { + calls.incrementAndGet(); + write(nativeLog, "would corrupt attribution"); + return 0; + }, completions::incrementAndGet); + check(calls.get() == 0 && completions.get() == 1, + "failed prepare completes without launching Work"); + check(java.util.Arrays.equals(beforeJava, Files.readAllBytes(javaLog)), + "failed prepare preserves prior current Java bytes"); + check(java.util.Arrays.equals(beforeNative, Files.readAllBytes(nativeLog)), + "failed prepare preserves prior current native bytes"); + contains(runs.exportText(), "existing Java output", "failed attempt export retains disk Java"); + contains(runs.exportText(), "existing native output", + "failed attempt export retains disk native"); + contains(runs.snapshot().status, "exit code 11", "prepare failure has preflight code"); + } + + private static void incompleteTerminalAndLaunchCheckpoint() throws Exception { + Path files = directory(); + RunCoordinator runs = direct(files, null); + Path javaLog = files.resolve("diagnostics/current/run.log"); + runs.start("checkpoint", log -> { + log.report("configuration before native launch\n"); + log.report("* launching native process\n"); + String persisted = new String(Files.readAllBytes(javaLog), StandardCharsets.UTF_8); + contains(persisted, "configuration before native launch", + "prelaunch checkpoint flushes pending configuration"); + contains(persisted, "* launching native process", "launch checkpoint is persisted"); + log.report("pending patch details\n"); + log.report("=== cleanup ===\n"); + persisted = new String(Files.readAllBytes(javaLog), StandardCharsets.UTF_8); + contains(persisted, "pending patch details", "native stage flushes prior progress"); + contains(persisted, "=== cleanup ===", "native stage is persisted immediately"); + log.report("cleanup: ERROR - restoration incomplete\n"); + persisted = new String(Files.readAllBytes(javaLog), StandardCharsets.UTF_8); + contains(persisted, "cleanup: ERROR", "explicit native error is persisted immediately"); + return 0; + }, null); + String complete = new String(Files.readAllBytes(javaLog), StandardCharsets.UTF_8); + check(complete.endsWith("\n"), "complete terminal line ends with newline"); + write(javaLog, complete.substring(0, complete.length() - 1)); + check(direct(files, null).snapshot().status.equals(RunLogStore.UNFINISHED), + "partial terminal line cannot claim completion"); + } + + private static void interruptedRotationRecovery() throws Exception { + for (int phase = 0; phase < 4; phase++) { + Path files = directory(); + Path logs = files.resolve("diagnostics"); + Files.createDirectories(logs); + if (phase == 0) { + generation(logs.resolve(".current-latest"), "LATEST_REAL", 1000); + generation(logs.resolve("previous"), "OLDER_REAL", 2000); + } else { + generation(logs.resolve("current"), "NEW_ATTEMPT", 500); + if (phase < 3) { + generation(logs.resolve(".current-latest"), "LATEST_REAL", 1000); + generation(logs.resolve(phase == 1 ? "previous" : ".previous-older"), + "OLDER_REAL", phase == 1 ? 1000 : 2000); + } else { + generation(logs.resolve("previous"), "LATEST_REAL", 1000); + generation(logs.resolve(".previous-older"), "OLDER_REAL", 2000); + } + } + generation(logs.resolve(".next-unused"), "NEVER_LAUNCHED", 9000); + String exported = direct(files, null).exportText(); + contains(exported, "LATEST_REAL", "recovery keeps most recent real run, phase " + phase); + excludes(exported, "NEVER_LAUNCHED", "unused staging not promoted, phase " + phase); + if (phase == 0) contains(exported, "OLDER_REAL", "pre-install rollback keeps history"); + else excludes(exported, "OLDER_REAL", + "rename role outranks reversed/equal wall clock, phase " + phase); + try (java.util.stream.Stream paths = Files.list(logs)) { + check(paths.count() == 2, "interrupted rotation bounded to two generations"); + } + } + Path files = directory(); + Path logs = files.resolve("diagnostics"); + generation(logs.resolve(".next-first-run"), "ONLY_COPY", 1000); + contains(direct(files, null).exportText(), "ONLY_COPY", "recovery preserves only staged copy"); + } + + private static void generation(Path directory, String text, long modified) throws Exception { + Files.createDirectories(directory); + write(directory.resolve("run.log"), text + "\n"); + write(directory.resolve("bootstrap.log"), text + " native\n"); + java.nio.file.attribute.FileTime time = + java.nio.file.attribute.FileTime.fromMillis(modified); + Files.setLastModifiedTime(directory.resolve("run.log"), time); + Files.setLastModifiedTime(directory.resolve("bootstrap.log"), time); + } + + private static void admissionDoesNotWaitForDiskReads() throws Exception { + RunLogStore store = new RunLogStore(directory().toFile()); + QueuedExecutor worker = new QueuedExecutor(); + RunCoordinator runs = new RunCoordinator(worker, store, null); + CountDownLatch readEntered = new CountDownLatch(1); + CountDownLatch admitted = new CountDownLatch(1); + Thread reader = new Thread(() -> { + readEntered.countDown(); + runs.snapshot(); + }); + Thread caller = new Thread(() -> { + runs.start("while disk blocked", log -> 0, null); + admitted.countDown(); + }); + synchronized (store) { + reader.start(); + await(readEntered); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (reader.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) + Thread.yield(); + check(reader.getState() == Thread.State.BLOCKED, "snapshot is blocked on disk store"); + caller.start(); + check(admitted.await(2, TimeUnit.SECONDS), "admission avoids snapshot disk lock"); + check(runs.isRunning(), "running state is readable while disk store is blocked"); + check(!runs.whileIdle(() -> { throw new AssertionError("busy mutation"); }), + "busy settings check avoids snapshot disk lock"); + } + caller.join(5000); + reader.join(5000); + check(!reader.isAlive() && !caller.isAlive(), "blocked snapshot resumes without deadlock"); + worker.runNext(); + } + + private static void terminalStatusesAndFailures() throws Exception { + RunCoordinator runs = direct(directory(), null); + int[] codes = {0, 1, 2, 3, 4, 10, 11, 17}; + String[] expected = {"root unverified", "Bootstrap failed", "incomplete", + "Patch failure", "Cleanup failed", "Already attempted", "Preflight failed", + "code 17"}; + for (int i = 0; i < codes.length; i++) { + final int code = codes[i]; + runs.start("status", log -> code, null); + contains(runs.snapshot().status, expected[i], "exit code " + code + " mapped honestly"); + } + AtomicInteger complete = new AtomicInteger(); + runs.start("exception", log -> { + throw new IOException("native exit code 4; reader failed"); + }, complete::incrementAndGet); + contains(runs.snapshot().status, "native exit code 4", "exception preserves native detail"); + check(!runs.isRunning() && complete.get() == 1, "exception completes and releases gate"); + runs.start("error", log -> { throw new AssertionError("fatal test work"); }, + complete::incrementAndGet); + contains(runs.snapshot().status, "fatal test work", "work Error remains visible"); + check(!runs.isRunning() && complete.get() == 2, "work Error completes and releases gate"); + } + + private static RunCoordinator direct(Path files, IReporter fallback) { + return new RunCoordinator(Runnable::run, new RunLogStore(files.toFile()), fallback); + } + + private static Path directory() throws IOException { + Path path = Files.createTempDirectory("dfroot-coordinator-test-"); + temporary.add(path); + return path; + } + + private static void write(Path path, String text) throws IOException { + Files.write(path, text.getBytes(StandardCharsets.UTF_8)); + } + + private static String repeat(String value, int count) { + StringBuilder text = new StringBuilder(value.length() * count); + for (int i = 0; i < count; i++) text.append(value); + return text.toString(); + } + + private static int occurrences(String text, String fragment) { + int count = 0; + for (int index = 0; (index = text.indexOf(fragment, index)) >= 0; index += fragment.length()) + count++; + return count; + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("latch timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + + private static void contains(String text, String expected, String message) { + check(text.contains(expected), message + " (missing: " + expected + ")"); + } + + private static void excludes(String text, String unexpected, String message) { + check(!text.contains(unexpected), message + " (unexpected: " + unexpected + ")"); + } + + private static synchronized void check(boolean condition, String message) { + assertions++; + if (!condition) { + failedAssertions.add(message); + throw new AssertionError(message); + } + } + + private static void deleteTree(Path path) throws IOException { + if (Files.isDirectory(path)) { + try (java.nio.file.DirectoryStream entries = Files.newDirectoryStream(path)) { + for (Path entry : entries) deleteTree(entry); + } + } + Files.deleteIfExists(path); + } + + private static final class QueuedExecutor implements Executor { + final List tasks = new ArrayList<>(); + + @Override public synchronized void execute(Runnable command) { + tasks.add(command); + } + + void runNext() { + Runnable task; + synchronized (this) { task = tasks.remove(0); } + task.run(); + } + } +} diff --git a/tests/run-java.sh b/tests/run-java.sh new file mode 100755 index 00000000..c2d812f4 --- /dev/null +++ b/tests/run-java.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +test_classes=$(mktemp -d "${TMPDIR:-/tmp}/dfroot-java-test.XXXXXX") +trap 'rm -rf "$test_classes"' EXIT HUP INT TERM +test_javac="${JAVAC:-javac}" +test_java="${JAVA:-java}" +"$test_javac" --release 11 -d "$test_classes" \ + app/src/main/java/df/root/IReporter.java \ + app/src/main/java/df/root/RunLogStore.java \ + app/src/main/java/df/root/RunCoordinator.java \ + tests/RunCoordinatorTest.java +"$test_java" -ea -cp "$test_classes" df.root.RunCoordinatorTest diff --git a/tests/run-native-safety.sh b/tests/run-native-safety.sh new file mode 100755 index 00000000..c0884483 --- /dev/null +++ b/tests/run-native-safety.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +test_binary=$(mktemp "${TMPDIR:-/tmp}/dfroot-native-safety.XXXXXX") +trap 'rm -f "$test_binary"' EXIT HUP INT TERM +${CC:-cc} -std=gnu11 -Wall -Wextra -Werror -pedantic \ + ${CFLAGS:-} tests/test_native_safety.c -o "$test_binary" +"$test_binary" diff --git a/tests/run-process.sh b/tests/run-process.sh new file mode 100755 index 00000000..75c085de --- /dev/null +++ b/tests/run-process.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/dfroot-process-test.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT HUP INT TERM + +if [ -n "${JAVA_HOME:-}" ]; then + test_javac=${JAVAC:-"$JAVA_HOME/bin/javac"} + test_java=${JAVA:-"$JAVA_HOME/bin/java"} +else + test_javac=${JAVAC:-javac} + test_java=${JAVA:-java} +fi + +"$test_javac" --release 11 -Xlint:all -d "$test_dir" \ + app/src/main/java/df/root/IReporter.java \ + app/src/main/java/df/root/ProcessOutput.java \ + tests/ProcessOutputTest.java +"$test_java" -ea -cp "$test_dir" df.root.ProcessOutputTest diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 00000000..bb1dd0bf --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +test_directory=$(mktemp -d "${TMPDIR:-/tmp}/dfroot-bootstrap-tests.XXXXXX") +trap 'rm -rf "$test_directory"' EXIT HUP INT TERM +for source in tests/test_disable_modules.c tests/test_bootstrap.c tests/test_bootstrap_log.c; do + test_binary="$test_directory/$(basename "$source" .c)" + ${CC:-cc} -std=gnu11 -Wall -Wextra -Werror -Itests/stubs "$source" -o "$test_binary" + "$test_binary" +done diff --git a/tests/stubs/android/log.h b/tests/stubs/android/log.h new file mode 100644 index 00000000..df896ba1 --- /dev/null +++ b/tests/stubs/android/log.h @@ -0,0 +1,7 @@ +#ifndef DFROOT_TEST_ANDROID_LOG_H +#define DFROOT_TEST_ANDROID_LOG_H +#define ANDROID_LOG_INFO 4 +#define ANDROID_LOG_WARN 5 +#define ANDROID_LOG_ERROR 6 +int __android_log_print(int priority, const char *tag, const char *format, ...); +#endif diff --git a/tests/test_bootstrap.c b/tests/test_bootstrap.c new file mode 100644 index 00000000..d3303896 --- /dev/null +++ b/tests/test_bootstrap.c @@ -0,0 +1,468 @@ +/* Host-only: main is renamed and never called. Process/context/ioctl actions + * are injected; filesystem operations use only a private temporary directory. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static pid_t test_fork(void); +static pid_t test_waitpid(pid_t pid, int *status, int options); +static int test_execv(const char *path, char *const argv[]); +static _Noreturn void test_exit(int code); +static int test_open(const char *path, int flags, ...); +static int test_close(int fd); +static ssize_t test_write(int fd, const void *buffer, size_t count); +static ssize_t test_read(int fd, void *buffer, size_t count); +static int test_fstat(int fd, struct stat *st); +static int test_fstatat(int fd, const char *path, struct stat *st, int flags); +static struct dirent *test_readdir(DIR *directory); +static int test_ioctl(int fd, unsigned long request, ...); +static int test_clock_gettime(clockid_t clock, struct timespec *time); +static int test_nanosleep(const struct timespec *delay, struct timespec *remaining); +#define fork test_fork +#define waitpid test_waitpid +#define execv test_execv +#define _exit test_exit +#define open test_open +#define close test_close +#define write test_write +#define read test_read +#define fstat test_fstat +#define fstatat test_fstatat +#define readdir test_readdir +#define ioctl test_ioctl +#define clock_gettime test_clock_gettime +#define nanosleep test_nanosleep +#define main bootstrap_main +#include "../app/src/main/jni/bootstrap.c" +#undef main +#undef fork +#undef waitpid +#undef execv +#undef _exit +#undef open +#undef close +#undef write +#undef read +#undef fstat +#undef fstatat +#undef readdir +#undef ioctl +#undef clock_gettime +#undef nanosleep + +static char logs[32768]; +static int fork_result = 42, wait_mode, wait_calls, child_exit = 7; +static int context_open_error, context_write_mode, context_write_calls, context_close_error; +static int exec_calls, intercepted_exit, read_mode, read_calls, close_error; +static int fake_block, ioctl_error, ioctl_calls, fake_clock, clock_error, sleep_calls; +static int statat_error, readdir_error; +static time_t fake_seconds; +static jmp_buf child_jump; +#define CONTEXT_FD 9000 + +int __android_log_print(int priority, const char *tag, const char *format, ...) +{ + (void)priority; + (void)tag; + size_t used = strlen(logs); + va_list args; + va_start(args, format); + vsnprintf(logs + used, sizeof(logs) - used, format, args); + va_end(args); + return 0; +} + +static pid_t test_fork(void) +{ + if (fork_result < 0) + errno = EAGAIN; + return fork_result; +} + +static pid_t test_waitpid(pid_t pid, int *status, int options) +{ + assert(pid == 42 && options == 0); + ++wait_calls; + if (wait_mode == 1 && wait_calls == 1) { + errno = EINTR; + return -1; + } + if (wait_mode == 2) { + errno = ECHILD; + return -1; + } + *status = wait_mode == 3 ? SIGTERM : child_exit << 8; + return pid; +} + +static int test_execv(const char *path, char *const argv[]) +{ + assert(path == argv[0]); + ++exec_calls; + errno = ENOENT; + return -1; +} + +static _Noreturn void test_exit(int code) +{ + intercepted_exit = code; + longjmp(child_jump, 1); +} + +static int test_open(const char *path, int flags, ...) +{ + if (!strcmp(path, "/proc/self/attr/exec")) { + if (context_open_error) { + errno = context_open_error; + return -1; + } + assert(flags & O_CLOEXEC); + return CONTEXT_FD; + } + if (flags & O_CREAT) { + va_list args; + va_start(args, flags); + mode_t mode = va_arg(args, int); + va_end(args); + return open(path, flags, mode); + } + return open(path, flags); +} + +static int test_close(int fd) +{ + if (fd == CONTEXT_FD) { + if (context_close_error) { + errno = context_close_error; + return -1; + } + return 0; + } + int rc = close(fd); + if (close_error) { + errno = close_error; + close_error = 0; + return -1; + } + return rc; +} + +static ssize_t test_write(int fd, const void *buffer, size_t count) +{ + if (fd == CONTEXT_FD) { + ++context_write_calls; + if (context_write_mode == 1 && context_write_calls == 1) { + errno = EINTR; + return -1; + } + if (context_write_mode == 2) + return (ssize_t)count - 1; + return (ssize_t)count; + } + return write(fd, buffer, count); +} + +static ssize_t test_read(int fd, void *buffer, size_t count) +{ + ++read_calls; + if (read_mode == 1 && read_calls == 1) { + errno = EINTR; + return -1; + } + if (read_mode == 1 && count > 3) + count = 3; + if (read_mode == 2) { + errno = EIO; + return -1; + } + return read(fd, buffer, count); +} + +static int test_fstat(int fd, struct stat *st) +{ + int rc = fstat(fd, st); + if (!rc && fake_block) + st->st_mode = (st->st_mode & 07777) | S_IFBLK; + return rc; +} + +static int test_fstatat(int fd, const char *path, struct stat *st, int flags) +{ + if (statat_error) { + errno = statat_error; + return -1; + } + return fstatat(fd, path, st, flags); +} + +static struct dirent *test_readdir(DIR *directory) +{ + if (readdir_error) { + errno = readdir_error; + return NULL; + } + return readdir(directory); +} + +static int test_ioctl(int fd, unsigned long request, ...) +{ + (void)fd; + assert(request == BLKROSET && fake_block); + ++ioctl_calls; + if (ioctl_error) { + errno = ioctl_error; + return -1; + } + return 0; +} + +static int test_clock_gettime(clockid_t clock, struct timespec *time) +{ + if (clock_error) { + errno = EIO; + return -1; + } + if (fake_clock) { + time->tv_sec = fake_seconds; + time->tv_nsec = 0; + return 0; + } + return clock_gettime(clock, time); +} + +static int test_nanosleep(const struct timespec *delay, struct timespec *remaining) +{ + (void)remaining; + assert(fake_clock && delay->tv_sec == 1); + ++sleep_calls; + fake_seconds += 20; + errno = EINTR; /* Even repeated interruptions cannot defeat the deadline. */ + return -1; +} + +static void write_file(const char *path, const void *data, size_t size) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); + assert(fd >= 0); + assert(write(fd, data, size) == (ssize_t)size); + assert(close(fd) == 0); +} + +static void test_processes(void) +{ + char *const argv[] = { "/nonexistent-test-executable", NULL }; + logs[0] = 0; + assert(run_ctx("test", NULL, argv) == 7 && strstr(logs, "launcher_exit=7")); + wait_mode = 1; + wait_calls = 0; + child_exit = 0; + assert(run_ctx("test", NULL, argv) == 0 && wait_calls == 2); + wait_mode = 2; + logs[0] = 0; + assert(run_ctx("test", NULL, argv) == -1 && strstr(logs, "op=waitpid")); + assert(!strstr(logs, "launcher_exit=")); + wait_mode = 3; + logs[0] = 0; + assert(run_ctx("test", NULL, argv) == 128 + SIGTERM && strstr(logs, "signal=")); + fork_result = -1; + logs[0] = 0; + assert(run_ctx("test", NULL, argv) == -1 && strstr(logs, "op=fork")); + + fork_result = 0; + logs[0] = 0; + if (setjmp(child_jump) == 0) { + run_ctx("test", NULL, argv); + assert(0); + } + assert(intercepted_exit == 127 && exec_calls == 1 && strstr(logs, "op=exec")); + context_open_error = EACCES; + logs[0] = 0; + if (setjmp(child_jump) == 0) { + run_ctx("test", "test-context", argv); + assert(0); + } + assert(intercepted_exit == 126 && exec_calls == 1 && strstr(logs, "context_open")); + context_open_error = 0; + context_write_mode = 2; + logs[0] = 0; + if (setjmp(child_jump) == 0) { + run_ctx("test", "test-context", argv); + assert(0); + } + assert(intercepted_exit == 126 && exec_calls == 1 && strstr(logs, "context_write")); + context_write_mode = 1; + context_write_calls = 0; + if (setjmp(child_jump) == 0) { + run_ctx("test", "test-context", argv); + assert(0); + } + assert(intercepted_exit == 127 && exec_calls == 2 && context_write_calls == 2); + context_write_mode = 0; + context_close_error = EIO; + if (setjmp(child_jump) == 0) { + run_ctx("test", "test-context", argv); + assert(0); + } + assert(intercepted_exit == 126 && exec_calls == 2); + context_close_error = 0; +} + +static void test_preferences(const char *root) +{ + char path[512], manager[256]; + int reboot = -1, modules = -1; + snprintf(path, sizeof(path), "%s/prefs", root); + const char valid[] = "example.manager" + "" + ""; + write_file(path, valid, strlen(valid)); + read_mode = 1; + read_calls = 0; + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == 0); + assert(!strcmp(manager, "example.manager") && reboot == 1 && modules == 1 && read_calls > 2); + read_mode = 2; + close_error = EPIPE; + logs[0] = 0; + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + char expected[32]; + snprintf(expected, sizeof(expected), "errno=%d ", EIO); + assert(strstr(logs, expected)); /* close failure must not replace read errno. */ + read_mode = 0; + char large[4096]; + memset(large, ' ', sizeof(large)); + memcpy(large, valid, strlen(valid)); + write_file(path, large, sizeof(large) - 1); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == 0); + write_file(path, large, sizeof(large)); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + write_file(path, valid, strlen(valid) - 6); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + write_file(path, valid, 0); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + write_file(path, valid, sizeof(valid)); /* Embedded NUL is not XML. */ + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + assert(unlink(path) == 0 && mkfifo(path, 0600) == 0); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); + assert(unlink(path) == 0); + assert(read_prefs(path, manager, sizeof(manager), &reboot, &modules) == -1); +} + +static void test_partitions(const char *root) +{ + char path[512]; + snprintf(path, sizeof(path), "%s/super", root); + write_file(path, "", 0); + logs[0] = 0; + assert(set_partitions_ro(root) == -1); /* Ordinary files cannot be block devices. */ + assert(strstr(logs, "attempted=1 succeeded=0 failed=1") && ioctl_calls == 0); + fake_block = 1; + logs[0] = 0; + assert(set_partitions_ro(root) == 0 && ioctl_calls == 1); + assert(strstr(logs, "attempted=1 succeeded=1 failed=0")); + ioctl_error = EACCES; + logs[0] = 0; + assert(set_partitions_ro(root) == -1 && strstr(logs, "op=ioctl")); + ioctl_error = 0; + fake_block = 0; + assert(unlink(path) == 0); + assert(symlink("missing-device", path) == 0); + logs[0] = 0; + assert(set_partitions_ro(root) == -1 && strstr(logs, "op=open")); + assert(unlink(path) == 0); +} + +static void test_module_errors(const char *root) +{ + char module[512], marker[1024]; + snprintf(module, sizeof(module), "%s/module", root); + snprintf(marker, sizeof(marker), "%s/disable", module); + assert(mkdir(module, 0700) == 0); + readdir_error = EIO; + assert(disable_modules(root) == -1); + readdir_error = 0; + statat_error = EIO; + assert(disable_modules(root) == -1); + statat_error = 0; + assert(access(marker, F_OK) == -1 && errno == ENOENT); + close_error = EIO; + assert(disable_modules(root) == -1); /* A failed marker close is not success. */ + assert(unlink(marker) == 0 && rmdir(module) == 0); +} + +static int scan_result, scan_calls, scan_advance; +static int injected_scan(void) +{ + ++scan_calls; + fake_seconds += scan_advance; + return scan_result; +} + +static void test_process_scan(const char *root) +{ + char process[512], cmdline[1024]; + snprintf(process, sizeof(process), "%s/123", root); + snprintf(cmdline, sizeof(cmdline), "%s/cmdline", process); + assert(mkdir(process, 0700) == 0); + const char running[] = "/data/adb/ksud\0late-load\0"; + write_file(cmdline, running, sizeof(running)); + assert(late_load_running_at(root) == 1); + write_file(cmdline, "ordinary-process", 16); + assert(late_load_running_at(root) == 0); + assert(chmod(cmdline, 0000) == 0); + assert(late_load_running_at(root) == -1); + assert(chmod(cmdline, 0600) == 0 && unlink(cmdline) == 0); + assert(late_load_running_at(root) == 0); /* A vanished process is expected. */ + assert(rmdir(process) == 0); + assert(late_load_running_at(process) == -1); + + fake_clock = 1; + fake_seconds = 0; + scan_result = 1; + scan_calls = sleep_calls = 0; + logs[0] = 0; + assert(wait_for_late_load(injected_scan) == -1); + assert(fake_seconds == 60 && sleep_calls == 3 && scan_calls == 4); + assert(strstr(logs, "timeout; soft reboot skipped")); + scan_result = -1; + scan_calls = sleep_calls = 0; + logs[0] = 0; + assert(wait_for_late_load(injected_scan) == -1 && scan_calls == 1 && sleep_calls == 0); + assert(strstr(logs, "unknown; soft reboot skipped")); + scan_result = 0; + assert(wait_for_late_load(injected_scan) == 0); + scan_advance = 61; + assert(wait_for_late_load(injected_scan) == -1); /* Late scan success is expired. */ + scan_advance = 0; + clock_error = 1; + assert(wait_for_late_load(injected_scan) == -1); + clock_error = fake_clock = 0; +} + +int main(void) +{ + assert(geteuid() != 0); + char root[] = "/tmp/dfroot-bootstrap-XXXXXX"; + assert(mkdtemp(root)); + test_processes(); + test_preferences(root); + test_partitions(root); + test_module_errors(root); + test_process_scan(root); + assert(rmdir(root) == 0); + puts("bootstrap stage and process tests passed"); + return 0; +} diff --git a/tests/test_bootstrap_log.c b/tests/test_bootstrap_log.c new file mode 100644 index 00000000..54c3cdeb --- /dev/null +++ b/tests/test_bootstrap_log.c @@ -0,0 +1,226 @@ +/* Host-only tests: all persistent paths live below mkdtemp. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static ssize_t log_test_write(int fd, const void *buf, size_t count); +static int log_test_fsync(int fd); +static int log_test_fstat(int fd, struct stat *st); +#define write log_test_write +#define fsync log_test_fsync +#define fstat log_test_fstat +#include "../app/src/main/jni/bootstrap_log.h" +#undef write +#undef fsync +#undef fstat + +static char captured[8192]; +static int write_mode, write_calls, sync_mode, stat_calls, change_owner_at; + +int __android_log_print(int priority, const char *tag, const char *format, ...) +{ + (void)priority; + (void)tag; + va_list args; + va_start(args, format); + size_t used = strlen(captured); + vsnprintf(captured + used, sizeof(captured) - used, format, args); + va_end(args); + errno = ENOTTY; /* Logging must not clobber its caller's errno. */ + return 0; +} + +static ssize_t log_test_write(int fd, const void *buf, size_t count) +{ + ++write_calls; + if (write_mode == 1 && write_calls == 1) { + errno = EINTR; + return -1; + } + if (write_mode == 1 && write_calls == 2 && count > 7) + count = 7; + if (write_mode == 2) { + errno = ENOSPC; + return -1; + } + if (write_mode == 3) + return 0; + return write(fd, buf, count); +} + +static int log_test_fsync(int fd) +{ + if (sync_mode == 1) { + sync_mode = 0; + errno = EINTR; + return -1; + } + if (sync_mode == 2) { + errno = EIO; + return -1; + } + return fsync(fd); +} + +static int log_test_fstat(int fd, struct stat *st) +{ + int rc = fstat(fd, st); + ++stat_calls; + if (!rc && stat_calls == change_owner_at) + st->st_uid = change_owner_at == 1 ? 0 : st->st_uid + 1; + return rc; +} + +static void join(char *out, size_t capacity, const char *parent, const char *name) +{ + int n = snprintf(out, capacity, "%s/%s", parent, name); + assert(n > 0 && (size_t)n < capacity); +} + +static void create_private(const char *path) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + assert(fd >= 0 && close(fd) == 0); +} + +static void close_sink(void) +{ + if (bootstrap_log_fd >= 0) + assert(close(bootstrap_log_fd) == 0); + bootstrap_log_fd = -1; +} + +int main(void) +{ + assert(geteuid() != 0); + char root[] = "/tmp/dfroot-log-XXXXXX"; + assert(mkdtemp(root)); + char dirs[3][512], leaf[512], alternative[1024]; + join(dirs[0], sizeof(dirs[0]), root, "files"); + join(dirs[1], sizeof(dirs[1]), dirs[0], "diagnostics"); + join(dirs[2], sizeof(dirs[2]), dirs[1], "current"); + for (size_t i = 0; i < 3; ++i) + assert(mkdir(dirs[i], 0700) == 0); + join(leaf, sizeof(leaf), dirs[2], "bootstrap.log"); + errno = E2BIG; + assert(bootstrap_log_open(root) == -1 && errno == E2BIG); + assert(access(leaf, F_OK) == -1 && errno == ENOENT); /* Never creates. */ + create_private(leaf); + + errno = E2BIG; + assert(bootstrap_log_open(root) == 0 && errno == E2BIG); + assert(fcntl(bootstrap_log_fd, F_GETFD) & FD_CLOEXEC); + assert(fcntl(bootstrap_log_fd, F_GETFL) & O_NONBLOCK); + assert(fcntl(bootstrap_log_fd, F_GETFL) & O_APPEND); + write_mode = 1; + write_calls = 0; + bootstrap_log(ANDROID_LOG_INFO, "test", "partial writes\nremain one record"); + assert(errno == E2BIG && write_calls >= 3); + sync_mode = 1; + bootstrap_log_sync(); + assert(errno == E2BIG && bootstrap_log_fd >= 0); + write_mode = 0; + close_sink(); + int fd = open(leaf, O_RDONLY); + char contents[2048]; + ssize_t n = read(fd, contents, sizeof(contents) - 1); + assert(n > 0 && close(fd) == 0); + contents[n] = 0; + assert(strstr(contents, "stage=test partial writes remain one record\n")); + + /* Reject root/wrong owners at every pinned component and at the leaf. */ + for (int component = 1; component <= 5; ++component) { + stat_calls = 0; + change_owner_at = component; + assert(bootstrap_log_open(root) == -1 && bootstrap_log_fd == -1); + } + change_owner_at = 0; + for (size_t i = 0; i < 3; ++i) { + snprintf(alternative, sizeof(alternative), "%s.saved", dirs[i]); + assert(rename(dirs[i], alternative) == 0); + assert(symlink(alternative, dirs[i]) == 0); + assert(bootstrap_log_open(root) == -1); + assert(unlink(dirs[i]) == 0 && rename(alternative, dirs[i]) == 0); + } + snprintf(alternative, sizeof(alternative), "%s.link", root); + assert(symlink(root, alternative) == 0); + assert(bootstrap_log_open(alternative) == -1); + assert(unlink(alternative) == 0); + assert(chmod(leaf, 0644) == 0); + assert(bootstrap_log_open(root) == -1); + assert(chmod(leaf, 0600) == 0); + join(alternative, sizeof(alternative), dirs[2], "other"); + assert(link(leaf, alternative) == 0); + assert(bootstrap_log_open(root) == -1); + assert(unlink(alternative) == 0); + assert(rename(leaf, alternative) == 0 && symlink(alternative, leaf) == 0); + assert(bootstrap_log_open(root) == -1); + assert(unlink(leaf) == 0 && rename(alternative, leaf) == 0); + assert(unlink(leaf) == 0 && mkfifo(leaf, 0600) == 0); + assert(bootstrap_log_open(root) == -1); + int reader = open(leaf, O_RDONLY | O_NONBLOCK); + assert(reader >= 0 && bootstrap_log_open(root) == -1); + assert(close(reader) == 0 && unlink(leaf) == 0); + assert(mkdir(leaf, 0700) == 0 && bootstrap_log_open(root) == -1); + assert(rmdir(leaf) == 0); + create_private(leaf); + + /* Reserve a visible truncation notice and retain the fd for final fsync. */ + assert(bootstrap_log_open(root) == 0); + fd = open(leaf, O_WRONLY); + off_t initial_size = BOOTSTRAP_LOG_LIMIT - sizeof(BOOTSTRAP_LOG_TRUNCATED); + assert(fd >= 0 && ftruncate(fd, initial_size) == 0 && close(fd) == 0); + bootstrap_log(ANDROID_LOG_INFO, "limit", "record would cross the cap"); + assert(bootstrap_log_fd >= 0 && bootstrap_log_capped); + struct stat st; + assert(stat(leaf, &st) == 0 && st.st_size == BOOTSTRAP_LOG_LIMIT - 1); + fd = open(leaf, O_RDONLY); + assert(fd >= 0 && lseek(fd, initial_size, SEEK_SET) == initial_size); + n = read(fd, contents, sizeof(contents) - 1); + assert(n > 0 && close(fd) == 0); + contents[n] = 0; + assert(!strcmp(contents, BOOTSTRAP_LOG_TRUNCATED)); + bootstrap_log(ANDROID_LOG_INFO, "limit", "this record remains in logcat"); + assert(stat(leaf, &st) == 0 && st.st_size == BOOTSTRAP_LOG_LIMIT - 1); + bootstrap_log_sync(); + assert(bootstrap_log_fd >= 0); + close_sink(); + fd = open(leaf, O_WRONLY); + assert(fd >= 0 && ftruncate(fd, BOOTSTRAP_LOG_LIMIT) == 0 && close(fd) == 0); + assert(bootstrap_log_open(root) == -1); + fd = open(leaf, O_WRONLY | O_TRUNC); + assert(fd >= 0 && close(fd) == 0); + + for (int mode = 2; mode <= 3; ++mode) { + assert(bootstrap_log_open(root) == 0); + write_mode = mode; + captured[0] = 0; + errno = EDOM; + bootstrap_log_error("test", "operation", EACCES); + assert(errno == EDOM && bootstrap_log_fd == -1); + assert(strstr(captured, "stage=test") && strstr(captured, "logcat only")); + } + write_mode = 0; + assert(bootstrap_log_open(root) == 0); + sync_mode = 2; + errno = EDOM; + bootstrap_log_sync(); + assert(errno == EDOM && bootstrap_log_fd == -1); + sync_mode = 0; + assert(bootstrap_log_open(root) == 0); + assert(bootstrap_finish(1) == 1 && bootstrap_log_fd == -1); + assert(unlink(leaf) == 0); + for (int i = 2; i >= 0; --i) + assert(rmdir(dirs[i]) == 0); + assert(rmdir(root) == 0); + puts("bootstrap persistent logging tests passed"); + return 0; +} diff --git a/tests/test_collect_logs.py b/tests/test_collect_logs.py new file mode 100644 index 00000000..f87dcfdf --- /dev/null +++ b/tests/test_collect_logs.py @@ -0,0 +1,334 @@ +"""Host-only collector regressions. The fake ADB never contacts a device.""" + +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import time +import unittest +from unittest import mock + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "tools" / "collect_logs.py" +SPEC = importlib.util.spec_from_file_location("collect_logs", MODULE_PATH) +collector = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(collector) + +FAKE_ADB = r''' +import json +from pathlib import Path +import sys +import time + +root = Path(__file__).parent +scenario = json.loads((root / "scenario.json").read_text()) +args = sys.argv[1:] +with (root / "calls.jsonl").open("a") as output: + output.write(json.dumps(args) + "\n") +if args[:2] != ["-s", "TEST-DEVICE"]: + raise SystemExit(99) +args = args[2:] + +def count(name): + path = root / (name + ".count") + current = int(path.read_text()) if path.exists() else 0 + path.write_text(str(current + 1)) + return current + +if args == ["get-state"]: + states = scenario.get("states", ["device"]) + state = states[min(count("state"), len(states) - 1)] + if state == "timeout": + time.sleep(20) + if state != "device": + print("fake transport unavailable", file=sys.stderr) + raise SystemExit(1) + print("device") +elif args[0] == "logcat": + mode = scenario.get("logcat", "normal") + iteration = count("logcat") + if mode == "denied": + print("fake permission denied", file=sys.stderr) + raise SystemExit(3) + if mode == "stderr-flood": + sys.stderr.write("E" * 200000) + sys.stderr.flush() + elif mode == "stdout-flood": + sys.stdout.write("L" * 200000) + sys.stdout.flush() + elif mode == "disconnect" and iteration == 0: + print("dfroot: before reconnect", flush=True) + raise SystemExit(1) + elif "-d" in args: + print("dfroot: fake snapshot", flush=True) + else: + while True: + if mode != "silent": + print("dfroot: fake stream " + "x" * 100, flush=True) + time.sleep(0.005) +elif args[0] == "shell": + command = args[1] + if command == "getprop ro.build.version.release": + time.sleep(scenario.get("metadata_delay", 0)) + if command == "su -c 'id -u'": + count("authorization") + if scenario.get("root") == "deny": + print("fake su denied", file=sys.stderr) + raise SystemExit(1) + if scenario.get("root") == "timeout": + time.sleep(20) + print(scenario.get("uid", "0")) + elif command.startswith("su -c "): + print("fake protected diagnostic") + if "/proc/last_kmsg" in command: + print("fake source unavailable", file=sys.stderr) + raise SystemExit(2) + elif "random/boot_id" in command: + print("00000000-0000-0000-0000-000000000001") + else: + print("fake metadata") +else: + print("unexpected fake command", file=sys.stderr) + raise SystemExit(98) +''' + + +class CollectorTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + self.fake = self.directory / "fake_adb.py" + self.fake.write_text(FAKE_ADB, encoding="utf-8") + self.scenario = self.directory / "scenario.json" + self.scenario.write_text("{}", encoding="utf-8") + self.output = self.directory / "captures" + + def configure(self, **scenario): + self.scenario.write_text(json.dumps(scenario), encoding="utf-8") + + def session(self, **options): + return collector.Session( + [sys.executable, str(self.fake)], "TEST-DEVICE", self.output, **options) + + def calls(self): + path = self.directory / "calls.jsonl" + return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] + + def manifest(self, session): + return json.loads((session.directory / "manifest.json").read_text()) + + def collect(self, session): + # Short retry windows keep fake-transport tests fast. All commands still + # execute real child processes with both stdout and stderr pipes. + with mock.patch.object(collector, "RECONNECT_DELAY", 0.05), \ + mock.patch.object(collector, "PROBE_TIMEOUT", 0.25), \ + mock.patch.object(collector, "AUTH_TIMEOUT", 0.25): + return session.collect() + + def test_snapshot_is_filtered_read_only_and_keeps_source_failures_separate(self): + session = self.session() + with mock.patch.object(collector.subprocess, "Popen", + wraps=collector.subprocess.Popen) as spawn: + self.assertEqual(self.collect(session), 0) + calls = self.calls() + self.assertTrue(all(call[:2] == ["-s", "TEST-DEVICE"] for call in calls)) + logcat = next(call[2:] for call in calls if call[2] == "logcat") + self.assertIn("-d", logcat) + self.assertEqual(logcat[-2:], ["dfroot:V", "*:S"]) + allowed = [["get-state"], logcat] + [["shell", command] for _, command in collector.METADATA] + self.assertTrue(all(call[2:] in allowed for call in calls)) + manifest = self.manifest(session) + self.assertEqual(manifest["root_status"], "not-requested") + self.assertEqual(manifest["status"], "finished") + self.assertTrue(all(command["status"] == "ok" for command in manifest["commands"])) + started = [call.args[0][4:] for call in spawn.call_args_list] + self.assertLess(started.index(logcat), + min(i for i, command in enumerate(started) if command[0] == "shell")) + + def test_root_is_verified_once_and_only_fixed_protected_sources_are_read(self): + session = self.session(root=True) + self.assertEqual(self.collect(session), 0) + self.assertEqual((self.directory / "authorization.count").read_text(), "1") + calls = self.calls() + protected = [call[2:] for call in calls + if call[2] == "shell" and call[3].startswith("su -c ")] + expected = [collector.root_command("id -u")] + expected += [collector.root_command(command) for _, command in collector.ROOT_SOURCES] + self.assertEqual(protected, expected) + manifest = self.manifest(session) + self.assertEqual(manifest["root_status"], "authorized") + failed = next(command for command in manifest["commands"] if command["source"] == "last-kmsg") + self.assertEqual((failed["status"], failed["exit_code"]), ("failed", 2)) + streams = {info["stream"] for info in manifest["storage"]["files"].values() + if info["command"] == failed["id"]} + self.assertEqual(streams, {"stdout", "stderr"}) + joined = " ".join(" ".join(call[2:]) for call in calls) + for forbidden in ("reboot", "am start", "logcat -c", "setenforce", "adb root"): + self.assertNotIn(forbidden, joined) + + def test_finished_snapshot_is_not_timed_out_by_slow_metadata(self): + self.configure(metadata_delay=0.35) + session = self.session() + # The snapshot exits immediately. Its first poll is delayed by metadata + # beyond its own timeout; that delay must not overwrite the real exit. + with mock.patch.object(collector, "COMMAND_TIMEOUT", 0.1): + self.assertEqual(self.collect(session), 0) + logcat = next(item for item in self.manifest(session)["commands"] + if item["source"] == "logcat") + self.assertEqual((logcat["status"], logcat["exit_code"]), ("ok", 0)) + + def test_nonroot_uid_does_not_enable_protected_reads(self): + self.configure(uid="2000") + session = self.session(root=True) + self.assertEqual(self.collect(session), 0) + protected = [call for call in self.calls() + if call[2] == "shell" and call[3].startswith("su -c ")] + self.assertEqual(len(protected), 1) + self.assertEqual(self.manifest(session)["root_status"], "unavailable") + + def test_denied_and_timed_out_root_leave_normal_capture_working(self): + for mode in ("deny", "timeout"): + with self.subTest(mode=mode): + self.configure(root=mode) + session = self.session(root=True) + self.assertEqual(self.collect(session), 0) + manifest = self.manifest(session) + self.assertEqual(manifest["root_status"], "unavailable") + auth = next(item for item in manifest["commands"] + if item["source"] == "root-authorization") + self.assertEqual(auth["status"], "failed" if mode == "deny" else "timeout") + + def test_watch_recovers_from_probe_timeout_and_stream_disconnect(self): + self.configure(states=["timeout", "device"], logcat="disconnect") + session = self.session(watch=True, duration=2.0) + started = time.monotonic() + self.assertEqual(self.collect(session), 0) + self.assertLess(time.monotonic() - started, 4) + manifest = self.manifest(session) + self.assertGreaterEqual(manifest["connections"], 2) + self.assertEqual(manifest["status"], "duration-reached") + self.assertTrue(any(event["status"] == "timeout" for event in manifest["events"])) + self.assertFalse(any(item["status"] == "running" for item in manifest["commands"])) + self.assertTrue(all(call[:2] == ["-s", "TEST-DEVICE"] for call in self.calls())) + self.assertFalse(session.active) + + def test_watch_does_not_reauthorize_root_after_reconnect(self): + self.configure(logcat="disconnect", root="deny") + session = self.session(watch=True, root=True, duration=2.0) + self.assertEqual(self.collect(session), 0) + self.assertGreaterEqual(self.manifest(session)["connections"], 2) + self.assertEqual((self.directory / "authorization.count").read_text(), "1") + + def test_watch_terminates_a_stream_that_stalls_after_disconnect(self): + self.configure(states=["device", "offline"], logcat="silent") + session = self.session(watch=True, duration=1.2) + self.collect(session) + logs = [item for item in self.manifest(session)["commands"] if item["source"] == "logcat"] + self.assertEqual(logs[0]["status"], "disconnected") + self.assertFalse(session.active) + + def test_rotation_bounds_files_and_retained_bytes(self): + session = self.session(watch=True, duration=1.2, source_limit=256, total_limit=2048) + self.assertEqual(self.collect(session), 0) + manifest = self.manifest(session) + storage = manifest["storage"] + self.assertLessEqual(storage["retained_bytes"], 2048) + self.assertGreater(storage["evicted_files"], 0) + actual = {path.name: path.stat().st_size for path in session.directory.glob("*.log")} + self.assertEqual(sum(actual.values()), storage["retained_bytes"]) + self.assertTrue(all(info["bytes"] == actual[name] + for name, info in storage["files"].items())) + self.assertTrue(all(size <= 256 for name, size in actual.items() if ".stdout-" in name)) + + def test_flooded_stdout_and_stderr_are_bounded_and_marked(self): + for mode in ("stdout-flood", "stderr-flood"): + with self.subTest(mode=mode): + self.configure(logcat=mode) + session = self.session(source_limit=128) + self.collect(session) + manifest = self.manifest(session) + logcat = next(item for item in manifest["commands"] if item["source"] == "logcat") + self.assertEqual(logcat["status"], "source-limit") + for info in manifest["storage"]["files"].values(): + if info["command"] == logcat["id"]: + maximum = 128 if info["stream"] == "stdout" else collector.STDERR_LIMIT + self.assertLessEqual(info["bytes"], maximum) + + def test_offline_snapshot_is_a_recorded_failure(self): + self.configure(states=["offline"]) + session = self.session() + self.assertEqual(self.collect(session), 1) + manifest = self.manifest(session) + self.assertEqual(manifest["connections"], 0) + self.assertEqual((manifest["status"], manifest["exit_code"]), ("failed", 1)) + self.assertTrue(any(info["stream"] == "stderr" + for info in manifest["storage"]["files"].values())) + self.assertEqual(len(self.calls()), 1) + + def test_failed_logcat_is_not_reported_as_a_successful_snapshot(self): + self.configure(logcat="denied") + session = self.session() + self.assertEqual(self.collect(session), 1) + logcat = self.manifest(session)["commands"][0] + self.assertEqual((logcat["status"], logcat["exit_code"]), ("failed", 3)) + + def test_watch_with_only_failed_streams_returns_failure(self): + self.configure(logcat="denied") + session = self.session(watch=True, duration=1.2) + self.assertEqual(self.collect(session), 1) + self.assertEqual(self.manifest(session)["exit_code"], 1) + + def test_missing_executable_is_recorded_without_an_unhandled_exception(self): + session = collector.Session( + [str(self.directory / "absent-adb")], "TEST-DEVICE", self.output) + self.assertEqual(self.collect(session), 1) + command = self.manifest(session)["commands"][0] + self.assertEqual(command["status"], "spawn-error") + self.assertIsNone(command["exit_code"]) + self.assertFalse(session.active) + + def test_sessions_are_unique_and_private(self): + first, second = self.session(), self.session() + self.assertNotEqual(first.directory, second.directory) + self.assertEqual(self.collect(first), 0) + if os.name != "nt": + self.assertEqual(stat.S_IMODE(first.directory.stat().st_mode), 0o700) + for path in first.directory.iterdir(): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + def test_cli_requires_serial_and_positive_finite_duration(self): + cases = [[], ["--serial", ""], ["--serial", "TEST", "--duration", "0"], + ["--serial", "TEST", "--duration", "-1"], + ["--serial", "TEST", "--duration", "nan"], + ["--serial", "TEST", "--duration", "inf"]] + for arguments in cases: + with self.subTest(arguments=arguments), contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as raised: + collector.main(arguments) + self.assertEqual(raised.exception.code, 2) + self.assertEqual(self.calls(), []) + + def test_stop_event_cleans_up_children_and_marks_interruption(self): + session = self.session(watch=True) + original = session.snapshots + + def stop_after_snapshot(stream): + original(stream) + session.stop.set() + + session.snapshots = stop_after_snapshot + self.collect(session) + manifest = self.manifest(session) + self.assertEqual(manifest["status"], "interrupted") + self.assertFalse(session.active) + self.assertFalse(any(item["status"] == "running" for item in manifest["commands"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_disable_modules.c b/tests/test_disable_modules.c new file mode 100644 index 00000000..3960e598 --- /dev/null +++ b/tests/test_disable_modules.c @@ -0,0 +1,121 @@ +/* Compile the real bootstrap helper with an Android log stub. The bootstrap + * entry point is renamed and never invoked; all filesystem work uses mkdtemp. */ +#define main bootstrap_main +#include "../app/src/main/jni/bootstrap.c" +#undef main + +#include +#include + +static char last_error[4096]; + +int __android_log_print(int priority, const char *tag, const char *format, ...) +{ + (void)tag; + va_list args; + va_start(args, format); + if (priority == ANDROID_LOG_ERROR) { + size_t used = strlen(last_error); + vsnprintf(last_error + used, sizeof(last_error) - used, format, args); + } + va_end(args); + return 0; +} + +static void path_join(char *out, size_t size, const char *directory, const char *name) +{ + int length = snprintf(out, size, "%s/%s", directory, name); + assert(length >= 0 && (size_t)length < size); +} + +int main(void) +{ + assert(geteuid() != 0); /* Permission-denial checks require an ordinary user. */ + char temporary[] = "/tmp/dfroot-modules-XXXXXX"; + char *root = mkdtemp(temporary); + assert(root); + char modules[512], module[512], marker[512], other[512]; + path_join(modules, sizeof(modules), root, "modules"); + + /* A fresh installation has no module directory and must not create one. */ + assert(disable_modules(modules) == 0); + assert(access(modules, F_OK) < 0 && errno == ENOENT); + assert(mkdir(modules, 0700) == 0); + assert(disable_modules(modules) == 0); + + /* Real module directories get markers; unrelated files are ignored. */ + path_join(module, sizeof(module), modules, "example"); + path_join(marker, sizeof(marker), module, "disable"); + path_join(other, sizeof(other), modules, "metadata"); + assert(mkdir(module, 0700) == 0); + int fd = open(other, O_CREAT | O_WRONLY, 0600); + assert(fd >= 0 && close(fd) == 0); + assert(disable_modules(modules) == 0); + struct stat st; + assert(stat(marker, &st) == 0 && S_ISREG(st.st_mode)); + assert(disable_modules(modules) == 0); /* Existing markers are accepted. */ + assert(unlink(marker) == 0); + + /* Directory links are never traversed; linked/FIFO markers fail closed. */ + char outside[512], link_path[512]; + path_join(outside, sizeof(outside), root, "outside"); + path_join(link_path, sizeof(link_path), modules, "linked-module"); + assert(mkdir(outside, 0700) == 0); + assert(symlink(outside, link_path) == 0); + assert(disable_modules(modules) == 0); + char outside_marker[512]; + path_join(outside_marker, sizeof(outside_marker), outside, "disable"); + assert(access(outside_marker, F_OK) < 0 && errno == ENOENT); + assert(unlink(link_path) == 0); + assert(unlink(marker) == 0); + assert(symlink(outside_marker, marker) == 0); + assert(disable_modules(modules) == -1); + assert(access(outside_marker, F_OK) < 0 && errno == ENOENT); + assert(unlink(marker) == 0); + assert(mkfifo(marker, 0600) == 0); + assert(disable_modules(modules) == -1); /* Must not block waiting for a reader. */ + int reader = open(marker, O_RDONLY | O_NONBLOCK); + assert(reader >= 0); + assert(disable_modules(modules) == -1); /* Also reject an already-open FIFO. */ + assert(close(reader) == 0); + assert(unlink(marker) == 0); + assert(rmdir(outside) == 0); + + /* Failed marker creation must stop startup and retain the specific error. */ + assert(chmod(module, 0500) == 0); + last_error[0] = '\0'; + assert(disable_modules(modules) == -1); + assert(strstr(last_error, "cannot disable") && strstr(last_error, strerror(EACCES))); + assert(chmod(module, 0700) == 0); + assert(rmdir(module) == 0); + assert(unlink(other) == 0); + + /* A legal maximum-length module name must not truncate its marker path. */ + char name[NAME_MAX + 1]; + memset(name, 'a', NAME_MAX); + name[NAME_MAX] = '\0'; + path_join(module, sizeof(module), modules, name); + path_join(marker, sizeof(marker), module, "disable"); + assert(mkdir(module, 0700) == 0); + assert(disable_modules(modules) == 0); + assert(stat(marker, &st) == 0 && S_ISREG(st.st_mode)); + assert(unlink(marker) == 0); + assert(rmdir(module) == 0); + + /* Directory-open failures are distinct from an absent directory. */ + assert(chmod(modules, 0000) == 0); + last_error[0] = '\0'; + assert(disable_modules(modules) == -1); + assert(strstr(last_error, "cannot open") && strstr(last_error, strerror(EACCES))); + assert(chmod(modules, 0700) == 0); + assert(rmdir(modules) == 0); + fd = open(modules, O_CREAT | O_WRONLY, 0600); + assert(fd >= 0 && close(fd) == 0); + last_error[0] = '\0'; + assert(disable_modules(modules) == -1); + assert(strstr(last_error, strerror(ENOTDIR))); + assert(unlink(modules) == 0); + assert(rmdir(root) == 0); + puts("disable_modules filesystem tests passed"); + return 0; +} diff --git a/tests/test_native_safety.c b/tests/test_native_safety.c new file mode 100644 index 00000000..4973bff8 --- /dev/null +++ b/tests/test_native_safety.c @@ -0,0 +1,192 @@ +#include "../app/src/main/jni/native_safety.h" + +#include + +static int open_fails, read_fails, close_status; +static int open_calls, read_calls, close_calls; +static const char *vendor_output; +static FILE stream_token; + +static FILE *mock_open(const char *command, const char *mode) +{ + assert(strcmp(command, "getprop ro.vendor.build.version.release") == 0); + assert(strcmp(mode, "r") == 0); + open_calls++; + return open_fails ? NULL : &stream_token; +} + +static char *mock_read(char *buffer, int capacity, FILE *stream) +{ + assert(stream == &stream_token); + read_calls++; + if (read_fails) return NULL; + snprintf(buffer, (size_t)capacity, "%s", vendor_output); + return buffer; +} + +static int mock_close(FILE *stream) +{ + assert(stream == &stream_token); + close_calls++; + return close_status; +} + +static void reset_stream(void) +{ + open_fails = read_fails = close_status = 0; + open_calls = read_calls = close_calls = 0; + vendor_output = "14\n"; +} + +static int android_version(const char *release, int *version) +{ + return native_android_version(release, version, mock_open, mock_read, mock_close); +} + +static void test_versions(void) +{ + int major = -1, minor = -1, version = -1; + assert(native_kernel_version("5.15.123-android13-4", &major, &minor) == 0); + assert(major == 5 && minor == 15); + assert(native_kernel_version("6.0-custom", &major, &minor) == 0); + assert(major == 6 && minor == 0); + assert(native_kernel_version("invalid", &major, &minor) == -1); + assert(native_kernel_version("6.", &major, &minor) == -1); + assert(native_kernel_version("0.15", &major, &minor) == -1); + assert(native_kernel_version("2147483648.15", &major, &minor) == -1); + assert(native_kernel_version("999999999999999999999.15", &major, &minor) == -1); + assert(native_kernel_version("6.999999999999999999999", &major, &minor) == -1); + + reset_stream(); + assert(android_version("5.15.123-android13-4", &version) == 0 && version == 13); + assert(open_calls == 0); + assert(android_version("5.15-android", &version) == -1); + assert(android_version("5.15-android0", &version) == -1); + assert(android_version("5.15-android999999999999999999999", &version) == -1); + assert(open_calls == 0); + + reset_stream(); + assert(android_version("5.15-vendor", &version) == 0 && version == 14); + assert(open_calls == 1 && read_calls == 1 && close_calls == 1); + vendor_output = "12.1\n"; + assert(android_version("5.10-vendor", &version) == 0 && version == 12); + vendor_output = ""; + assert(android_version("5.15-vendor", &version) == -1); + vendor_output = "0"; + assert(android_version("5.15-vendor", &version) == -1); + vendor_output = "-14"; + assert(android_version("5.15-vendor", &version) == -1); + vendor_output = "unavailable"; + assert(android_version("5.15-vendor", &version) == -1); + vendor_output = "999999999999999999999"; + assert(android_version("5.15-vendor", &version) == -1); + vendor_output = "2147483648"; + assert(android_version("5.15-vendor", &version) == -1); + + reset_stream(); + open_fails = 1; + assert(android_version("5.15-vendor", &version) == -1); + assert(read_calls == 0 && close_calls == 0); + reset_stream(); + read_fails = 1; + assert(android_version("5.15-vendor", &version) == -1); + assert(close_calls == 1); + reset_stream(); + close_status = 1 << 8; + assert(android_version("5.15-vendor", &version) == -1); + reset_stream(); + close_status = -1; + assert(android_version("5.15-vendor", &version) == -1); + reset_stream(); + close_status = 9; + assert(android_version("5.15-vendor", &version) == -1); +} + +static int wait_interruptions, wait_error, wait_calls, child_status; + +static pid_t mock_wait(pid_t pid, int *status, int options) +{ + assert(pid == 42 && options == 0); + wait_calls++; + if (wait_interruptions > 0) { + wait_interruptions--; + errno = EINTR; + return -1; + } + if (wait_error) { + errno = wait_error; + return -1; + } + *status = child_status; + return pid; +} + +static void test_waiting(void) +{ + child_status = 0; + wait_interruptions = 2; + assert(native_wait_status(42, mock_wait) == 0); + assert(wait_calls == 3); + child_status = 7 << 8; + int result = native_wait_status(42, mock_wait); + assert(WIFEXITED(result) && WEXITSTATUS(result) == 7); + child_status = 9; + result = native_wait_status(42, mock_wait); + assert(WIFSIGNALED(result)); + wait_error = ECHILD; + assert(native_wait_status(42, mock_wait) == -1 && errno == ECHILD); + wait_interruptions = 1; + wait_error = EIO; + assert(native_wait_status(42, mock_wait) == -1 && errno == EIO); +} + +struct restore_mock { + int first_result, second_result; + int first_calls, second_calls; +}; + +static int mock_restore_first(void *context) +{ + struct restore_mock *state = context; + assert(state->second_calls == 0); + state->first_calls++; + return state->first_result; +} + +static int mock_restore_second(void *context) +{ + struct restore_mock *state = context; + assert(state->first_calls == 1); + state->second_calls++; + return state->second_result; +} + +static void test_restoration(void) +{ + struct restore_mock state = {0}; + assert(native_restore_in_order(mock_restore_first, mock_restore_second, &state) + == NATIVE_RESTORE_OK); + assert(state.first_calls == 1 && state.second_calls == 1); + state = (struct restore_mock){ .first_result = -1 }; + assert(native_restore_in_order(mock_restore_first, mock_restore_second, &state) + == NATIVE_RESTORE_FIRST_FAILED); + assert(state.first_calls == 1 && state.second_calls == 0); + state = (struct restore_mock){ .second_result = -1 }; + assert(native_restore_in_order(mock_restore_first, mock_restore_second, &state) + == NATIVE_RESTORE_SECOND_FAILED); + assert(state.first_calls == 1 && state.second_calls == 1); + for (int operation = 0; operation <= 3; operation++) { + assert(native_exit_status(operation, NATIVE_RESTORE_OK) == operation); + assert(native_exit_status(operation, NATIVE_RESTORE_FIRST_FAILED) == 4); + assert(native_exit_status(operation, NATIVE_RESTORE_SECOND_FAILED) == 4); + } +} + +int main(void) +{ + test_versions(); + test_waiting(); + test_restoration(); + puts("native safety tests passed (mock callbacks; no patching or privileged syscalls)"); + return 0; +} diff --git a/tools/collect_logs.py b/tools/collect_logs.py new file mode 100644 index 00000000..3f281540 --- /dev/null +++ b/tools/collect_logs.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""Collect local DFRoot diagnostics without changing the connected device. + +Python 3.9+ and Android platform-tools are required. Normal collection contains +only dfroot-tagged logcat and the metadata listed below. --root uses existing su +access; its authorization probe runs once per invocation. Output is private on +POSIX hosts; Windows inherits the destination directory's access controls. + +Watch mode retains a bounded, rotating history. Reconnected logcat streams may +overlap: buffers are never cleared and no records are silently deduplicated. +""" + +import argparse +from collections import OrderedDict, deque +import datetime +import json +import math +import os +from pathlib import Path +import shlex +import shutil +import signal +import subprocess +import tempfile +import threading +import time + + +SOURCE_LIMIT = 2 * 1024 * 1024 +STDERR_LIMIT = 64 * 1024 +TOTAL_LIMIT = 64 * 1024 * 1024 +FILE_LIMIT = 128 +HISTORY_LIMIT = 128 +COMMAND_TIMEOUT = 5.0 +AUTH_TIMEOUT = 10.0 +PROBE_TIMEOUT = 2.0 +RECONNECT_DELAY = 1.0 + +METADATA = ( + ("android-version", "getprop ro.build.version.release"), + ("security-patch", "getprop ro.build.version.security_patch"), + ("kernel-version", "uname -r"), + ("boot-id", "cat /proc/sys/kernel/random/boot_id"), + ("uptime", "cat /proc/uptime"), + ("boot-completed", "getprop sys.boot_completed"), + ("selinux", "getenforce"), +) +ROOT_SOURCES = ( + ("dmesg", "dmesg"), + ("pstore", ( + 'test -d /sys/fs/pstore || exit 2; found=0; result=0; ' + 'for path in /sys/fs/pstore/*; do ' + 'test -f "$path" || continue; found=1; ' + 'printf "\\n--- %s ---\\n" "$path"; cat "$path" || result=1; ' + 'done; test "$found" = 1 || exit 2; exit "$result"' + )), + ("last-kmsg", "cat /proc/last_kmsg"), +) + tuple( + (period + "-" + name.removesuffix(".log"), + "cat /data/user_de/0/df.root/files/diagnostics/" + period + "/" + name) + for period in ("current", "previous") + for name in ("run.log", "bootstrap.log") +) + + +def utc_now(): + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +def root_command(command): + # adb shell joins its arguments into a remote shell command. Quote the + # complete, fixed su command here; user arguments never enter this string. + return ["shell", "su -c " + shlex.quote(command)] + + +class CaptureStore: + """Bound disk use, including empty files, while keeping active files intact.""" + + def __init__(self, directory, total_limit=TOTAL_LIMIT, file_limit=FILE_LIMIT): + self.directory = directory + self.total_limit = total_limit + self.file_limit = file_limit + self.files = OrderedDict() + self.active = set() + self.total = 0 + self.evicted_files = 0 + self.evicted_bytes = 0 + self.lock = threading.RLock() + + def _make_room(self, size=0, new_file=False): + while (self.total + size > self.total_limit or + len(self.files) + int(new_file) > self.file_limit): + victim = next((name for name in self.files + if name not in self.active), None) + if victim is None: + return False + info = self.files.pop(victim) + (self.directory / victim).unlink() + self.total -= info["bytes"] + self.evicted_bytes += info["bytes"] + self.evicted_files += 1 + return True + + def create(self, name, command, stream): + with self.lock: + if not self._make_room(new_file=True): + raise OSError("capture file limit reached") + descriptor = os.open(self.directory / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + self.files[name] = {"command": command, "stream": stream, "bytes": 0} + self.active.add(name) + + def append(self, name, data): + with self.lock: + if not self._make_room(len(data)): + raise OSError("capture storage limit reached") + with (self.directory / name).open("ab") as output: + output.write(data) + self.files[name]["bytes"] += len(data) + self.total += len(data) + + def release(self, name): + with self.lock: + self.active.discard(name) + + def snapshot(self): + with self.lock: + return { + "retained_bytes": self.total, + "evicted_bytes": self.evicted_bytes, + "evicted_files": self.evicted_files, + "files": {name: dict(info) for name, info in self.files.items()}, + } + + +class OutputSink: + def __init__(self, command, stream, limit, rotate): + self.command = command + self.stream = stream + self.limit = limit + self.rotate = rotate + self.part = 0 + self.size = 0 + self.name = None + self.preview = bytearray() + self.bytes_seen = 0 + + def _open(self): + self.part += 1 + self.name = "%05d-%s.%s-%04d.log" % ( + self.command.number, self.command.name, self.stream, self.part) + self.command.store.create(self.name, self.command.number, self.stream) + self.size = 0 + + def write(self, data): + self.bytes_seen += len(data) + self.preview.extend(data[:max(0, 4096 - len(self.preview))]) + while data: + if self.size == self.limit: + if not self.rotate: + self.command.limit_reached.set() + return + if self.name: + self.command.store.release(self.name) + self.name = None + self.size = 0 + if self.command.store and self.name is None: + self._open() + chunk, data = data[:self.limit - self.size], data[self.limit - self.size:] + if self.command.store: + self.command.store.append(self.name, chunk) + self.size += len(chunk) + + def close(self): + if self.name: + self.command.store.release(self.name) + + +class Command: + """Drain both pipes concurrently so a noisy stderr cannot deadlock stdout.""" + + def __init__(self, argv, name, number, stop, deadline=None, timeout=None, + store=None, rotate=False, source_limit=SOURCE_LIMIT): + self.name, self.number, self.store = name, number, store + self.stop_event, self.deadline, self.timeout = stop, deadline, timeout + self.started = time.monotonic() + self.limit_reached = threading.Event() + self.io_error = None + self.process = None + self.threads = [] + self.record = { + "id": number, "source": name, "started_at": utc_now(), + "status": "running", "exit_code": None, + } + self.stdout = OutputSink(self, "stdout", source_limit, rotate) + self.stderr = OutputSink(self, "stderr", STDERR_LIMIT, False) + try: + options = {"start_new_session": True} if os.name != "nt" else {} + self.process = subprocess.Popen( + argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, shell=False, bufsize=0, **options) + for pipe, sink in ((self.process.stdout, self.stdout), + (self.process.stderr, self.stderr)): + thread = threading.Thread(target=self._drain, + args=(pipe, sink), daemon=True) + thread.start() + self.threads.append(thread) + except OSError as error: + self.io_error = str(error) + self.finish("spawn-error") + + def _drain(self, pipe, sink): + try: + with pipe: + while True: + data = pipe.read(65536) + if not data: + break + sink.write(data) + except OSError as error: + self.io_error = str(error) + self.limit_reached.set() + finally: + sink.close() + + def finish(self, status): + if self.record["status"] != "running": + return + if self.process is not None: + if self.process.poll() is None: + try: + self.process.terminate() + except OSError: + if self.process.poll() is None: + raise + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=1) + for thread in self.threads: + thread.join(timeout=2) + self.record["exit_code"] = self.process.returncode + if status in ("ok", "failed") and self.limit_reached.is_set(): + status = "output-error" if self.io_error else "source-limit" + if self.io_error: + self.record["error"] = self.io_error + self.record.update( + status=status, ended_at=utc_now(), + stdout_bytes_seen=self.stdout.bytes_seen, + stderr_bytes_seen=self.stderr.bytes_seen, + ) + + def poll(self): + if self.record["status"] != "running": + return True + now = time.monotonic() + # A command can finish while another source is being collected. Apply + # deadlines only to a process that is still running when observed. + if self.process.poll() is not None: + self.finish("ok" if self.process.returncode == 0 else "failed") + elif self.stop_event.is_set(): + self.finish("interrupted") + elif self.deadline is not None and now >= self.deadline: + self.finish("duration-reached") + elif self.limit_reached.is_set(): + self.finish("output-error" if self.io_error else "source-limit") + elif self.timeout is not None and now - self.started >= self.timeout: + self.finish("timeout") + return self.record["status"] != "running" + + def wait(self): + while not self.poll(): + self.stop_event.wait(0.05) + return self + + +class Session: + def __init__(self, adb_command, serial, output, watch=False, duration=None, + root=False, source_limit=SOURCE_LIMIT, total_limit=TOTAL_LIMIT): + output = Path(output) + output.mkdir(mode=0o700, parents=True, exist_ok=True) + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + self.directory = Path(tempfile.mkdtemp(prefix=stamp, dir=output)) + self.base = list(adb_command) + ["-s", serial] + self.watch, self.root = watch, root + self.source_limit = source_limit + self.store = CaptureStore(self.directory, total_limit) + self.stop = threading.Event() + self.deadline = time.monotonic() + duration if duration is not None else None + self.commands = deque(maxlen=HISTORY_LIMIT) + self.events = deque(maxlen=HISTORY_LIMIT) + self.active = [] + self.sequence = 0 + self.retained_count = 0 + self.connections = 0 + self.root_checked = False + self.root_authorized = False + self.manifest = { + "schema": 1, "started_at": utc_now(), "status": "starting", + "watch": watch, "root_requested": root, "root_status": "not-requested", + "limits": {"source_bytes": source_limit, "stderr_bytes": STDERR_LIMIT, + "retained_data_bytes": total_limit, "retained_files": FILE_LIMIT, + "command_history": HISTORY_LIMIT}, + "notes": [ + "Raw captures can contain private data; review before sharing.", + "Reconnects may replay buffered records; files are not deduplicated.", + "Older output files and command records are rotated out.", + ], + } + + def expired(self): + return self.stop.is_set() or ( + self.deadline is not None and time.monotonic() >= self.deadline) + + def start_command(self, name, args, timeout=COMMAND_TIMEOUT, rotate=False, + retained=True, source_limit=None): + self.sequence += 1 + command = Command( + self.base + args, name, self.sequence, self.stop, self.deadline, + timeout, self.store if retained else None, rotate, + source_limit or self.source_limit) + self.active.append(command) + if retained: + self.retained_count += 1 + self.commands.append(command.record) + return command + + def run_command(self, name, args, timeout=COMMAND_TIMEOUT, retained=True, + source_limit=None): + command = self.start_command(name, args, timeout, retained=retained, + source_limit=source_limit) + try: + return command.wait() + finally: + if command.record["status"] != "running": + self.active.remove(command) + + def connected(self): + probe = self.run_command("connection", ["get-state"], PROBE_TIMEOUT, + retained=False, source_limit=4096) + connected = (probe.record["status"] == "ok" and + bytes(probe.stdout.preview).strip() == b"device") + if not connected: + self.commands.append(probe.record) + self.retained_count += 1 + # Healthy heartbeats need no files. Retain bounded failure output + # separately so offline/unauthorized/transport errors remain useful. + for stream, sink in (("stdout", probe.stdout), ("stderr", probe.stderr)): + if sink.preview: + name = "%05d-connection.%s-0001.log" % (probe.number, stream) + self.store.create(name, probe.number, stream) + try: + self.store.append(name, bytes(sink.preview)) + finally: + self.store.release(name) + probe.record["saved_output_is_preview"] = any( + sink.bytes_seen > len(sink.preview) for sink in (probe.stdout, probe.stderr)) + self.events.append({ + "time": utc_now(), "event": "connection-unavailable", + "status": probe.record["status"], + "exit_code": probe.record["exit_code"], + }) + return connected + + def save(self): + self.manifest.update( + updated_at=utc_now(), connections=self.connections, + root_status=("authorized" if self.root_authorized else "unavailable") + if self.root_checked else ("pending" if self.root else "not-requested"), + command_records_discarded=max(0, self.retained_count - HISTORY_LIMIT), + commands=list(self.commands), events=list(self.events), + storage=self.store.snapshot(), + ) + temporary = self.directory / "manifest.json.tmp" + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + output.write(json.dumps(self.manifest, indent=2) + "\n") + temporary.replace(self.directory / "manifest.json") + + def snapshots(self, stream): + for name, command in METADATA: + if self.expired(): + return + result = self.run_command(name, ["shell", command], source_limit=4096) + if result.record["status"] != "ok" and not self.connected(): + return + stream.poll() + if self.root and not self.root_checked and not self.expired(): + self.root_checked = True + authorization = self.run_command("root-authorization", root_command("id -u"), + AUTH_TIMEOUT, source_limit=4096) + self.root_authorized = ( + authorization.record["status"] == "ok" and + bytes(authorization.stdout.preview).strip() == b"0") + if self.root_authorized: + for name, command in ROOT_SOURCES: + if self.expired(): + return + result = self.run_command(name, root_command(command)) + if result.record["status"] != "ok" and not self.connected(): + return + stream.poll() + self.save() + + def collect(self): + logcat_ok = False + def result(code): + self.manifest["exit_code"] = code + return code + + self.save() + try: + while not self.expired(): + if not self.connected(): + self.manifest["status"] = "waiting-for-device" + self.save() + if not self.watch: + return result(1) + self.stop.wait(min(RECONNECT_DELAY, max( + 0, self.deadline - time.monotonic()) + if self.deadline is not None else RECONNECT_DELAY)) + continue + self.connections += 1 + args = ["logcat", "-b", "all", "-v", "threadtime"] + if not self.watch: + args.append("-d") + args += ["dfroot:V", "*:S"] + stream = self.start_command("logcat", args, timeout=None if self.watch + else COMMAND_TIMEOUT, rotate=self.watch) + self.manifest["status"] = "recording" + self.save() + self.snapshots(stream) + if not self.watch: + stream.wait() + logcat_ok = stream.record["status"] in ("ok", "source-limit") + return result(0 if logcat_ok else 1) + next_probe = time.monotonic() + while not stream.poll(): + logcat_ok = True + if time.monotonic() >= next_probe: + if not self.connected(): + stream.finish("disconnected") + break + next_probe = time.monotonic() + RECONNECT_DELAY + self.save() + self.stop.wait(0.05) + logcat_ok = logcat_ok or stream.stdout.bytes_seen > 0 + self.active.remove(stream) + if stream.record["status"] == "output-error": + return result(1) + self.manifest["status"] = "waiting-for-device" + self.save() + if not self.expired(): + self.stop.wait(RECONNECT_DELAY) + return result(0 if logcat_ok else 1) + except KeyboardInterrupt: + self.stop.set() + return result(130) + finally: + for command in list(self.active): + command.finish("interrupted" if self.stop.is_set() else "stopped") + self.active.clear() + self.manifest["status"] = ( + "interrupted" if self.stop.is_set() else + "duration-reached" if self.expired() else + "finished" if self.manifest.get("exit_code") == 0 else "failed") + self.manifest["ended_at"] = utc_now() + self.save() + + +def positive_seconds(value): + try: + seconds = float(value) + except ValueError: + raise argparse.ArgumentTypeError("must be a positive number") + if not math.isfinite(seconds) or seconds <= 0: + raise argparse.ArgumentTypeError("must be a positive, finite number") + return seconds + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", required=True, help="ADB serial; never switch devices") + parser.add_argument("--watch", action="store_true", help="stream and reconnect until stopped") + parser.add_argument("--duration", type=positive_seconds, help="maximum capture time in seconds") + parser.add_argument("--root", action="store_true", help="try existing su access once") + parser.add_argument("--output", default="out/diagnostics", help="parent directory for a new session") + parser.add_argument("--adb", default="adb", help="ADB executable path or command name") + args = parser.parse_args(argv) + if not args.serial.strip(): + parser.error("--serial must not be empty") + adb = shutil.which(args.adb) + if adb is None: + parser.error("ADB executable was not found; provide --adb or update PATH") + previous_umask = os.umask(0o077) + session = None + old_term = None + try: + session = Session([adb], args.serial, args.output, args.watch, args.duration, args.root) + old_term = signal.signal(signal.SIGTERM, lambda signum, frame: session.stop.set()) + print("Capture directory: " + str(session.directory), flush=True) + return session.collect() + except OSError as error: + parser.exit(1, "Capture failed: %s\n" % error) + finally: + if old_term is not None: + signal.signal(signal.SIGTERM, old_term) + os.umask(previous_umask) + + +if __name__ == "__main__": + raise SystemExit(main())