30cd436080de
M-03: preserve last4 + entry IDs on REPLACE; tag/url overflow throws instead of truncating
- time
- 2026-07-29 14:27 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-4
- session
- audit-remediation-2026-07-29
- parent
- 24fe1013d9b3
↓ peach-android-30cd4360.tar.gzatlas at this save
files changed3 files
app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+6 −2app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+16 −10app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+15 −3app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+6 −2
@@ -312,8 +312,12 @@312312 val fulls = preview.fullEntries313313 val drafts = if (fulls != null) {314314 // PeachScript: the complete entries (all fields, all types).315- // Re-id so an import can never overwrite an existing entry.316- fulls.map { it.copy(id = "") }315+ // M-03: REPLACE is a restore of *these* entries, so keep their316+ // ids — re-iding turned a restore into delete-plus-new317+ // identities that paired devices then re-synced as churn.318+ // MERGE folds into an existing set, so re-id there: it is the319+ // explicit collision resolution (never overwrite a live entry).320+ if (mode == ImportMode.REPLACE) fulls else fulls.map { it.copy(id = "") }317321 } else {318322 preview.entries.map { p ->319323 VaultEntry(app/src/main/java/com/peachpasswords/vault/PeachPayloadCodec.kt+16 −10
@@ -418,9 +418,10 @@418418 w.lpString(entry.cvc ?: "")419419 w.u16(table.intern(entry.cardNickname))420420 w.u8(networkToByte(entry.cardNetwork))421- // Android has no last4 field — derive it from the card number so the422- // wire format matches the browser (BROWSER-SPEC: card last4 note).423- w.u16(table.intern(deriveLast4(entry)))421+ // M-03: prefer the stored field. Deriving it lost the last four422+ // for cards held without a full PAN (the browser stores last4423+ // independently, so those cards exported as blank).424+ w.u16(table.intern(entry.last4?.takeIf { it.isNotEmpty() } ?: deriveLast4(entry)))424425 if (flags and F_HAS_TAGS != 0) writeTags(w, table, entry.tags!!)425426 if (flags and F_HAS_NOTES != 0) w.u16(table.intern(entry.notes))426427 }@@ -491,7 +492,7 @@491492 val cvc = r.lpString()492493 val nicknameIdx = r.u16()493494 val network = networkFromByte(r.u8())494- r.u16() // last4 index — Android has no field; dropped (number carries it)495+ val last4 = lookup(strings, r.u16()) // M-03: was read and dropped495496 val tags = if (flags and F_HAS_TAGS != 0) readTags(r, strings) else null496497 val notes = if (flags and F_HAS_NOTES != 0) lookup(strings, r.u16()) else null497498 VaultEntry(@@ -500,6 +501,7 @@500501 cardNumber = cardNumber.ifEmpty { null }, expMonth = expMonth.ifEmpty { null },501502 expYear = expYear.ifEmpty { null }, cvc = cvc.ifEmpty { null },502503 cardNickname = lookup(strings, nicknameIdx), cardNetwork = network,504+ last4 = last4,503505 tags = tags, notes = notes,504506 createdAt = createdAt, modifiedAt = modifiedAt, deletedAt = deletedAt,505507 )@@ -515,16 +517,20 @@515517 }516518 }517519 520+ // M-03: the count is a u8, so >255 items cannot be represented. Truncating521+ // silently dropped user data into an export that still looked successful —522+ // fail loudly instead and let the caller decide (matches lpString's523+ // STRING_TOO_LONG_U8 behaviour for the same reason).518524 private fun writeTags(w: BinaryWriter, table: StringTable, tags: List<String>) {519- val n = minOf(tags.size, 0xff)520- w.u8(n)521- for (i in 0 until n) w.u16(table.intern(tags[i]))525+ if (tags.size > 0xff) throw PeachScriptException("TAG_LIST_TOO_LONG")526+ w.u8(tags.size)527+ for (t in tags) w.u16(table.intern(t))522528 }523529 524530 private fun writeStrings(w: BinaryWriter, table: StringTable, items: List<String>) {525- val n = minOf(items.size, 0xff)526- w.u8(n)527- for (i in 0 until n) w.u16(table.intern(items[i]))531+ if (items.size > 0xff) throw PeachScriptException("URL_LIST_TOO_LONG")532+ w.u8(items.size)533+ for (s in items) w.u16(table.intern(s))528534 }529535 530536 private fun readTags(r: BinaryReader, strings: List<String?>): List<String> {app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+15 −3
@@ -1058,14 +1058,21 @@10581058 ),10591059 )10601060 }1061- // H-03: capture rollback and swap the list under one stateLock.1061+ // M-03: an incoming entry that shares an id with an existing live entry1062+ // is meant to *replace* it (PEACHSCRIPT REPLACE restore). Tombstoning1063+ // the old one and then appending the new one would leave the list with1064+ // two entries of the same id — the tombstoned one would be returned by1065+ // updateEntry's indexOfFirst and trip entry_not_found. Drop the to-be-1066+ // superseded entries from the tombstone set instead.1067+ val incomingIds = ready.map { it.id }.toHashSet()10621068 val rollback = mutateState {10631069 val r = entryList.toMutableList()10641070 val replaced = entryList.map { entry ->10651071 if (entry.deletedAt != null) return@map entry1072+ if (entry.id in incomingIds) return@map null10661073 val stamp = VaultCrypto.monotonicEntryTimestamp(entry.modifiedAt)10671074 entry.copy(deletedAt = stamp, modifiedAt = stamp)1068- }1075+ }.filterNotNull()10691076 entryList = (replaced + ready).toMutableList()10701077 r10711078 }@@ -1777,11 +1784,16 @@17771784 * Keep [VaultEntry.last4] in sync with the card number for card entries (MF-3),17781785 * so it is stored explicitly and survives the sync wire format rather than being17791786 * re-derived at render time. Non-card entries pass through unchanged.1787+ *1788+ * M-03: never overwrite a stored last4 with null. A card kept only as last41789+ * (e.g. after a PeachScript import that had no PAN) must keep that value;1790+ * re-deriving from an absent cardNumber would silently zero the field.17801791 */17811792 private fun withDerivedLast4(entry: VaultEntry): VaultEntry {17821793 if (!entry.isCard()) return entry17831794 val digits = entry.cardNumber?.filter { it.isDigit() } ?: ""1784- return entry.copy(last4 = if (digits.length >= 4) digits.takeLast(4) else null)1795+ val derived = if (digits.length >= 4) digits.takeLast(4) else null1796+ return entry.copy(last4 = derived ?: entry.last4)17851797 }17861798 17871799 /**