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 9fbbdc2a3a..51a07180c7 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": 29, "identities_sha256": "a13306c37eb1a9272b8666d09700d5120fb5fe0e629f3aaaeef649357f7f8b45"}, {"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 767e162915..4cfaf85f44 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,7 +19,7 @@ }, "authority": { "files": {"count": 509, "sha256": "0a0b02d39eb26ad0635933ad6fca51fb4c33db98178410bfe6676ab9d0fdcaee"}, - "functions": {"count": 4537, "sha256": "a10084c830d9b91c0f02bb308fa7ac0f1466c7537de3303669e0f4fbeffdf6f2"}, + "functions": {"count": 4537, "sha256": "e90d4e52affa53db49e2172c7b037f4d8e30244b168cf8a4cc805e4bcc4bb523"}, "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": 369, "sha256": "3462a2a8135bfd793dc6493f10fb72b4fae9028ad9ec47c8dec84b9f5a4dc01a"}, - "unpaired_functions": {"count": 4098, "sha256": "a7c3215172bf4d1cd33b8df390684d4d65fc1109f8ee52b3d344b04d231f08fd"}, + "unpaired_functions": {"count": 4098, "sha256": "e9530149aff7f491fe994b0ba508aeea7c96e00ea93dcf5001cbfc649f3d789e"}, "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 -> 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 953fdce65d..27b84b6b5b 100644 Binary files a/android/app/src/test/java/com/noop/data/BackupSyncFileIoTest.kt and b/android/app/src/test/java/com/noop/data/BackupSyncFileIoTest.kt differ