From b1593921b866f3ce49b78ef553723c371d47ade1 Mon Sep 17 00:00:00 2001 From: Guy Dupont Date: Tue, 15 Sep 2026 12:27:55 -0400 Subject: [PATCH 1/3] chunk backups for easier resumes --- .github/workflows/pr-check.yml | 2 + README.md | 17 +- .../com/thelightphone/backup/BackupWorker.kt | 7 +- .../com/thelightphone/backup/RemoteBackup.kt | 182 ++++++++++----- .../backup/BackupRunnerContractTest.kt | 218 ++++++++++++++++-- .../backup/BackupRunnerResumeTest.kt | 87 +++++++ .../thelightphone/backup/ChunkWindowsTest.kt | 88 +++++++ .../com/thelightphone/backup/FakeClock.kt | 8 + .../backup/InMemoryRemoteBackup.kt | 82 +++++++ .../backup/OfflineBackupRunnerContractTest.kt | 11 + 10 files changed, 610 insertions(+), 92 deletions(-) create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerResumeTest.kt create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/ChunkWindowsTest.kt create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/FakeClock.kt create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/InMemoryRemoteBackup.kt create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OfflineBackupRunnerContractTest.kt diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 06b4f5b..75b0ca9 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -30,6 +30,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: 'platform-tools' - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 diff --git a/README.md b/README.md index fe66c3f..6ee403e 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,17 @@ This repo is open source, but is depended on by shipping Light Phone products - for examples. - [**`BackupDataSource`**](library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt) - the interface a host app implements to say *what* to back up (`getPathsToBackUp`, - `getFilesToBackUpForPath`, `readFile`, `hashForFile`). This library ships no implementation, it - only knows how to move bytes once told which ones. + `getFilesToBackUpForPath`, `readFile`, `hashForFile`, `getEarliestPossibleBackupDate`). This + library ships no implementation, it only knows how to move bytes once told which ones. - [**`BackupRunner`**](library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt) - - orchestrates one backup pass: walks each path, uploads what's changed since that path's last - backup, writes its checksum manifest, and records a `_meta` entry for any path that made progress - (these are used to determine time of last successful backup). Platform-agnostic - on Android it's - invoked by [`BackupWorker`](library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt), - an Android WorkManager `CoroutineWorker`. + orchestrates one backup pass: for each path, splits the time since its last backup into bounded, + independently-completable windows (one day by default - see `chunkWindows`), uploads what's changed + within each window, writes its checksum manifest, and records a `_meta` entry for any window that + made progress (these are used to determine time of last successful backup). Windowing means a long + catch-up period doesn't have to be redone in full after a partial failure - only the windows after + the last one that completed do. Platform-agnostic - on Android it's invoked by + [`BackupWorker`](library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt), an + Android WorkManager `CoroutineWorker`. - [**`RemoteAccessTokenProvider`** / **`StoredOAuthTokenProvider`**](library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteAccessTokenProvider.kt) - provider-agnostic OAuth access token caching, refresh, and persistence. diff --git a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt index 279dcc2..817bc0f 100644 --- a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt +++ b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt @@ -16,7 +16,6 @@ import androidx.work.WorkerParameters import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes -import kotlin.time.Instant import kotlin.time.toJavaDuration // Runs one BackupRunner pass @@ -33,7 +32,7 @@ class BackupWorker( val remoteBackup = buildRemoteBackup(provider) ?: return Result.failure() val runner = BackupRunner(remoteBackup, dependencyProvider.createDataSource(), clock) - return when (val result = runner.run(todayDirectoryName(clock.now()))) { + return when (val result = runner.run()) { is BackupResult.Completed -> { preferences.setLastBackupStatus(BackupStatus.Succeeded(clock.now())) Result.success() @@ -76,10 +75,6 @@ class BackupWorker( } } -// "yyyy-MM-ddTHH-mm-ssZ" -private fun todayDirectoryName(now: Instant): String = - Instant.fromEpochSeconds(now.epochSeconds).toString().replace(":", "-") - // use a worker factory: // class App : Application(), Configuration.Provider { // override val workManagerConfiguration diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt index 64798ae..0c4bf62 100644 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt +++ b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt @@ -8,6 +8,8 @@ import java.io.InputStream import java.io.OutputStream import kotlin.random.Random import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.Instant enum class RemoteBackupProvider { @@ -77,11 +79,19 @@ data class BackupPath(val authority: String, val localPath: Path, val label: Str interface BackupDataSource { suspend fun getPathsToBackUp(): Result> - suspend fun getFilesToBackUpForPath(parent: Path, timeOfLastBackup: Instant): Result> + + // Files under parent that were created/modified within (lowerBound, upperBound] (and should be backed up) + suspend fun getFilesToBackUpForPath(parent: Path, lowerBound: Instant, upperBound: Instant): Result> + suspend fun readFile(path: Path): Result // SHA-256 hex digest of the file's current contents. Independent of readFile suspend fun hashForFile(path: Path): Result + + // The earliest timestamp among files currently under parent that could ever need backing up + // (likely the oldest file's creation/modified time). + // BackupRunner uses this as the starting lower bound when a label has never completed a backup + suspend fun getEarliestPossibleBackupDate(parent: Path): Result } // Name of the per-leaf-directory manifest BackupRunner uploads alongside each path's files. @@ -92,8 +102,11 @@ private fun buildChecksumManifest(entries: List>): ByteArra private const val TIMESTAMP_LENGTH = 20 // "yyyy-MM-ddTHH-mm-ssZ" +// Precision for marking backups as successful +internal fun Instant.truncatedToWholeSeconds(): Instant = Instant.fromEpochSeconds(epochSeconds) + internal fun metaFileNameFor(instant: Instant): String { - val timestamp = Instant.fromEpochSeconds(instant.epochSeconds).toString().replace(':', '-') + val timestamp = instant.truncatedToWholeSeconds().toString().replace(':', '-') val suffix = Random.nextBytes(4).joinToString("") { "%02x".format(it) } return "$timestamp-$suffix" } @@ -125,11 +138,15 @@ class BackupRunner( private val remoteBackup: RemoteBackup, private val dataSource: BackupDataSource, private val clock: Clock = Clock.System, + private val chunkDuration: Duration = 1.days, ) { - suspend fun run(todayDirectoryName: String): BackupResult { + suspend fun run(): BackupResult { // structure in backup root in cloud is // tool-id/date/file.example - // where there is one date directory per backup + // where there is one date directory per backup window + + // Truncated so window boundaries line up exactly + val now = clock.now().truncatedToWholeSeconds() val timesOfLastBackup = remoteBackup.getMostRecentBackupDates().getOrElse { return BackupResult.Failed(BackupFailure(FailureScope.Run, it)) @@ -143,73 +160,89 @@ class BackupRunner( val failures = mutableListOf() for (path in paths) { - val timeOfLastBackup = timesOfLastBackup[path.label] ?: Instant.DISTANT_PAST - val files = dataSource - .getFilesToBackUpForPath(path.localPath, timeOfLastBackup) + val resumeFrom = timesOfLastBackup[path.label] ?: dataSource + .getEarliestPossibleBackupDate(path.localPath) .getOrElse { - // Local listing failures are never fatal to the run (see isFatalToBackupRun) failures += BackupFailure(FailureScope.Path(path.label), it) continue } - if (files.isEmpty()) continue + for (window in chunkWindows(resumeFrom, now, chunkDuration)) { + val directoryName = backupDirectoryNameFor(window.upperBound) - val timeRemotePath = Path(Path(remoteBackup.rootFolderPath, path.label), todayDirectoryName) + val files = dataSource + .getFilesToBackUpForPath(path.localPath, window.lowerBound, window.upperBound) + .getOrElse { + // Local listing failures are never fatal to the run (see isFatalToBackupRun) + failures += BackupFailure(FailureScope.Path(path.label), it) + continue + } - remoteBackup.createDirectory(timeRemotePath).exceptionOrNull()?.let { cause -> - val failure = BackupFailure(FailureScope.Path(path.label), cause) - if (cause.isFatalToBackupRun()) { - return abort(filesBackedUp, failures, path.label, todayDirectoryName, 0, failure) - } - failures += failure - continue - } + if (files.isEmpty()) continue - val checksums = mutableListOf>() - var pathFilesBackedUp = 0 + val windowRemotePath = Path(Path(remoteBackup.rootFolderPath, path.label), directoryName) - for (file in files) { - val data = dataSource.readFile(file).getOrElse { - failures += BackupFailure(FailureScope.File(path.label, file.name), it) - continue - } - val uploadError = remoteBackup.uploadFile(timeRemotePath, file.name, data).exceptionOrNull() - if (uploadError != null) { - val failure = BackupFailure(FailureScope.File(path.label, file.name), uploadError) - if (uploadError.isFatalToBackupRun()) { - return abort(filesBackedUp, failures, path.label, todayDirectoryName, pathFilesBackedUp, failure) + remoteBackup.createDirectory(windowRemotePath).exceptionOrNull()?.let { cause -> + val failure = BackupFailure(FailureScope.Path(path.label), cause) + if (cause.isFatalToBackupRun()) { + return abort(filesBackedUp, failures, path.label, directoryName, 0, window.upperBound, failure) } failures += failure continue } - filesBackedUp++ - pathFilesBackedUp++ - - // The file is backed up either way at this point - a hash failure only means it - // won't be verifiable via the manifest, not that the upload itself is invalid. - dataSource.hashForFile(file).fold( - onSuccess = { hash -> checksums += file.name to hash }, - onFailure = { failures += BackupFailure(FailureScope.File(path.label, file.name), it) }, - ) - } - if (checksums.isNotEmpty()) { - val manifest = buildChecksumManifest(checksums) - val manifestError = remoteBackup - .uploadFile(timeRemotePath, CHECKSUM_MANIFEST_FILE_NAME, manifest.inputStream()) - .exceptionOrNull() - if (manifestError != null) { - val failure = BackupFailure(FailureScope.Path(path.label), manifestError) - if (manifestError.isFatalToBackupRun()) { - return abort(filesBackedUp, failures, path.label, todayDirectoryName, pathFilesBackedUp, failure) + val checksums = mutableListOf>() + var windowFilesBackedUp = 0 + + for (file in files) { + val data = dataSource.readFile(file).getOrElse { + failures += BackupFailure(FailureScope.File(path.label, file.name), it) + continue } - failures += failure + val uploadError = remoteBackup.uploadFile(windowRemotePath, file.name, data).exceptionOrNull() + if (uploadError != null) { + val failure = BackupFailure(FailureScope.File(path.label, file.name), uploadError) + if (uploadError.isFatalToBackupRun()) { + return abort( + filesBackedUp, failures, path.label, directoryName, + windowFilesBackedUp, window.upperBound, failure, + ) + } + failures += failure + continue + } + filesBackedUp++ + windowFilesBackedUp++ + + // The file is backed up either way at this point - a hash failure only means it + // won't be verifiable via the manifest, not that the upload itself is invalid. + dataSource.hashForFile(file).fold( + onSuccess = { hash -> checksums += file.name to hash }, + onFailure = { failures += BackupFailure(FailureScope.File(path.label, file.name), it) }, + ) } - } - if (pathFilesBackedUp > 0) { - writeMetaEntry(path.label, todayDirectoryName, pathFilesBackedUp).exceptionOrNull()?.let { - failures += BackupFailure(FailureScope.Path(path.label), it) + if (checksums.isNotEmpty()) { + val manifest = buildChecksumManifest(checksums) + val manifestError = remoteBackup + .uploadFile(windowRemotePath, CHECKSUM_MANIFEST_FILE_NAME, manifest.inputStream()) + .exceptionOrNull() + if (manifestError != null) { + val failure = BackupFailure(FailureScope.Path(path.label), manifestError) + if (manifestError.isFatalToBackupRun()) { + return abort( + filesBackedUp, failures, path.label, directoryName, + windowFilesBackedUp, window.upperBound, failure, + ) + } + failures += failure + } + } + + if (windowFilesBackedUp > 0) { + writeMetaEntry(path.label, directoryName, windowFilesBackedUp, window.upperBound).exceptionOrNull()?.let { + failures += BackupFailure(FailureScope.Path(path.label), it) + } } } } @@ -221,13 +254,16 @@ class BackupRunner( } } - // Writes one _meta entry recording that `label` backed up `filesBackedUp` files as of now - - // filed under that path's own _meta subfolder (rootFolderPath/label/_meta/ - private suspend fun writeMetaEntry(label: String, directoryName: String, filesBackedUp: Int): Result { + // Writes one _meta entry recording that `label` backed up `filesBackedUp` files as of completedAt + private suspend fun writeMetaEntry( + label: String, + directoryName: String, + filesBackedUp: Int, + completedAt: Instant, + ): Result { val metaDirectory = Path(Path(remoteBackup.rootFolderPath, label), META_FOLDER_NAME) remoteBackup.createDirectory(metaDirectory).getOrElse { return Result.failure(it) } - val completedAt = clock.now() val summary = buildBackupSummary(directoryName, filesBackedUp, completedAt) return remoteBackup.uploadFile(metaDirectory, metaFileNameFor(completedAt), summary.inputStream()) } @@ -236,12 +272,13 @@ class BackupRunner( filesBackedUp: Int, failures: MutableList, inProgressLabel: String, - todayDirectoryName: String, - inProgressPathFilesBackedUp: Int, + directoryName: String, + inProgressWindowFilesBackedUp: Int, + completedAt: Instant, abortedBy: BackupFailure, ): BackupResult { - if (inProgressPathFilesBackedUp > 0) { - writeMetaEntry(inProgressLabel, todayDirectoryName, inProgressPathFilesBackedUp).exceptionOrNull()?.let { + if (inProgressWindowFilesBackedUp > 0) { + writeMetaEntry(inProgressLabel, directoryName, inProgressWindowFilesBackedUp, completedAt).exceptionOrNull()?.let { failures += BackupFailure(FailureScope.Path(inProgressLabel), it) } } @@ -251,4 +288,23 @@ class BackupRunner( BackupResult.Partial(filesBackedUp, failures, abortedBy) } } -} \ No newline at end of file +} + +// (lowerBound, upperBound] - strictly after lowerBound, up to and including upperBound. +internal data class BackupWindow(val lowerBound: Instant, val upperBound: Instant) + +// Splits (from, to] into consecutive windows no longer than chunkDuration +internal fun chunkWindows(from: Instant, to: Instant, chunkDuration: Duration = 1.days): List { + if (from >= to) return emptyList() + val windows = mutableListOf() + var lowerBound = from + while (lowerBound < to) { + val upperBound = minOf(lowerBound + chunkDuration, to) + windows += BackupWindow(lowerBound, upperBound) + lowerBound = upperBound + } + return windows +} + +internal fun backupDirectoryNameFor(instant: Instant): String = + instant.truncatedToWholeSeconds().toString().replace(':', '-') \ No newline at end of file diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerContractTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerContractTest.kt index c25af0c..1db17bf 100644 --- a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerContractTest.kt +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerContractTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant @@ -34,12 +35,12 @@ abstract class BackupRunnerContractTest { val dataSource = FakeBackupDataSource(paths, filesPerPath = 3) val runner = BackupRunner(remoteBackup, dataSource) - val result = runner.run("run-1") + val result = runner.run() assertIs(result) assertEquals(6, result.filesBackedUp) - val dir1RunFolder = Path(Path(remoteBackup.rootFolderPath, "dir1"), "run-1") + val dir1RunFolder = Path(Path(remoteBackup.rootFolderPath, "dir1"), onlyWindowFolder("dir1")) val fileContent = remoteBackup.downloadFile(dir1RunFolder, "dir1-file1.txt").getOrThrow() .bufferedReader().readText() @@ -67,7 +68,7 @@ abstract class BackupRunnerContractTest { val dataSource = FakeBackupDataSource(paths, filesPerPath = 3, failingFile = "dir1-file2.txt") val runner = BackupRunner(remoteBackup, dataSource) - val result = runner.run("run-partial") + val result = runner.run() assertIs(result) assertEquals(2, result.filesBackedUp) @@ -75,7 +76,7 @@ abstract class BackupRunnerContractTest { assertEquals(FailureScope.File("dir1", "dir1-file2.txt"), failure.scope) // The two good files made it despite the third's local read failure - val dir1RunFolder = Path(Path(remoteBackup.rootFolderPath, "dir1"), "run-partial") + val dir1RunFolder = Path(Path(remoteBackup.rootFolderPath, "dir1"), onlyWindowFolder("dir1")) val files = remoteBackup.listFiles(dir1RunFolder).getOrThrow() assertTrue("dir1-file1.txt" in files) assertTrue("dir1-file3.txt" in files) @@ -103,14 +104,17 @@ abstract class BackupRunnerContractTest { val dataSource = FakeBackupDataSource(paths, filesPerPath = 1, emptyLabels = setOf("dir2")) val runner = BackupRunner(remoteBackup, dataSource) - val result = runner.run("run-nothing-for-dir2") + val result = runner.run() assertIs(result) assertEquals(1, result.filesBackedUp) val rootSubfolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath)).getOrThrow() assertTrue("dir1" in rootSubfolders, "expected dir1 to be created since it had a file to back up") - assertTrue("dir2" !in rootSubfolders, "expected no dir2 folder since it had nothing to back up, got $rootSubfolders") + assertTrue( + "dir2" !in rootSubfolders, + "expected no dir2 folder since it had nothing to back up, got $rootSubfolders" + ) } @Test @@ -119,12 +123,12 @@ abstract class BackupRunnerContractTest { val clock = FakeClock(Clock.System.now()) val runner = BackupRunner(remoteBackup, dataSource, clock) - assertIs(runner.run("run-a")) + assertIs(runner.run()) val afterFirst = remoteBackup.getMostRecentBackupDates().getOrThrow()["dir1"] clock.now += 1.seconds - assertIs(runner.run("run-b")) + assertIs(runner.run()) val afterSecond = remoteBackup.getMostRecentBackupDates().getOrThrow()["dir1"] assertTrue(afterSecond!! > afterFirst!!, "expected $afterSecond to be after $afterFirst") @@ -132,9 +136,11 @@ abstract class BackupRunnerContractTest { val rootSubfolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath)).getOrThrow() assertEquals(1, rootSubfolders.count { it == "dir1" }, "expected dir1 to be created once and reused") - val dir1Subfolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, "dir1")).getOrThrow() - assertTrue("run-a" in dir1Subfolders) - assertTrue("run-b" in dir1Subfolders) + // Each run gets its own dated window folder under dir1 + val dir1WindowFolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, "dir1")) + .getOrThrow() + .filter { it != META_FOLDER_NAME } + assertEquals(2, dir1WindowFolders.size, "expected two distinct dated folders, got $dir1WindowFolders") // Each run wrote its own dir1/_meta entry, named after its own completedAt - not a shared // file keyed by directory name. @@ -143,6 +149,179 @@ abstract class BackupRunnerContractTest { .getOrThrow() assertEquals(2, dir1MetaFiles.size, "expected two dir1/_meta entries, got $dir1MetaFiles") } + + @Test + fun run_withACatchUpPeriodLongerThanChunkDuration_splitsIntoMultipleWindows() = runBlocking { + val start = Clock.System.now() + val clock = FakeClock(start + 3.hours) + val dataSource = FakeBackupDataSource( + paths = listOf(BackupPath("com.example", Path("dir1"), "dir1")), + earliestPossibleBackupDate = start, + filesForWindow = { label, _, upperBound -> Result.success(listOf("$label-${upperBound.epochSeconds}.txt")) }, + ) + val runner = BackupRunner(remoteBackup, dataSource, clock, chunkDuration = 1.hours) + + val result = runner.run() + + assertIs(result) + assertEquals(3, result.filesBackedUp) + + val windowFolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, "dir1")) + .getOrThrow() + .filter { it != META_FOLDER_NAME } + assertEquals(3, windowFolders.size, "expected one folder per 1-hour window, got $windowFolders") + + windowFolders.forEach { folder -> + val filesInFolder = remoteBackup + .listFiles(Path(Path(remoteBackup.rootFolderPath, "dir1"), folder)) + .getOrThrow() + assertEquals( + 2, filesInFolder.size, + "expected the one data file plus checksums manifest in $folder, got $filesInFolder", + ) + } + + val dir1MetaFiles = remoteBackup + .listFiles(Path(Path(remoteBackup.rootFolderPath, "dir1"), META_FOLDER_NAME)) + .getOrThrow() + assertEquals(3, dir1MetaFiles.size, "expected one _meta entry per window, got $dir1MetaFiles") + } + + @Test + fun run_withSparseWindows_onlyCreatesFoldersAndMetaEntriesForWindowsThatHadFiles() = runBlocking { + val start = Clock.System.now() + val clock = FakeClock(start + 3.hours) + // Only the middle of the 3 hourly windows has anything to back up. + val dataSource = FakeBackupDataSource( + paths = listOf(BackupPath("com.example", Path("dir1"), "dir1")), + earliestPossibleBackupDate = start, + filesForWindow = { label, lowerBound, _ -> + if (lowerBound == start + 1.hours) { + Result.success(listOf("$label-only-file.txt")) + } else { + Result.success(emptyList()) + } + }, + ) + val runner = BackupRunner(remoteBackup, dataSource, clock, chunkDuration = 1.hours) + + val result = runner.run() + + assertIs(result) + assertEquals(1, result.filesBackedUp) + + val windowFolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, "dir1")) + .getOrThrow() + .filter { it != META_FOLDER_NAME } + assertEquals(1, windowFolders.size, "expected only the non-empty window to get a folder, got $windowFolders") + + val dir1MetaFiles = remoteBackup + .listFiles(Path(Path(remoteBackup.rootFolderPath, "dir1"), META_FOLDER_NAME)) + .getOrThrow() + assertEquals(1, dir1MetaFiles.size, "expected exactly one _meta entry, for the window that had files") + } + + @Test + fun run_withANonFatalListingFailureInOneWindow_stillAttemptsLaterWindowsForThatPath() = runBlocking { + val start = Clock.System.now() + val clock = FakeClock(start + 3.hours) + val dataSource = FakeBackupDataSource( + paths = listOf(BackupPath("com.example", Path("dir1"), "dir1")), + earliestPossibleBackupDate = start, + filesForWindow = { label, lowerBound, upperBound -> + if (lowerBound == start) { + Result.failure(IOException("simulated local listing failure for the first window")) + } else { + Result.success(listOf("$label-${upperBound.epochSeconds}.txt")) + } + }, + ) + val runner = BackupRunner(remoteBackup, dataSource, clock, chunkDuration = 1.hours) + + val result = runner.run() + + assertIs(result) + assertEquals( + 2, + result.filesBackedUp, + "expected the later two windows to still complete despite the first's failure" + ) + assertEquals(FailureScope.Path("dir1"), result.failures.single().scope) + + val windowFolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, "dir1")) + .getOrThrow() + .filter { it != META_FOLDER_NAME } + assertEquals( + 2, + windowFolders.size, + "expected folders only for the two windows that succeeded, got $windowFolders" + ) + } + + @Test + fun run_withNothingEverToBackUp_completesWithoutCreatingAnyFolder() = runBlocking { + val now = Clock.System.now() + val dataSource = FakeBackupDataSource( + paths = listOf(BackupPath("com.example", Path("dir1"), "dir1")), + // Nothing under dir1 yet - see getEarliestPossibleBackupDate's contract for parent with + // nothing in it. + earliestPossibleBackupDate = now, + ) + val runner = BackupRunner(remoteBackup, dataSource, FakeClock(now)) + + val result = runner.run() + + assertIs(result) + assertEquals(0, result.filesBackedUp) + + val rootSubfolders = remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath)) + .getOrElse { if (it is RemoteBackupError.NotFound) emptyList() else throw it } + assertTrue( + "dir1" !in rootSubfolders, + "expected no dir1 folder since it never had anything to back up, got $rootSubfolders" + ) + } + + @Test + fun run_whenGetEarliestPossibleBackupDateFails_recordsAPathFailureAndContinuesOtherPaths() = runBlocking { + val clock = FakeClock(Clock.System.now()) + + // Seed dir1 with a completed backup so its resumeFrom comes from getMostRecentBackupDates, + // not getEarliestPossibleBackupDate - only dir2 (never backed up) needs the latter to + // succeed. + val seedRunner = BackupRunner( + remoteBackup, + FakeBackupDataSource(listOf(BackupPath("com.example", Path("dir1"), "dir1")), filesPerPath = 1), + clock, + ) + assertIs(seedRunner.run()) + + // A real gap, rather than relying on wall-clock time moving between the two run() calls - + // now gets truncated to whole seconds (see truncatedToWholeSeconds), so two runs within the + // same real-world second would otherwise see no new time to work with for dir1. + clock.now += 1.hours + + val paths = listOf( + BackupPath("com.example", Path("dir1"), "dir1"), + BackupPath("com.example", Path("dir2"), "dir2"), + ) + val dataSource = FakeBackupDataSource( + paths, filesPerPath = 1, + earliestPossibleBackupDateFailure = IOException("simulated getEarliestPossibleBackupDate failure"), + ) + val runner = BackupRunner(remoteBackup, dataSource, clock) + + val result = runner.run() + + assertIs(result) + assertEquals(1, result.filesBackedUp, "expected dir1, which already had a last-backup time, to still complete") + assertEquals(FailureScope.Path("dir2"), result.failures.single().scope) + } + + // There should be exactly one non-_meta subfolder under rootFolderPath/label; returns its name. + private suspend fun onlyWindowFolder(label: String): String = + remoteBackup.listSubdirectories(Path(remoteBackup.rootFolderPath, label)).getOrThrow() + .single { it != META_FOLDER_NAME } } private class FakeBackupDataSource( @@ -151,10 +330,18 @@ private class FakeBackupDataSource( private val failingFile: String? = null, // Labels that should report nothing left to back up, regardless of filesPerPath. private val emptyLabels: Set = emptySet(), + private val earliestPossibleBackupDate: Instant = Clock.System.now() - 1.hours, + private val earliestPossibleBackupDateFailure: Throwable? = null, + private val filesForWindow: ((label: String, lowerBound: Instant, upperBound: Instant) -> Result>)? = null, ) : BackupDataSource { override suspend fun getPathsToBackUp(): Result> = Result.success(paths) - override suspend fun getFilesToBackUpForPath(parent: Path, timeOfLastBackup: Instant): Result> { + override suspend fun getFilesToBackUpForPath( + parent: Path, + lowerBound: Instant, + upperBound: Instant, + ): Result> { + filesForWindow?.let { return it(parent.name, lowerBound, upperBound).map { names -> names.map(::Path) } } if (parent.name in emptyLabels) return Result.success(emptyList()) return Result.success((1..filesPerPath).map { Path("${parent.name}-file$it.txt") }) } @@ -168,9 +355,8 @@ private class FakeBackupDataSource( override suspend fun hashForFile(path: Path): Result = Result.success(sha256Hex(contentFor(path.name))) - fun contentFor(fileName: String) = "content for $fileName\n" -} + override suspend fun getEarliestPossibleBackupDate(parent: Path): Result = + earliestPossibleBackupDateFailure?.let { Result.failure(it) } ?: Result.success(earliestPossibleBackupDate) -private class FakeClock(var now: Instant) : Clock { - override fun now(): Instant = now + fun contentFor(fileName: String) = "content for $fileName\n" } diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerResumeTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerResumeTest.kt new file mode 100644 index 0000000..af3735d --- /dev/null +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupRunnerResumeTest.kt @@ -0,0 +1,87 @@ +package com.thelightphone.backup + +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import org.junit.Test +import java.io.InputStream +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class BackupRunnerResumeTest { + @Test + fun run_afterAFatalFailureMidCatchUp_resumesFromTheLastCompletedWindowOnly() = runBlocking { + val remoteBackup = InMemoryRemoteBackup() + val start = Clock.System.now() + val clock = FakeClock(start + 3.hours) + val window1Upper = start + 1.hours + val window2Upper = start + 2.hours + val window3Upper = start + 3.hours + + val dataSource = OneFilePerWindowDataSource(label = "dir1", earliestPossibleBackupDate = start) + val runner = BackupRunner(remoteBackup, dataSource, clock, chunkDuration = 1.hours) + + // Make the second window's upload fail fatally, aborting the run right after the first + // window completed. + remoteBackup.failUploadsTo(backupDirectoryNameFor(window2Upper), RemoteBackupError.QuotaExceeded()) + + val firstResult = runner.run() + assertIs(firstResult) + assertEquals(1, firstResult.filesBackedUp, "expected only the first window to make it before the abort") + assertIs(firstResult.abortedBy?.cause) + + val dir1Path = Path(remoteBackup.rootFolderPath, "dir1") + assertTrue( + backupDirectoryNameFor(window1Upper) in remoteBackup.listSubdirectories(dir1Path).getOrThrow(), + "expected the first window's folder to exist despite the later abort", + ) + assertEquals( + 1, + remoteBackup.listFiles(Path(dir1Path, META_FOLDER_NAME)).getOrThrow().size, + "expected only the first window's _meta entry to exist after the abort", + ) + + // Clear the injected fault and retry - should resume right after the first window's + // recorded progress (i.e. only redo windows 2 and 3), not redo window 1. + remoteBackup.clearUploadFailures() + val secondResult = runner.run() + + assertIs(secondResult) + assertEquals(2, secondResult.filesBackedUp, "expected only the two remaining windows to be redone") + + val allWindowFolders = remoteBackup.listSubdirectories(dir1Path).getOrThrow().filter { it != META_FOLDER_NAME } + assertEquals( + setOf(window1Upper, window2Upper, window3Upper).map(::backupDirectoryNameFor).toSet(), + allWindowFolders.toSet(), + "expected all three window folders to exist once resuming finished", + ) + assertEquals( + 3, + remoteBackup.listFiles(Path(dir1Path, META_FOLDER_NAME)).getOrThrow().size, + "expected 3 total _meta entries: 1 from the aborted run + 2 from the resume", + ) + } +} + +private class OneFilePerWindowDataSource( + private val label: String, + private val earliestPossibleBackupDate: Instant, +) : BackupDataSource { + override suspend fun getPathsToBackUp(): Result> = + Result.success(listOf(BackupPath("com.example", Path(label), label))) + + override suspend fun getFilesToBackUpForPath(parent: Path, lowerBound: Instant, upperBound: Instant): Result> = + Result.success(listOf(Path("${parent.name}-${upperBound.epochSeconds}.txt"))) + + override suspend fun readFile(path: Path): Result = Result.success(contentFor(path.name).byteInputStream()) + + override suspend fun hashForFile(path: Path): Result = Result.success(sha256Hex(contentFor(path.name))) + + override suspend fun getEarliestPossibleBackupDate(parent: Path): Result = + Result.success(earliestPossibleBackupDate) + + private fun contentFor(fileName: String) = "content for $fileName\n" +} \ No newline at end of file diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/ChunkWindowsTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/ChunkWindowsTest.kt new file mode 100644 index 0000000..d9c33d5 --- /dev/null +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/ChunkWindowsTest.kt @@ -0,0 +1,88 @@ +package com.thelightphone.backup + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +// boundary testing for chunkWindows +class ChunkWindowsTest { + @Test + fun chunkWindows_withFromEqualToTo_returnsNoWindows() { + val instant = Instant.parse("2026-09-12T00:00:00Z") + + assertEquals(emptyList(), chunkWindows(instant, instant)) + } + + @Test + fun chunkWindows_withFromAfterTo_returnsNoWindows() { + val from = Instant.parse("2026-09-12T12:00:00Z") + val to = Instant.parse("2026-09-12T00:00:00Z") + + assertEquals(emptyList(), chunkWindows(from, to)) + } + + @Test + fun chunkWindows_withSpanShorterThanChunkDuration_returnsOneWindowCoveringTheWholeSpan() { + val from = Instant.parse("2026-09-12T00:00:00Z") + val to = from + 5.hours + + val windows = chunkWindows(from, to, chunkDuration = 24.hours) + + assertEquals(listOf(BackupWindow(from, to)), windows) + } + + @Test + fun chunkWindows_withSpanAnExactMultipleOfChunkDuration_hasNoShortTrailingWindow() { + val from = Instant.parse("2026-09-12T00:00:00Z") + val to = from + 3.hours + + val windows = chunkWindows(from, to, chunkDuration = 1.hours) + + assertEquals( + listOf( + BackupWindow(from, from + 1.hours), + BackupWindow(from + 1.hours, from + 2.hours), + BackupWindow(from + 2.hours, from + 3.hours), + ), + windows, + ) + } + + @Test + fun chunkWindows_withARemainder_endsWithAShortFinalWindow() { + val from = Instant.parse("2026-09-12T00:00:00Z") + val to = from + 2.hours + 30.minutes + + val windows = chunkWindows(from, to, chunkDuration = 1.hours) + + assertEquals( + listOf( + BackupWindow(from, from + 1.hours), + BackupWindow(from + 1.hours, from + 2.hours), + BackupWindow(from + 2.hours, to), + ), + windows, + ) + } + + @Test + fun chunkWindows_windowsAreContiguousAndCoverTheWholeSpanExactlyOnce() { + val from = Instant.parse("2026-09-01T00:00:00Z") + val to = from + 30.hours + + val windows = chunkWindows(from, to, chunkDuration = 1.hours) + + assertEquals(from, windows.first().lowerBound) + assertEquals(to, windows.last().upperBound) + for (i in 1 until windows.size) { + assertEquals( + windows[i - 1].upperBound, windows[i].lowerBound, + "expected window $i to start exactly where window ${i - 1} ended", + ) + } + assertTrue(windows.all { it.lowerBound < it.upperBound }, "expected every window to be non-empty") + } +} \ No newline at end of file diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/FakeClock.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/FakeClock.kt new file mode 100644 index 0000000..4830949 --- /dev/null +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/FakeClock.kt @@ -0,0 +1,8 @@ +package com.thelightphone.backup + +import kotlin.time.Clock +import kotlin.time.Instant + +internal class FakeClock(var now: Instant) : Clock { + override fun now(): Instant = now +} \ No newline at end of file diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/InMemoryRemoteBackup.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/InMemoryRemoteBackup.kt new file mode 100644 index 0000000..d75a31e --- /dev/null +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/InMemoryRemoteBackup.kt @@ -0,0 +1,82 @@ +package com.thelightphone.backup + +import kotlinx.io.files.Path +import java.io.InputStream +import kotlin.time.Instant + +internal class InMemoryRemoteBackup(override val rootFolderPath: String = "root") : RemoteBackup { + private class Dir { + val files = mutableMapOf() + val subDirs = mutableSetOf() + } + + private val dirs = mutableMapOf() + + // (targetDirectoryName, error) + private var uploadFailure: Pair? = null + + fun failUploadsTo(directoryName: String, error: Throwable) { + uploadFailure = directoryName to error + } + + fun clearUploadFailures() { + uploadFailure = null + } + + private fun normalize(path: Path): String = path.segments().joinToString("/") + + override suspend fun getMostRecentBackupDates(): Result> { + val root = dirs[normalize(Path(rootFolderPath))] ?: return Result.success(emptyMap()) + val result = mutableMapOf() + for (label in root.subDirs) { + val metaDir = dirs[normalize(Path(Path(rootFolderPath, label), META_FOLDER_NAME))] ?: continue + val latest = metaDir.files.keys.mapNotNull { parseMetaFileName(it) }.maxOrNull() ?: continue + result[label] = latest + } + return Result.success(result) + } + + override suspend fun createDirectory(path: Path): Result { + val segments = path.segments() + var key = "" + for ((index, segment) in segments.withIndex()) { + val parentKey = key + key = if (index == 0) segment else "$key/$segment" + dirs.getOrPut(key) { Dir() } + if (index > 0) { + dirs.getValue(parentKey).subDirs += segment + } + } + return Result.success(Unit) + } + + override suspend fun uploadFile(remoteDirectory: Path, fileName: String, data: InputStream): Result { + uploadFailure?.let { (directoryName, error) -> + if (remoteDirectory.name == directoryName) return Result.failure(error) + } + val dir = dirs[normalize(remoteDirectory)] + ?: return Result.failure(RemoteBackupError.NotFound(normalize(remoteDirectory))) + dir.files[fileName] = data.readBytes() + return Result.success(Unit) + } + + override suspend fun listFiles(remoteDirectory: Path): Result> { + val dir = dirs[normalize(remoteDirectory)] + ?: return Result.failure(RemoteBackupError.NotFound(normalize(remoteDirectory))) + return Result.success(dir.files.keys.toList()) + } + + override suspend fun listSubdirectories(remoteDirectory: Path): Result> { + val dir = dirs[normalize(remoteDirectory)] + ?: return Result.failure(RemoteBackupError.NotFound(normalize(remoteDirectory))) + return Result.success(dir.subDirs.toList()) + } + + override suspend fun downloadFile(remoteDirectory: Path, fileName: String): Result { + val dir = dirs[normalize(remoteDirectory)] + ?: return Result.failure(RemoteBackupError.NotFound(normalize(remoteDirectory))) + val bytes = dir.files[fileName] + ?: return Result.failure(RemoteBackupError.NotFound("${normalize(remoteDirectory)}/$fileName")) + return Result.success(bytes.inputStream()) + } +} \ No newline at end of file diff --git a/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OfflineBackupRunnerContractTest.kt b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OfflineBackupRunnerContractTest.kt new file mode 100644 index 0000000..50c9105 --- /dev/null +++ b/library/src/jvmSharedTest/kotlin/com/thelightphone/backup/OfflineBackupRunnerContractTest.kt @@ -0,0 +1,11 @@ +package com.thelightphone.backup + +import kotlin.test.Test + +class OfflineBackupRunnerContractTest : BackupRunnerContractTest() { + override fun createRemoteBackup(): RemoteBackup = InMemoryRemoteBackup() + + // just so Android Studio shows me the little shortcut start test buttons in this file + @Test + fun dummyTest() = Unit +} \ No newline at end of file From a8f8f4d878612622d660bf1849613a8f8f99be67 Mon Sep 17 00:00:00 2001 From: Guy Dupont Date: Tue, 15 Sep 2026 16:40:37 -0400 Subject: [PATCH 2/3] add guardrails and progress updates --- library/src/androidMain/AndroidManifest.xml | 14 ++ .../com/thelightphone/backup/BackupWorker.kt | 57 ++++- .../com/thelightphone/backup/BackupResult.kt | 3 + .../com/thelightphone/backup/RemoteBackup.kt | 197 ++++++++++++------ .../thelightphone/backup/BackupPathTest.kt | 35 ++++ .../backup/BackupRunnerContractTest.kt | 123 +++++++++++ 6 files changed, 362 insertions(+), 67 deletions(-) create mode 100644 library/src/androidMain/AndroidManifest.xml create mode 100644 library/src/jvmSharedTest/kotlin/com/thelightphone/backup/BackupPathTest.kt diff --git a/library/src/androidMain/AndroidManifest.xml b/library/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..85e7032 --- /dev/null +++ b/library/src/androidMain/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt index 817bc0f..eec192e 100644 --- a/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt +++ b/library/src/androidMain/kotlin/com/thelightphone/backup/BackupWorker.kt @@ -1,10 +1,16 @@ package com.thelightphone.backup +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo import androidx.work.ListenableWorker import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder @@ -13,12 +19,16 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerFactory import androidx.work.WorkerParameters +import androidx.work.workDataOf import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.toJavaDuration -// Runs one BackupRunner pass +// Runs one BackupRunner pass. Always promoted to a foreground service (see buildForegroundInfo) - +// an initial backup can run long enough that the OS would otherwise kill a plain background +// worker partway through. LightOS's Android fork doesn't render the notification a foreground +// service is normally required to show, so unlike a typical app this costs nothing in visible UI. class BackupWorker( context: Context, params: WorkerParameters, @@ -31,7 +41,22 @@ class BackupWorker( val provider = preferences.getActiveProvider() ?: return Result.success() val remoteBackup = buildRemoteBackup(provider) ?: return Result.failure() - val runner = BackupRunner(remoteBackup, dependencyProvider.createDataSource(), clock) + ensureNotificationChannel() + setForeground(buildForegroundInfo()) + + val runner = BackupRunner( + remoteBackup, + dependencyProvider.createDataSource(), + clock, + onProgress = { progress -> + setProgress( + workDataOf( + PROGRESS_WINDOWS_COMPLETED to progress.windowsCompleted, + PROGRESS_TOTAL_WINDOWS to progress.totalWindows, + ), + ) + }, + ) return when (val result = runner.run()) { is BackupResult.Completed -> { preferences.setLastBackupStatus(BackupStatus.Succeeded(clock.now())) @@ -43,6 +68,24 @@ class BackupWorker( } } + private fun ensureNotificationChannel() { + val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, "Backup", NotificationManager.IMPORTANCE_LOW) + applicationContext.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + private fun buildForegroundInfo(): ForegroundInfo { + val notification = Notification.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setContentTitle("LightOS Backup") + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .build() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + private suspend fun recordFailureAndDecide(cause: Throwable?): Result = when (cause) { is RemoteBackupError.Unauthorized -> { preferences.setLastBackupStatus(BackupStatus.NeedsReauth) @@ -73,6 +116,16 @@ class BackupWorker( ) } } + + companion object { + // Keys into the Data reported via CoroutineWorker.setProgress(), observable through + // WorkInfo.progress - values are BackupProgress.windowsCompleted/totalWindows. + const val PROGRESS_WINDOWS_COMPLETED = "windowsCompleted" + const val PROGRESS_TOTAL_WINDOWS = "totalWindows" + + private const val NOTIFICATION_CHANNEL_ID = "light-backup" + private const val NOTIFICATION_ID = 1 + } } // use a worker factory: diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/BackupResult.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/BackupResult.kt index d07ba31..06cadb0 100644 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/BackupResult.kt +++ b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/BackupResult.kt @@ -13,6 +13,9 @@ sealed class FailureScope { data class BackupFailure(val scope: FailureScope, val cause: Throwable) +// Where "window" is per app, per day +data class BackupProgress(val windowsCompleted: Int, val totalWindows: Int) + sealed class BackupResult { // Every planned file was uploaded. data class Completed(val filesBackedUp: Int) : BackupResult() diff --git a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt index 0c4bf62..45ddbb9 100644 --- a/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt +++ b/library/src/jvmSharedMain/kotlin/com/thelightphone/backup/RemoteBackup.kt @@ -1,15 +1,18 @@ package com.thelightphone.backup +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.io.files.Path import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.io.InputStream import java.io.OutputStream +import java.util.concurrent.TimeoutException import kotlin.random.Random import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant enum class RemoteBackupProvider { @@ -77,6 +80,12 @@ internal fun InputStream.readChunk(maxLength: Int): ByteArray { data class BackupPath(val authority: String, val localPath: Path, val label: String) +// label becomes a single path segment directly under rootFolderPath (see BackupRunner.run), so a +// label a client didn't sanitize (e.g. containing "..") must never be allowed to address anything +// outside its own rootFolderPath/