cba84286c56f

naturalise foreign entry ids at the import seam instead of trusting them (v2.14.85)

A Peach entry id is a UUID, but a restored or imported file carries whatever id its author used and no reader on either platform bounds it. One restored entry with an oversized id would ride into every future sync frame and be rejected there — a permanent, opaque failure on both platforms, caused by data that was already local. addEntry, addEntries and replaceEntries now replace an id that is not 1-64 chars of [A-Za-z0-9_-] with a freshly minted UUID. These three are the only entry-write choke points and they cover every foreign path: PeachScript paste, .peach files, CSV and vendor exports all funnel through ImportScreen into addEntries/replaceEntries. Restore-from-our-own-backup paths never needed this because their ids are already Peach ids. The action is mint, not reject, because this is the last point where an id is still local: the worst outcome is one duplicate if the same vault is later restored beside a device still holding the old id, which is visible and fixable, whereas rejecting would lose the user's import. Sync deliberately does not normalize. applyMergedRemoteEntries adopts a peer's id verbatim — that is the only thing keeping two devices agreed on which entry they are discussing — and a test asserts a 124-char peer id survives untouched so the seam cannot drift into the sync path later. Verification: four new cases — a UUID and a 22-char vendor id kept, an oversized and an injection-shaped id minted as UUIDs with the entries intact, and the sync path adopting a long peer id verbatim; full JVM suite 1043 tests / 0 failures / 0 errors / 2 skipped, up from 1040; lintDebug 0 errors and 90 warnings, unchanged; assembleDebug and debug androidTest sources compile.

time
2026-09-01 03:59 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
define the entry-id namespace at the import seam
parent
21276fade773
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+21 −3app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+58 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.84\ No newline at end of file1+2.14.85\ No newline at end of file

view file

app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+21 −3
@@ -1612,12 +1612,30 @@16121612 16131613     // ---- Entry CRUD -------------------------------------------------------16141614 1615+    /**1616+     * A Peach entry id is a UUID. Import and restore carry ids from foreign1617+     * files (another Peach install, a vendor export, a hand-written backup) and1618+     * no reader bounds them, so an id that is not a well-formed Peach id is1619+     * replaced here rather than trusted.1620+     *1621+     * This is the naturalisation seam, and it is deliberately the only place it1622+     * happens. Minting costs at most one duplicate if the same vault is later1623+     * restored on a device that still holds the old id — visible and fixable.1624+     * The sync path must NOT do this: adopting a peer's id verbatim is the only1625+     * thing that keeps two devices agreeing on which entry they are discussing,1626+     * so reminting there would fork identity on every device, on every round.1627+     */1628+    private val entryIdShape = Regex("[A-Za-z0-9_-]{1,64}")1629+1630+    private fun String?.normalizedEntryId(): String =1631+        if (this != null && entryIdShape.matches(this)) this else UUID.randomUUID().toString()1632+16151633     suspend fun addEntry(draft: VaultEntry): VaultEntry = mutex.withLock {16161634         val epoch = requireUnlocked()16171635         val stamp = VaultCrypto.monotonicEntryTimestamp()16181636         val now = System.currentTimeMillis()16191637         val entry = withDerivedLast4(draft.copy(1620-            id = draft.id.ifBlank { UUID.randomUUID().toString() },1638+            id = draft.id.normalizedEntryId(),16211639             createdAt = if (draft.createdAt > 0) draft.createdAt else now,16221640             modifiedAt = stamp,16231641             passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,@@ -1650,7 +1668,7 @@16501668             val stamp = VaultCrypto.monotonicEntryTimestamp()16511669             withDerivedLast4(16521670                 draft.copy(1653-                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1671+                    id = draft.id.normalizedEntryId(),16541672                     createdAt = if (draft.createdAt > 0) draft.createdAt else now,16551673                     modifiedAt = stamp,16561674                     passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,@@ -1687,7 +1705,7 @@16871705             val stamp = VaultCrypto.monotonicEntryTimestamp()16881706             withDerivedLast4(16891707                 draft.copy(1690-                    id = draft.id.ifBlank { UUID.randomUUID().toString() },1708+                    id = draft.id.normalizedEntryId(),16911709                     createdAt = if (draft.createdAt > 0) draft.createdAt else now,16921710                     modifiedAt = stamp,16931711                     passwordChangedAt = if (!draft.password.isNullOrEmpty()) stamp else draft.passwordChangedAt,

view file

app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+58 −0
@@ -3,6 +3,7 @@33 import kotlinx.coroutines.runBlocking44 import org.junit.Assert.assertEquals55 import org.junit.Assert.assertFalse6+import org.junit.Assert.assertNotNull67 import org.junit.Assert.assertNull78 import org.junit.Assert.assertTrue89 import org.junit.Test@@ -269,6 +270,63 @@269270         assertEquals(2, repo.getEntries().size)270271         // The whole point: a sync apply must NOT advance the local version.271272         assertEquals(versionBefore, repo.getVaultVersion())273+    }274+275+    /**276+     * The entry-id namespace. A Peach id is a UUID, but a restored or imported277+     * file brings ids from whoever wrote it and no reader bounds them, so an id278+     * that is not a well-formed Peach id is replaced at the import seam rather279+     * than trusted. Minting costs at most one duplicate after a cross-device280+     * restore; trusting one used to be able to wedge sync on both platforms.281+     */282+    @Test fun import_keeps_a_well_formed_foreign_id() = runBlocking {283+        val repo = VaultRepository(FakeStorage())284+        repo.createVault("pw")285+        val fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40"286+        repo.addEntries(287+            listOf(288+                login("Bitwarden").copy(id = fromAnotherPeach),289+                login("OnePassword").copy(id = "sj4kd9f2q7m1x8zb3nv6ct0h"),290+            ),291+        )292+        assertEquals(293+            setOf(fromAnotherPeach, "sj4kd9f2q7m1x8zb3nv6ct0h"),294+            repo.getEntries().mapTo(mutableSetOf()) { it.id },295+        )296+    }297+298+    @Test fun import_mints_a_fresh_id_when_the_incoming_one_is_not_a_peach_id() = runBlocking {299+        val repo = VaultRepository(FakeStorage())300+        repo.createVault("pw")301+        repo.addEntries(302+            listOf(303+                login("Too long").copy(id = "x".repeat(200)),304+                login("Not an id").copy(id = "a b\"c\n{\"injected\":true}"),305+            ),306+        )307+        val minted = repo.getEntries().map { it.id }308+        assertEquals("both entries survive with fresh ids", 2, minted.toSet().size)309+        for (id in minted) {310+            assertEquals("a minted id is a UUID", 36, id.length)311+            assertNotNull(java.util.UUID.fromString(id))312+        }313+    }314+315+    /** The seam is the import path only — reminting here would fork identity. */316+    @Test fun sync_still_adopts_a_peer_id_verbatim() = runBlocking {317+        val repo = VaultRepository(FakeStorage())318+        repo.createVault("pw")319+        val peerId = "peer-" + "x".repeat(120)320+        repo.applyMergedRemoteEntries(321+            listOf(322+                VaultEntry(323+                    id = peerId, title = "Remote", type = "login", password = "x",324+                    createdAt = 1, modifiedAt = System.currentTimeMillis(),325+                ),326+            ),327+            emptyList(),328+        )329+        assertEquals(peerId, repo.getEntries().single().id)272330     }273331 274332     @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {

view file