diff options
| author | Lena <lena@omega> | 2026-08-23 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-23 00:00:00 +0000 |
| commit | 780d3167a427d6964806694ef5282be0c6f4cd33 (patch) | |
| tree | 2d49e4e525702cd55195b33f229fa8436cd0e1d4 | |
| parent | 0c653418a67fe960bb3126559fe77833faaa385c (diff) | |
| download | rsend-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-- | README | 17 | ||||
| -rw-r--r-- | REPRODUCIBLE-BUILDS (renamed from metadata/reproducible-builds.md) | 14 | ||||
| -rw-r--r-- | app/build.gradle | 6 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/App.kt | 1 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Keys.kt | 6 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/MainActivity.kt | 7 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Rsync.kt | 15 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/Scheduler.kt | 10 | ||||
| -rw-r--r-- | app/src/main/java/invalid/lena/rsend/SyncLog.kt | 4 | ||||
| -rw-r--r-- | rsh/e2e_test.go | 3 | ||||
| -rw-r--r-- | rsh/transport_test.go | 4 |
11 files changed, 49 insertions, 38 deletions
@@ -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 }) |