diff options
| -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 }) |