From 78a12532656fb777de998bfaffd15156d093d66a Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:59:11 -0500 Subject: [PATCH 1/2] Test the production Android backup archive writer (Refs #2708) --- Tools/parity_dispositions.json | 9 +++ Tools/parity_ledger_baseline.json | 2 +- Tools/parity_twin_map.json | 4 +- .../src/main/java/com/noop/data/DataBackup.kt | 62 +++++++++---------- .../com/noop/data/BackupSettingsCodecTest.kt | 54 +++++++++++++++- 5 files changed, 91 insertions(+), 40 deletions(-) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 407341f626..337a69f88d 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -300,6 +300,15 @@ "platform": "kotlin", "rationale": "Swift Repository.refresh reads the same imported daily union (unionDailyMetrics) in the excluded app layer and hands it, still apart from the computed union, to ChargeBaselines.resolve (#2525); this disposition does not assert behavioral parity.", "twin_path": "Strand/Data/Repository.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/DataBackup.kt::writeBackupZip/4#1", + "identity_sha256": "c6f74cc3acf36fb9f2695fb06f73513dbd9465c66543c5da266fbc7d6178fd94", + "platform": "kotlin", + "rationale": "Refs #2708: the Swift DataBackup.writeBackupZip(dbURL:to:settingsJSON:manifestJSON:) archive writer is in the excluded app layer. This records the container-writing role twin for the Android production stream extraction without asserting byte-identical ZIP compression.", + "twin_path": "Strand/Data/DataBackup.swift" } ] } diff --git a/Tools/parity_ledger_baseline.json b/Tools/parity_ledger_baseline.json index 361e74c26a..56f6916e83 100644 --- a/Tools/parity_ledger_baseline.json +++ b/Tools/parity_ledger_baseline.json @@ -20,7 +20,7 @@ {"rule": "test-only-callsite", "scope": "Packages/WhoopProtocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopProtocol; no wildcard matching.", "count": 30, "identities_sha256": "a9dc115534e8710217db9cd39bcceaa1419741737bfd3b733007dc0215cd5af2"}, {"rule": "test-only-callsite", "scope": "Packages/WhoopStore", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope Packages/WhoopStore; no wildcard matching.", "count": 37, "identities_sha256": "2a62c1ece1c541b662dd407f0668d9a02782bb4d21f9bb181518272ff12f0e40"}, {"rule": "test-only-callsite", "scope": "android/analytics", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/analytics; no wildcard matching.", "count": 65, "identities_sha256": "fda58e5bb0fe07a9fa773d8e5f8961d483e8b177f1529b6e9dc3ac26df7c31cf"}, - {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 13, "identities_sha256": "6abaae19303cadccbe259d7db320850e269433472b28c84a1bfce7d32d54ce5f"}, + {"rule": "test-only-callsite", "scope": "android/data", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/data; no wildcard matching.", "count": 12, "identities_sha256": "da661c1cb68d13a042bfc029410bd61858202bcd498a8bf16aa2b30c239bfe32"}, {"rule": "test-only-callsite", "scope": "android/ingest", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/ingest; no wildcard matching.", "count": 12, "identities_sha256": "d4f45d66087768eab133b918af6658b06ec1d91305e38fb045f6ae1629c94678"}, {"rule": "test-only-callsite", "scope": "android/oura", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/oura; no wildcard matching.", "count": 4, "identities_sha256": "9beaa9034af0a1021b9a9f43739971934f260147e0a9a5c462468548a7fd577d"}, {"rule": "test-only-callsite", "scope": "android/protocol", "reason": "Exact declaration is reached only from tests in the conservative lexical call graph; dynamic, callback and external entry points are intentionally not inferred.", "provenance": "Exact identities emitted by parity_ledger rule test-only-callsite for current upstream scope android/protocol; no wildcard matching.", "count": 31, "identities_sha256": "ecb83d46b48df8ad0bd63bb5b91125fe5be1fe35aa25bbd539c0dca998735226"} diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 6d97b4e14d..786d6c816a 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,7 +19,7 @@ }, "authority": { "files": {"count": 510, "sha256": "b1c73338e79009d984d1313d8b3166e52672712c68074f5fee190c503e299b18"}, - "functions": {"count": 4538, "sha256": "f62a1ca0dfb2122fdca87a2f455ec0ca4435903c24069ae13bc4e75c0c3f32c0"}, + "functions": {"count": 4538, "sha256": "0aefe76deccfa1024cdff19bd307dea0d6f174f9aa5735411a55460828486dfc"}, "properties": {"count": 488, "sha256": "77d60fe0f6218252233b9e60da8bf5135968e49be3c6d2027d0973ea20ddf792"}, "constants": {"count": 2001, "sha256": "a9c1f36d2acc614dd747fd6cc0b3ea48079b81517afda5591d219f9591e353c3"}, "file_pairs": {"count": 81, "sha256": "3c94824ccff843da495a212a973d89ce61e536b6795dbee2d0e496a7e7a498f0"}, @@ -27,7 +27,7 @@ "property_pairs": {"count": 163, "sha256": "b7d4a29086beec661993370657c0bcdae86f79b221fdc0043206161df5e1117f"}, "constant_pairs": {"count": 702, "sha256": "e3de4c1b84b8500c5700292648bf23dc680e5e14cde055c36c5d1ef4ecc39dc2"}, "unpaired_files": {"count": 370, "sha256": "441451c8f68b68e1dc3d3556c28952b4bdac243de9d6f909d3c18d6ac9732a1e"}, - "unpaired_functions": {"count": 4099, "sha256": "3ee3e74d901e17225e294c309c1b00a7a5b178bb3164bff43eaaa5309d574169"}, + "unpaired_functions": {"count": 4099, "sha256": "6e7aa9720d92ac0c4e95bb6e3f47d01ff3151cbd630285d64107cd0037d94981"}, "unpaired_properties": {"count": 162, "sha256": "dabff97ba9bce0aebdc8ef84b9c4f107ce574020e8b0455d8f9214082948246b"}, "unpaired_constants": {"count": 597, "sha256": "578c607cc05b1cd1f867c206c9fab70237ac40707cd49d4e0c5cc66b400184c7"} } diff --git a/android/app/src/main/java/com/noop/data/DataBackup.kt b/android/app/src/main/java/com/noop/data/DataBackup.kt index 82ee87c7e3..8617cbb9e5 100644 --- a/android/app/src/main/java/com/noop/data/DataBackup.kt +++ b/android/app/src/main/java/com/noop/data/DataBackup.kt @@ -294,24 +294,7 @@ object DataBackup { // unaffected. Anything committed after the checkpoint above lives in the new WAL and // is simply (consistently) absent from this snapshot, same as before. db.runInTransaction { - ZipOutputStream(out).use { zip -> - zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME)) - // #1807: count what actually LANDS in the entry, rather than reading dbFile.length() - // afterwards. The copy runs inside this transaction against a snapshot; once it - // commits, Room can checkpoint again and the main file's length can move, so a later - // read is a different number from the one the restore path will meet. The cap is - // enforced on this entry, so this is the only measurement that answers the question. - dbFile.inputStream().use { input -> entryBytes = input.copyTo(zip) } - zip.closeEntry() - if (settingsJson != null) { - zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME)) - zip.write(settingsJson.toByteArray(Charsets.UTF_8)) - zip.closeEntry() - } - zip.putNextEntry(ZipEntry(MANIFEST_ENTRY_NAME)) - zip.write(manifestJson.toByteArray(Charsets.UTF_8)) - zip.closeEntry() - } + entryBytes = writeBackupZip(dbFile, out, settingsJson, manifestJson) } } // #1014 (write-side): the SOURCE was verified before archiving, but the PRODUCED file can still @@ -675,24 +658,35 @@ object DataBackup { } } - /** Write [dbFile]'s bytes into a deflate ZIP at [dest] (the `.noopbak` container), DB entry first, - * plus the optional `settings.json` entry (#1000) when [settingsJson] is non-null. Context-free - * twin of the stream the live [exportTo] writes, so tests round-trip a real archive of either - * shape (legacy single-entry when [settingsJson] is null). */ + /** + * Write the container used by [exportTo] and return the DB bytes copied into its first entry. + * The caller holds Room's write transaction while copying its checkpointed snapshot; measuring + * this copy keeps the restore-ceiling warning tied to the archived DB rather than a later file size. + * Optional settings and provenance follow the DB, preserving older importers' entry-order contract. + * Finishes and closes the ZIP before verification. The caller also scopes [output] with `use` so + * provider failures during ZIP finalization cannot prevent the underlying stream from closing. + */ @Throws(IOException::class) - fun writeBackupZip(dbFile: File, dest: File, settingsJson: String? = null) { - FileOutputStream(dest).use { out -> - ZipOutputStream(out).use { zip -> - zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME)) - dbFile.inputStream().use { input -> input.copyTo(zip) } - zip.closeEntry() - if (settingsJson != null) { - zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME)) - zip.write(settingsJson.toByteArray(Charsets.UTF_8)) - zip.closeEntry() - } - } + internal fun writeBackupZip( + dbFile: File, + output: java.io.OutputStream, + settingsJson: String? = null, + manifestJson: String? = null, + ): Long = ZipOutputStream(output).use { zip -> + zip.putNextEntry(ZipEntry(ZIP_ENTRY_NAME)) + val entryBytes = dbFile.inputStream().use { input -> input.copyTo(zip) } + zip.closeEntry() + if (settingsJson != null) { + zip.putNextEntry(ZipEntry(SETTINGS_ENTRY_NAME)) + zip.write(settingsJson.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + if (manifestJson != null) { + zip.putNextEntry(ZipEntry(MANIFEST_ENTRY_NAME)) + zip.write(manifestJson.toByteArray(Charsets.UTF_8)) + zip.closeEntry() } + entryBytes } /** True when [file] begins with the SQLite 3 magic. Pure; used by [importFrom] and the tests. */ diff --git a/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt b/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt index 01622d52c5..ea188e43a8 100644 --- a/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt +++ b/android/app/src/test/java/com/noop/data/BackupSettingsCodecTest.kt @@ -4,11 +4,15 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.io.IOException +import java.io.OutputStream import java.util.zip.ZipEntry +import java.util.zip.ZipFile import java.util.zip.ZipOutputStream /** @@ -135,7 +139,7 @@ class BackupSettingsCodecTest { BackupSettingsCodec.encode(mapOf("profile.age" to 41, "profile.weightKg" to 90.5)), ) val backup = tmp.newFile("with-settings.noopbak") - DataBackup.writeBackupZip(liveDb, backup, settingsJson) + DataBackup.writeBackupZip(liveDb, backup.outputStream(), settingsJson) val stagedDb = tmp.newFile() val stagedSettings = File(tmp.root, "staged-settings.json") @@ -154,7 +158,7 @@ class BackupSettingsCodecTest { @Test fun legacySingleEntryZipStagesDbAndNoSettings() { val liveDb = fakeSqlite("legacy-rows") val backup = tmp.newFile("legacy.noopbak") - DataBackup.writeBackupZip(liveDb, backup) // settingsJson defaults null → pre-#1000 shape + DataBackup.writeBackupZip(liveDb, backup.outputStream()) // settingsJson defaults null → pre-#1000 shape val stagedDb = tmp.newFile() val stagedSettings = File(tmp.root, "staged-settings.json") @@ -171,7 +175,7 @@ class BackupSettingsCodecTest { // The pre-#1000 call shape (no settingsDest) keeps working for a 2-entry zip. val liveDb = fakeSqlite("rows2") val backup = tmp.newFile("two-entry.noopbak") - DataBackup.writeBackupZip(liveDb, backup, """{"profile.age":30}""") + DataBackup.writeBackupZip(liveDb, backup.outputStream(), """{"profile.age":30}""") val stagedDb = tmp.newFile() val result = DataBackup.stageBackupSqlite(backup.inputStream(), DataBackup.peekHeader(backup), stagedDb) @@ -179,6 +183,50 @@ class BackupSettingsCodecTest { assertEquals(liveDb.readBytes().toList(), stagedDb.readBytes().toList()) } + @Test fun productionArchiveIncludesManifestAndCountsOnlyDatabaseBytes() { + val liveDb = fakeSqlite("database rows") + val backup = tmp.newFile("with-manifest.noopbak") + val settings = """{"profile.age":41}""" + val manifest = """{"platform":"android","note":"é"}""" + val copiedBytes = DataBackup.writeBackupZip(liveDb, backup.outputStream(), settings, manifest) + + assertEquals(liveDb.length(), copiedBytes) + // ZipFile reads the central directory, unlike the streaming restore path. Opening it proves + // the writer finished the archive; entry order and payloads must match the live export contract. + ZipFile(backup).use { zip -> + val entries = zip.entries().toList() + assertEquals(listOf("noop-backup.sqlite", "settings.json", "manifest.json"), entries.map { it.name }) + assertEquals(liveDb.readBytes().toList(), zip.getInputStream(entries[0]).use { it.readBytes().toList() }) + assertEquals(settings, zip.getInputStream(entries[1]).bufferedReader(Charsets.UTF_8).use { it.readText() }) + assertEquals(manifest, zip.getInputStream(entries[2]).bufferedReader(Charsets.UTF_8).use { it.readText() }) + } + val staged = tmp.newFile() + assertEquals(DataBackup.StageResult.OK, + DataBackup.stageBackupSqlite(backup.inputStream(), DataBackup.peekHeader(backup), staged)) + assertEquals(liveDb.readBytes().toList(), staged.readBytes().toList()) + } + + @Test fun manifestWithoutSettingsStillFollowsDatabase() { + val liveDb = fakeSqlite("rows") + val backup = tmp.newFile("manifest-only.noopbak") + DataBackup.writeBackupZip(liveDb, backup.outputStream(), manifestJson = "{}") + ZipFile(backup).use { zip -> + assertEquals(listOf("noop-backup.sqlite", "manifest.json"), zip.entries().toList().map { it.name }) + } + } + + @Test fun archiveWriteFailurePropagatesAndClosesOutput() { + var closed = false + val failingOutput = object : OutputStream() { + override fun write(value: Int) { throw IOException("synthetic provider failure") } + override fun close() { closed = true } + } + assertThrows(IOException::class.java) { + failingOutput.use { DataBackup.writeBackupZip(fakeSqlite("rows"), it, manifestJson = "{}") } + } + assertTrue("Failed export must release its output stream", closed) + } + @Test fun zipWithNonCanonicalSqliteEntryIsRejected() { val backup = tmp.newFile("wrong-entry.noopbak") ZipOutputStream(backup.outputStream()).use { zip -> From d1c1abc9ff7644bf63dc418761359ae01f81c17d Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:04:21 -0500 Subject: [PATCH 2/2] Update the backup sync archive fixture (Refs #2708) --- .../com/noop/data/BackupSyncFileIoTest.kt | Bin 8112 -> 8127 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/android/app/src/test/java/com/noop/data/BackupSyncFileIoTest.kt b/android/app/src/test/java/com/noop/data/BackupSyncFileIoTest.kt index 953fdce65d33bfefab04ee0b1198f53d4d41fe79..27b84b6b5b28df63fcbcaa6cd7c835e4f3f3315b 100644 GIT binary patch delta 28 kcmdmBzu$htAr5}M{L+$w(vskkqSVA(4b9DmINpf?0IN(3lmGw# delta 12 UcmdmQzrlXPA&$)#Io^o@04SFQ_y7O^