diff --git a/controller/app/src/main/java/org/iiab/controller/RsyncManager.java b/controller/app/src/main/java/org/iiab/controller/RsyncManager.java index 0894432..e2819f2 100644 --- a/controller/app/src/main/java/org/iiab/controller/RsyncManager.java +++ b/controller/app/src/main/java/org/iiab/controller/RsyncManager.java @@ -17,6 +17,7 @@ import org.iiab.controller.sync.domain.RsyncConfig; import org.iiab.controller.sync.domain.RsyncOutcome; import org.iiab.controller.sync.domain.RsyncProgress; +import org.iiab.controller.sync.domain.RsyncProcessMatcher; import org.iiab.controller.sync.domain.ShareConfig; import org.iiab.controller.sync.domain.SyncCredentialValidator; import org.iiab.controller.sync.transport.SecretStore; @@ -24,6 +25,7 @@ import java.io.BufferedReader; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStreamReader; import java.io.PrintWriter; @@ -32,6 +34,7 @@ public class RsyncManager implements TransportEngine { private static final String TAG = "IIAB-RsyncManager"; private volatile Process rsyncProcess; + private volatile String rsyncBinPath; // ADFA-4539: so stop() can find our per-connection children in /proc private volatile boolean isCancelled = false; // S10: read/written across threads private SecretStore secretStore; @@ -55,6 +58,7 @@ public boolean startServer(Context context, ShareConfig config, String pass, Str try { File nativeLibDir = new File(context.getApplicationInfo().nativeLibraryDir); File rsyncBin = new File(nativeLibDir, "librsync.so"); + rsyncBinPath = rsyncBin.getAbsolutePath(); if (!rsyncBin.exists()) { Log.e(TAG, context.getString(R.string.rsync_error_binary_missing)); @@ -106,6 +110,7 @@ public void startClient(Context context, ShareConfig config, String hostIp, int try { File nativeLibDir = new File(context.getApplicationInfo().nativeLibraryDir); File rsyncBin = new File(nativeLibDir, "librsync.so"); + rsyncBinPath = rsyncBin.getAbsolutePath(); if (!rsyncBin.exists()) { throw new Exception(context.getString(R.string.rsync_error_binary_missing)); @@ -193,6 +198,7 @@ public void calculateTransferPlan(Context context, ShareConfig config, String ho try { File nativeLibDir = new File(context.getApplicationInfo().nativeLibraryDir); File rsyncBin = new File(nativeLibDir, "librsync.so"); + rsyncBinPath = rsyncBin.getAbsolutePath(); if (!rsyncBin.exists()) throw new Exception(context.getString(R.string.rsync_error_binary_missing)); @@ -252,11 +258,74 @@ public void stop() { if (secretStore != null) secretStore.clear(); // S11: don't let secrets outlive the session if (rsyncProcess != null) { try { - rsyncProcess.destroy(); + rsyncProcess.destroy(); // SIGTERM the parent daemon so it stops accepting/forking } catch (Exception e) { Log.w(TAG, "Error destroying rsync process on stop", e); } } + // ADFA-4539: destroy() only signals the parent. The daemon forks a child per + // connection (--no-detach keeps the parent in foreground); that child keeps + // streaming the transfer and reparents to init when the parent dies. So sweep + // /proc and SIGKILL every rsync process that is ours (matched by our unique + // librsync.so path), which covers the connection children on both the share + // (daemon) and receive (client) sides. + killOurRsyncProcesses(); + } + + /** + * Kill every rsync process launched from our app-private librsync.so, found by + * scanning /proc. Same-UID kills only (SIGKILL via android.os.Process.killProcess), + * and never our own app process. Best-effort: unreadable/vanished entries are skipped. + */ + private void killOurRsyncProcesses() { + String bin = rsyncBinPath; + if (bin == null || bin.isEmpty()) { + return; + } + File procDir = new File("/proc"); + File[] pidDirs = procDir.listFiles(); + if (pidDirs == null) { + return; + } + int myPid = android.os.Process.myPid(); + for (File dir : pidDirs) { + String name = dir.getName(); + int pid; + try { + pid = Integer.parseInt(name); + } catch (NumberFormatException notAPid) { + continue; + } + if (pid == myPid) { + continue; // never kill the app itself + } + String cmdline = readCmdline(new File(dir, "cmdline")); + if (RsyncProcessMatcher.isOurRsyncProcess(cmdline, bin)) { + try { + android.os.Process.killProcess(pid); // SIGKILL; same UID as us + Log.i(TAG, "ADFA-4539: killed lingering rsync pid " + pid); + } catch (Exception e) { + Log.w(TAG, "Could not kill rsync pid " + pid, e); + } + } + } + } + + /** Read /proc//cmdline (NUL-separated argv) as a space-joined string, or null. */ + private String readCmdline(File cmdlineFile) { + try (FileInputStream in = new FileInputStream(cmdlineFile)) { + byte[] buf = new byte[4096]; + int total = 0, r; + while (total < buf.length && (r = in.read(buf, total, buf.length - total)) != -1) { + total += r; + } + if (total == 0) { + return null; + } + return new String(buf, 0, total).replace('\0', ' ').trim(); + } catch (Exception e) { + return null; // not ours / vanished / no permission + } } private void writeTextToFile(File file, String text) throws Exception { diff --git a/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProcessMatcher.java b/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProcessMatcher.java new file mode 100644 index 0000000..839684b --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/sync/domain/RsyncProcessMatcher.java @@ -0,0 +1,44 @@ +/* + * ============================================================================ + * Name : RsyncProcessMatcher.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain rule for ADFA-4539. The rsync daemon runs with + * --daemon --no-detach and forks a child per connection; killing + * only the parent Process leaves the connection child streaming + * the transfer (it reparents to init). To stop for real we sweep + * /proc and kill every process that is one of ours, identified by + * our unique librsync.so path in its cmdline. This class is the + * pure, testable decision "is this cmdline one of our rsync + * processes?"; the /proc walk and the SIGKILL stay in the Android + * layer. No android.* here. + * ============================================================================ + */ +package org.iiab.controller.sync.domain; + +public final class RsyncProcessMatcher { + + private RsyncProcessMatcher() { + } + + /** + * True when {@code cmdline} (a /proc//cmdline, NUL bytes already turned + * into spaces or left as-is) belongs to one of our rsync processes, i.e. it + * was launched from our app-private {@code rsyncBinPath}. The path is unique + * to this app (its nativeLibraryDir), so this both matches all our rsync + * processes (parent daemon + per-connection children) and cannot match + * another app's or the system's processes. + * + * @param cmdline raw cmdline of a candidate process (may be null/empty) + * @param rsyncBinPath absolute path of our librsync.so (may be null/empty) + */ + public static boolean isOurRsyncProcess(String cmdline, String rsyncBinPath) { + if (cmdline == null || rsyncBinPath == null) { + return false; + } + if (cmdline.isEmpty() || rsyncBinPath.isEmpty()) { + return false; + } + return cmdline.contains(rsyncBinPath); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProcessMatcherTest.java b/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProcessMatcherTest.java new file mode 100644 index 0000000..9470432 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/sync/domain/RsyncProcessMatcherTest.java @@ -0,0 +1,46 @@ +package org.iiab.controller.sync.domain; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Match our rsync processes by our unique librsync.so path in the cmdline. Pure JVM. */ +public class RsyncProcessMatcherTest { + + private static final String BIN = + "/data/app/org.iiab.controller-1/lib/arm64/librsync.so"; + + @Test + public void matchesTheDaemonParent() { + // /proc cmdline is NUL-separated; here shown space-separated + String cmd = BIN + " --daemon --no-detach --config=/data/.../rsyncd.conf"; + assertTrue(RsyncProcessMatcher.isOurRsyncProcess(cmd, BIN)); + } + + @Test + public void matchesAForkedConnectionChild() { + // rsync rewrites argv per connection but still runs our binary + String cmd = BIN + " --server --daemon ."; + assertTrue(RsyncProcessMatcher.isOurRsyncProcess(cmd, BIN)); + } + + @Test + public void doesNotMatchAnotherProcess() { + assertFalse(RsyncProcessMatcher.isOurRsyncProcess("/system/bin/app_process /system/bin --application", BIN)); + } + + @Test + public void doesNotMatchOurOwnAppProcess() { + // our app's main process cmdline is the package name, not the binary path + assertFalse(RsyncProcessMatcher.isOurRsyncProcess("org.iiab.controller", BIN)); + } + + @Test + public void nullOrEmptyIsNoMatch() { + assertFalse(RsyncProcessMatcher.isOurRsyncProcess(null, BIN)); + assertFalse(RsyncProcessMatcher.isOurRsyncProcess("", BIN)); + assertFalse(RsyncProcessMatcher.isOurRsyncProcess(BIN, null)); + assertFalse(RsyncProcessMatcher.isOurRsyncProcess(BIN, "")); + } +}