Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
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;
import org.iiab.controller.sync.transport.TransportEngine;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
Expand All @@ -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;

Expand All @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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/<pid>/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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<pid>/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);
}
}
Original file line number Diff line number Diff line change
@@ -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, ""));
}
}