A file in a chat keeps its name and its label

`Ref.toJson` is what the vault persists next to a message, and it never
wrote `filename`, `classification` or `compartments`. They reach the far
end correctly, as additive top-level keys on the envelope, so a received
document ARRIVED named. It was the local copy that lost them, on the
first read back out of storage. A file in a chat therefore had a name
until the app was restarted and never again, which reads as a damaged
file rather than as the app forgetting.

`fromJson` now defaults those three to null rather than to empty.
Passing nothing means "not a wire parse, read what was stored"; passing
a value, including an empty one, means the envelope said so and wins.
Empty defaults could not tell those two apart, and answering "" for both
is exactly what made a stored file nameless.

The same rule fixes `expires`. The history path hands in 0.0, and 0
means "does not expire", so every stored story read back as immortal:
its bytes were never dropped and it rendered forever. A caller that
names an expiry still means it; one that does not now gets what the
vault kept.

They are stored under the same names the shared backup format uses, so
`BackupPayload` carries them straight through. A document restored from
a backup arrived with no name and no label before this, which is worse
than not restoring it.

Four tests on the storage round trip, one on the envelope still winning,
one that an attachment which never had a name does not invent one, and
one carrying a document through a backup whole. Full suite: 1276 tests,
no failures. No new ktlint violations in Content.kt (3 at HEAD, 3 now).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-10 19:29:31 -04:00
co-authored by Claude Opus 5
parent 140affc356
commit 7ab5c774cb
4 changed files with 195 additions and 10 deletions
@@ -0,0 +1,92 @@
package io.flamenet.e2e
import org.json.JSONObject
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* What a document is once it leaves Files: a name and a label.
*
* `Ref.toJson` is how an attachment is persisted alongside a message, and it
* did not write `filename`, `classification` or `compartments`. They reached
* the wire correctly, so a file ARRIVED with its name; it was the local copy
* that lost it, on the first read back from the vault. A document in a chat
* therefore had a name until the app was restarted and never again, which
* looks like the file was damaged rather than like the app forgot.
*/
class AttachmentNameAtRestTest {
private fun fileRef() =
Attachment.Ref(
kind = Attachment.FILE,
id = "att-1",
key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 },
bytes = 4096,
mime = "application/octet-stream",
filename = "contract.pdf",
classification = 3,
compartments = listOf(2, 7),
)
@Test
fun `a stored file keeps its name, its label and its compartments`() {
val back = Attachment.Ref.fromJson(Attachment.FILE, fileRef().toJson(), 0.0)
assertEquals("contract.pdf", back?.filename)
assertEquals(3, back?.classification)
assertEquals(listOf(2, 7), back?.compartments)
}
@Test
fun `a story keeps its expiry through the vault, not only through the strip`() {
val story =
Attachment.Ref(
kind = Attachment.STORY,
id = "s-1",
key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 },
bytes = 10,
mime = "image/jpeg",
expires = 1_700_600_000.0,
)
// Read back with no expiry handed in, as the history path does.
val back = Attachment.Ref.fromJson(Attachment.STORY, story.toJson(), 0.0)
assertEquals(1_700_600_000.0, back?.expires)
}
@Test
fun `the envelope still wins, because that is where an inbound file names itself`() {
// `filename`, `classification` and `compartments` are additive
// top-level keys on the WIRE, not part of `att`. An inbound message is
// parsed with them handed in explicitly, and that must keep overriding
// whatever the attachment object does or does not carry.
val fromWire =
Attachment.Ref.fromJson(
Attachment.FILE,
fileRef().toJson(),
0.0,
filename = "renamed-by-sender.pdf",
classification = 9,
compartments = listOf(1),
)
assertEquals("renamed-by-sender.pdf", fromWire?.filename)
assertEquals(9, fromWire?.classification)
assertEquals(listOf(1), fromWire?.compartments)
}
@Test
fun `an attachment that never had a name does not invent one`() {
val plain =
Attachment.Ref(
kind = Attachment.IMAGE,
id = "i-1",
key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 },
bytes = 10,
mime = "image/jpeg",
)
val back = Attachment.Ref.fromJson(Attachment.IMAGE, plain.toJson(), 0.0)
assertEquals("", back?.filename)
assertEquals(0, back?.classification)
assertEquals(emptyList(), back?.compartments)
}
}
@@ -398,4 +398,56 @@ class BackupPayloadTest {
assertEquals("t", out.getJSONObject("groupSecrets").getJSONObject("gid-1").getString("tag"))
assertTrue(out.getJSONArray("callLog").getJSONObject(0).getBoolean("ok"))
}
@Test
fun `a document keeps its name and its label across a backup`() {
val fileAttachment =
JSONObject()
.put("id", "att-2")
.put("key", "a".repeat(43))
.put("digest", "b".repeat(43))
.put("bytes", 900)
.put("mime", "application/octet-stream")
.put("filename", "contract.pdf")
.put("classification", 3)
.put("compartments", JSONArray(listOf(2, 7)))
val internal =
JSONObject().put(
"threads",
JSONObject().put(
"7",
JSONArray().put(
JSONObject()
.put("text", "")
.put("at", 1L)
.put("kind", Attachment.FILE)
.put("att", fileAttachment),
),
),
)
val shared = BackupPayload.toShared(internal)
val sharedAtt =
shared
.getJSONObject("threads")
.getJSONArray("7")
.getJSONObject(0)
.getJSONObject("att")
assertEquals(Attachment.FILE, sharedAtt.getString("kind"))
assertEquals("contract.pdf", sharedAtt.getString("filename"))
assertEquals(3, sharedAtt.getInt("classification"))
val back =
BackupPayload
.fromShared(shared)
.getJSONObject("threads")
.getJSONArray("7")
.getJSONObject(0)
.getJSONObject("att")
assertEquals("contract.pdf", back.getString("filename"))
assertEquals(3, back.getInt("classification"))
assertEquals(
listOf(2, 7),
back.getJSONArray("compartments").let { a -> (0 until a.length()).map { a.getInt(it) } },
)
}
}