aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-23 00:00:00 +0000
committerLena <lena@omega>2026-08-23 00:00:00 +0000
commit780d3167a427d6964806694ef5282be0c6f4cd33 (patch)
tree2d49e4e525702cd55195b33f229fa8436cd0e1d4
parent0c653418a67fe960bb3126559fe77833faaa385c (diff)
downloadrsend-780d3167a427d6964806694ef5282be0c6f4cd33.tar.gz
doc: correct drift and move the build notes to the root
The Rsync comment about draining a hostile remote's pipe sat on the Tally type, where it means nothing; it describes boundedLines. Scheduler explained itself with what happened during development instead of the constraint that WorkManager persists unique names across upgrades, and MainActivity and SyncLog did the same. Keys pointed at a pin() that does not exist. The App header restated the class name and omitted half of what onCreate does. OUT_FORMAT had no caller outside its file. The README layout list omitted fastlane. The e2e test hardcodes a copy of the app's rsync vector with nothing saying so. useradd already writes a locked password field, so "skip passwd -l" does not leave a non-locked one and key auth is still refused; verified against sshd with UsePAM no. The CI file also runs verify-repro on a tag, and the NDK and build-tools are pinned by revision and verified against Google's manifest rather than a checksum this repository owns, which the reproducibility notes claimed for every input. metadata/ held one document and nothing else, and reads as a store metadata directory beside fastlane/metadata. Root, extensionless, beside THIRD_PARTY.
-rw-r--r--README17
-rw-r--r--REPRODUCIBLE-BUILDS (renamed from metadata/reproducible-builds.md)14
-rw-r--r--app/build.gradle6
-rw-r--r--app/src/main/java/invalid/lena/rsend/App.kt1
-rw-r--r--app/src/main/java/invalid/lena/rsend/Keys.kt6
-rw-r--r--app/src/main/java/invalid/lena/rsend/MainActivity.kt7
-rw-r--r--app/src/main/java/invalid/lena/rsend/Rsync.kt15
-rw-r--r--app/src/main/java/invalid/lena/rsend/Scheduler.kt10
-rw-r--r--app/src/main/java/invalid/lena/rsend/SyncLog.kt4
-rw-r--r--rsh/e2e_test.go3
-rw-r--r--rsh/transport_test.go4
11 files changed, 49 insertions, 38 deletions
diff --git a/README b/README
index f9722c9..d5fcf48 100644
--- a/README
+++ b/README
@@ -47,9 +47,10 @@ Layout
- rsync/ build script that compiles pinned rsync from source via the NDK.
- app/ Android app (Kotlin, classic Views); bundles both as lib*.so.
- ci/ CI-agnostic build and test scripts; the Makefile drives them.
-- metadata/ reproducibility and F-Droid build notes.
+- fastlane/ F-Droid store listing: descriptions, changelogs, screenshots.
- THIRD_PARTY complete shipped-component inventory and license notices.
- APACHE-2.0 license terms for the shipped Apache-licensed components.
+- REPRODUCIBLE-BUILDS determinism, pinning, and F-Droid build notes.
- versions pinned toolchain and source versions; the single source of truth.
@@ -74,8 +75,9 @@ build; see versions.
`make test` also requires host rsync and lets govulncheck query the public Go
vulnerability database. No source code is uploaded. The APK lands under
app/build/outputs/apk/. It is unsigned by default and cannot be installed until
-signed. .gitlab-ci.yml runs exactly those scripts and holds no build logic of
-its own: ci/setup-toolchain.sh, ci/test.sh, and ci/build.sh.
+signed. .gitlab-ci.yml runs exactly these scripts and holds no build logic of
+its own: ci/setup-toolchain.sh, ci/test.sh, ci/build.sh, and, on a tag,
+ci/verify-repro.sh.
For a locally installable APK, create a keystore and a gitignored
keystore.properties before building:
@@ -146,9 +148,12 @@ write-only root. As root on the server:
This assumes UsePAM yes in sshd_config (the default on most distros; set it if
your build has it off). With UsePAM yes a password-locked account still accepts
-key logins. With UsePAM no, sshd refuses any locked account even for keys
-("account is locked"); there, skip passwd -l, set PasswordAuthentication no, and
-leave the account with a non-locked password field.
+key logins. With UsePAM no, sshd refuses any account whose password field is
+locked, even for keys, and useradd writes a locked field of its own, so omitting
+passwd -l is not enough. There, set PasswordAuthentication no and give the
+account a password nobody knows:
+
+ printf 'rsendbackup:%s\n' "$(head -c 24 /dev/urandom | base64)" | chpasswd
Put the app's public key in /srv/backup/.ssh/authorized_keys as one restricted
line that forces rrsync, write-only, into that directory:
diff --git a/metadata/reproducible-builds.md b/REPRODUCIBLE-BUILDS
index ee3102d..7cfcde9 100644
--- a/metadata/reproducible-builds.md
+++ b/REPRODUCIBLE-BUILDS
@@ -8,14 +8,20 @@ Pinning
Every tool and source version lives in ./versions: the rsync source, Go, JDK,
Android NDK, SDK platform, build-tools, command-line tools, Gradle, the Android
Gradle Plugin, and Kotlin. Directly downloaded archives have repository-owned
-checksums. The SDK platform revision and android.jar bytes are checked after
-sdkmanager installs them. Temurin's glibc and musl archives have separate
-checksums and cache directories. The Gradle wrapper records its distribution
-checksum too. The
+checksums. Temurin's glibc and musl archives have separate checksums and cache
+directories. The Gradle wrapper records its distribution checksum too. The
gradle files and go.mod repeat some pins because gradle cannot source a shell
file. Gradle's verification-metadata.xml locks every resolved Maven and plugin
artifact by SHA-256. ci/test.sh fails if direct pins or packaged notices drift.
+The packages sdkmanager installs are the exception. The NDK and build-tools are
+pinned by revision and verified against the checksums Google publishes in the
+SDK repository manifest, not against a checksum this repository owns; only the
+platform's android.jar is checked here byte for byte. A republished revision
+would therefore change the output without any pin in this tree changing. Adding
+repository-owned hashes would mean bypassing sdkmanager and fetching the
+multi-gigabyte archives directly, which is not worth the exchange.
+
After a deliberate dependency change, regenerate the checksum manifest while
running every Gradle path that resolves artifacts, then review its diff:
diff --git a/app/build.gradle b/app/build.gradle
index 3c73d2e..17768b0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -19,7 +19,7 @@ android {
targetSdk 35
versionCode 5
versionName '0.2.0'
- // Ship only the ABIs we build native libs for.
+ // Ship only the ABIs the native build scripts produce (ABIS in versions).
ndk { abiFilters 'arm64-v8a', 'x86_64' }
}
@@ -69,8 +69,8 @@ android {
buildConfig true
}
- // Native executables ship as lib*.so and must be extracted so we can exec
- // them from nativeLibraryDir (useLegacyPackaging => extractNativeLibs=true).
+ // Native executables ship as lib*.so and must be extracted to be exec'd from
+ // nativeLibraryDir (useLegacyPackaging => extractNativeLibs=true).
packaging {
jniLibs {
useLegacyPackaging true
diff --git a/app/src/main/java/invalid/lena/rsend/App.kt b/app/src/main/java/invalid/lena/rsend/App.kt
index 443229d..0ba1604 100644
--- a/app/src/main/java/invalid/lena/rsend/App.kt
+++ b/app/src/main/java/invalid/lena/rsend/App.kt
@@ -5,7 +5,6 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import kotlin.concurrent.thread
-// App creates the notification channel used by the foreground sync service.
class App : Application() {
override fun onCreate() {
diff --git a/app/src/main/java/invalid/lena/rsend/Keys.kt b/app/src/main/java/invalid/lena/rsend/Keys.kt
index 23a3b97..72f9654 100644
--- a/app/src/main/java/invalid/lena/rsend/Keys.kt
+++ b/app/src/main/java/invalid/lena/rsend/Keys.kt
@@ -27,7 +27,7 @@ object Keys {
// generate creates a key pair via rsh -keygen (the plaintext key never
// touches disk), stores it encrypted, and returns the public key in
- // authorized_keys format. Storage and validation reuse importKey.
+ // authorized_keys format. Validation and storage are shared with importKey.
fun generate(ctx: Context): String = synchronized(keyLock) {
val result = Native.run(Native.rsh(ctx), listOf("-keygen"))
if (result.code != 0) {
@@ -97,8 +97,8 @@ object Keys {
data class Scan(val ok: Boolean, val fingerprint: String, val line: String, val error: String)
// scan connects (proving the key is installed), captures the host key, and
- // returns its fingerprint and the known_hosts line to pin. pin() writes it
- // once the user accepts.
+ // returns its fingerprint and the known_hosts line to pin. Nothing is
+ // stored; RemoteActivity saves the line once the user accepts it.
fun scan(ctx: Context, remote: Remote): Scan = synchronized(keyLock) {
// Point the scan at this remote's own pin, so rsh offers the key type
// already pinned and an unchanged server reproduces its stored line. A
diff --git a/app/src/main/java/invalid/lena/rsend/MainActivity.kt b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
index f523a04..bba3ada 100644
--- a/app/src/main/java/invalid/lena/rsend/MainActivity.kt
+++ b/app/src/main/java/invalid/lena/rsend/MainActivity.kt
@@ -55,8 +55,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var syncStatus: TextView
// Last drawn schedule and the observed state of the periodic job. Observing
- // it keeps the dashboard off the 500ms blocking WorkManager query that used
- // to run on the main thread for every refresh.
+ // it keeps the dashboard off WorkManager's blocking query, which costs
+ // hundreds of milliseconds and must not run on the main thread.
private var schedule: Schedule = Schedule()
private var jobState: String = "none"
private var recoveryShown = false
@@ -281,8 +281,7 @@ class MainActivity : AppCompatActivity() {
)
// Every field above comes from an app-private file, so read them on a worker
- // thread and draw the result. Reading them during layout was the same
- // main-thread stall the WorkManager query was moved off.
+ // thread and draw the result.
private fun refresh() {
val current = ++generation
thread {
diff --git a/app/src/main/java/invalid/lena/rsend/Rsync.kt b/app/src/main/java/invalid/lena/rsend/Rsync.kt
index 93ce623..d4c8f40 100644
--- a/app/src/main/java/invalid/lena/rsend/Rsync.kt
+++ b/app/src/main/java/invalid/lena/rsend/Rsync.kt
@@ -44,11 +44,10 @@ object Rsync {
// Relative, so rsync excludes it automatically and --delete leaves it alone.
const val PARTIAL_DIR = ".rsend-partial"
- // "<operation> <path>": %o is send, recv or del.
- const val OUT_FORMAT = "%o %n"
-
- // Transfers are counted rather than logged one line each. Deletions and
- // everything else go to the log verbatim.
+ // "<operation> <path>": %o is send, recv or del. Transfers are counted
+ // rather than logged one line each; deletions and everything else go to the
+ // log verbatim.
+ private const val OUT_FORMAT = "%o %n"
private const val SENT = "send "
private const val DELETED = "del. "
internal const val MAX_OUTPUT_LINE_BYTES = 16 * 1024
@@ -140,9 +139,6 @@ object Rsync {
}
}
- // A hostile or broken remote can print an arbitrarily long line. Keep
- // draining its pipe so rsync cannot deadlock, but retain only a bounded
- // prefix for the log.
internal data class Tally(val sent: Long, val deleted: Long, val unreachable: Boolean)
// tally reads rsync's merged output, counts what it did, and passes
@@ -163,6 +159,9 @@ object Rsync {
return Tally(sent, deleted, unreachable)
}
+ // A hostile or broken remote can print an arbitrarily long line. Keep
+ // draining its pipe so rsync cannot deadlock, but retain only a bounded
+ // prefix for the log.
internal fun boundedLines(input: InputStream, emit: (String) -> Unit) {
val line = ByteArrayOutputStream()
val buf = ByteArray(4096)
diff --git a/app/src/main/java/invalid/lena/rsend/Scheduler.kt b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
index c35fb42..46e43e7 100644
--- a/app/src/main/java/invalid/lena/rsend/Scheduler.kt
+++ b/app/src/main/java/invalid/lena/rsend/Scheduler.kt
@@ -11,9 +11,9 @@ import java.util.concurrent.TimeUnit
// Scheduler keeps one periodic sync request aligned with the saved config.
object Scheduler {
- // Keep this distinct from the retired pre-0.1.3 one-time chain. WorkManager
- // persists unique names across app upgrades, so changing it creates a
- // second schedule and leaves the first one invisible to the UI.
+ // WorkManager persists unique names across app upgrades, so changing NAME
+ // enqueues a second schedule and leaves the installed one running, invisible
+ // to the UI. RETIRED_NAME is what earlier releases used.
const val NAME = "periodic-sync-v2"
private const val RETIRED_NAME = "periodic-sync"
@@ -22,8 +22,8 @@ object Scheduler {
val s = cfg.schedule
val wm = WorkManager.getInstance(ctx)
- // Idempotently retire both the old one-time chain and any periodic job
- // created by the unreleased name regression.
+ // An install upgraded from an earlier release still holds work under the
+ // old name. Cancelling is idempotent and costs nothing on a fresh one.
wm.cancelUniqueWork(RETIRED_NAME)
if (!s.enabled || !cfg.syncReady(Keys.exists(ctx))) {
diff --git a/app/src/main/java/invalid/lena/rsend/SyncLog.kt b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
index 64de544..b7d9721 100644
--- a/app/src/main/java/invalid/lena/rsend/SyncLog.kt
+++ b/app/src/main/java/invalid/lena/rsend/SyncLog.kt
@@ -25,8 +25,8 @@ class SyncLog(ctx: Context) {
}
}
- // Roll over rather than truncate. Truncating destroyed every earlier run,
- // and a run that outgrew the cap destroyed its own output, including the
+ // Roll over rather than truncate: truncating drops every earlier run, and a
+ // run that outgrows the cap would destroy its own output, including the
// record of what it had just deleted on the server.
private fun rotate() {
if (previous.exists() && !previous.delete()) {
diff --git a/rsh/e2e_test.go b/rsh/e2e_test.go
index e6f82b9..f4b44b4 100644
--- a/rsh/e2e_test.go
+++ b/rsh/e2e_test.go
@@ -46,6 +46,9 @@ func TestEndToEndRealRsync(t *testing.T) {
mustWrite(t, filepath.Join(src, "sub", "b.bin"), "\x00\x01\x02\x03beta")
mustWrite(t, filepath.Join(src, "sub", "c.txt"), "gamma gamma gamma")
+ // These flags are a copy of the vector Rsync.args builds in the app. Nothing
+ // links the two, so changing one without the other leaves this test proving
+ // an argument vector the app no longer runs.
runRsync := func(mirror bool) {
t.Helper()
args := []string{
diff --git a/rsh/transport_test.go b/rsh/transport_test.go
index d06f086..1afa1cf 100644
--- a/rsh/transport_test.go
+++ b/rsh/transport_test.go
@@ -148,8 +148,8 @@ func TestDialTimeoutFromEnv(t *testing.T) {
}
}
-// A host we cannot reach must be reported with the marker the app greps for,
-// so it can skip the rest of the folders bound to that remote.
+// An unreachable host must be reported with the marker the app greps for, so it
+// can skip the rest of the folders bound to that remote.
func TestUnreachableHostIsMarked(t *testing.T) {
keyPath, pub := genClientKey(t)
srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 })