aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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 })