Share a file into a chat, and save one back — with the label it carries
Both directions: pick a document from the vault inside a conversation, and save a received one into the vault at the classification it arrived with. 🔴 **This is the answer to android#15, and the codebase had already given it.** `Attachment.FILE` has carried this since before the lattice existed: *the label is a MARKING, not a permission — the content key is in the same envelope, so whoever can read the message can read the file.* That settles the ★-property question. The key travels with the file by design, because that is what makes sending work at all; there is no trusted computing base to enforce against, since the person holds every key on a device they control. A client-side refusal would be a suggestion, and **a suggestion dressed as a control is worse than none** — it is the one people plan around. So neither client implements the ★-property, `LabelTest` keeps asserting that absence, and PARITY.md now says plainly that Files is not mandatory access control. What it IS: cryptographic read separation, plus a classification that travels. The picker says so before the tap, in those words. Three things found on the way: 🔴 **A second copy of the vocabulary.** `classificationName` in ChatScreens listed the same four names as `FileLevels`, agreeing by coincidence with nothing making them agree. Rename a level for the Files screen and the chat bubble keeps showing the old word for the same number, in the same conversation, on the same phone. Collapsed to one. 🔴 **Attach and Camera did the same thing.** Both launched the gallery, and there was no way to send a document at all. Attach now opens a small panel — photo, or from Files — which keeps the kit's composer row at its four controls. 🔴 **iOS's picker lists the root only**, so a file inside a folder cannot be sent from a conversation, and nothing says so — it is simply absent, which reads as "it isn't there". `LocalVault.allFiles()` walks the whole tree. Filed as ios#68. ⚠️ The picker is the ONE place this vault hides a row it cannot read. Everywhere else it insists on showing them, because hiding one leaves somebody unable to see anything is there; a picker exists only for choosing, and a row that can never be chosen is furniture. ⚠️ A received file is always saved as an encrypted copy, never a link — there is no file on this phone to point at — and always at the label it arrived with. Re-labelling somebody else's document on receipt would launder its classification, and the number is the only thing that survived the trip. 9 new tests. Each guard proved by breaking the code: writing the label for every attachment kind fails the image test; dropping compartments fails the two that name them; removing the fallback caption fails the older-build test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fbf2c4c531
commit
6ec626cde2
@@ -270,6 +270,18 @@ class Conversations(
|
||||
height: Int = 0,
|
||||
duration: Double = 0.0,
|
||||
groupId: String? = null,
|
||||
/** File only: the document's own name. */
|
||||
filename: String = "",
|
||||
/**
|
||||
* File only: the label it was filed under.
|
||||
*
|
||||
* 🔴 **A MARKING, not a permission.** The content key rides in the same
|
||||
* envelope, so whoever can read the message can read the file. This
|
||||
* travels so the recipient can file it the same way — it must never be
|
||||
* described, here or on screen, as something that stops them opening it.
|
||||
*/
|
||||
classification: Int = 0,
|
||||
compartments: List<Int> = emptyList(),
|
||||
): Sent {
|
||||
val sealed = Attachment.seal(data)
|
||||
val id = relay.uploadAttachment(sealed.blob)
|
||||
@@ -277,6 +289,8 @@ class Conversations(
|
||||
kind = kind, id = id, key = sealed.key, digest = sealed.digest,
|
||||
bytes = sealed.blob.size, mime = mime,
|
||||
width = width, height = height, duration = duration,
|
||||
filename = filename, classification = classification,
|
||||
compartments = compartments.sorted(),
|
||||
)
|
||||
return sendPlaintext(
|
||||
toUserId,
|
||||
@@ -301,12 +315,19 @@ class Conversations(
|
||||
width: Int,
|
||||
height: Int,
|
||||
caption: String? = null,
|
||||
kind: String = Attachment.IMAGE,
|
||||
filename: String = "",
|
||||
/** See the note on [sendAttachment]: a marking, not a permission. */
|
||||
classification: Int = 0,
|
||||
compartments: List<Int> = emptyList(),
|
||||
): Sent {
|
||||
val sealed = Attachment.seal(data)
|
||||
val id = relay.uploadAttachment(sealed.blob)
|
||||
val ref = Attachment.Ref(
|
||||
kind = Attachment.IMAGE, id = id, key = sealed.key, digest = sealed.digest,
|
||||
kind = kind, id = id, key = sealed.key, digest = sealed.digest,
|
||||
bytes = sealed.blob.size, mime = mime, width = width, height = height,
|
||||
filename = filename, classification = classification,
|
||||
compartments = compartments.sorted(),
|
||||
)
|
||||
val body = Content.attachment(ref, caption, group.id,
|
||||
groupName = vault.groupName(group.id))
|
||||
|
||||
@@ -344,6 +344,28 @@ class LocalVault(
|
||||
found
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file in the whole tree, for a picker that has no business showing
|
||||
* folders.
|
||||
*
|
||||
* 🔴 **The whole tree, not the top of it.** iOS's picker lists `list(nil)`,
|
||||
* so a document filed into a folder cannot be sent from a conversation at
|
||||
* all — and nothing says so; the file is simply absent from the list, which
|
||||
* reads as "it isn't there". Filed as ios#68.
|
||||
*
|
||||
* ⚠️ Unreadable and missing rows are dropped here rather than shown
|
||||
* disabled. Everywhere else this class insists on showing a row you cannot
|
||||
* open — because hiding it would leave somebody unable to see that anything
|
||||
* is there. A picker is the exception: its only purpose is choosing
|
||||
* something to send, and a row that can never be chosen is furniture.
|
||||
*/
|
||||
fun allFiles(): List<Entry> = lock.withLock {
|
||||
nodes.filter { !it.isFolder }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.filter { it.name != null && !it.isMissing }
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" })
|
||||
}
|
||||
|
||||
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
|
||||
|
||||
private fun readContents(node: Node): ByteArray {
|
||||
|
||||
@@ -468,6 +468,52 @@ class LocalVaultTest {
|
||||
assertEquals(listOf("b.txt"), reopen().list(null).map { it.name })
|
||||
}
|
||||
|
||||
// ---- The picker ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 **The whole tree, not the top of it.** iOS's picker lists the root
|
||||
* only, so a document filed into a folder cannot be sent from a
|
||||
* conversation at all — and nothing says so: the file is simply absent,
|
||||
* which reads as "it isn't there". Filed as ios#68.
|
||||
*/
|
||||
@Test
|
||||
fun `everything sendable is offered, however deep it is filed`() {
|
||||
val folder = vault.createFolder("Reports", open)
|
||||
val inner = vault.createFolder("2026", open, parentId = folder.id)
|
||||
vault.add("a".toByteArray(), "top.txt", open)
|
||||
vault.add("b".toByteArray(), "buried.txt", secret, parentId = inner.id)
|
||||
|
||||
assertEquals(listOf("buried.txt", "top.txt"), vault.allFiles().map { it.name })
|
||||
}
|
||||
|
||||
/** Folders are places, not files. Sending one means nothing. */
|
||||
@Test
|
||||
fun `folders are not offered`() {
|
||||
vault.createFolder("Reports", open)
|
||||
assertTrue(vault.allFiles().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **The one place this class hides a row it cannot read.** Everywhere
|
||||
* else an unreadable row is shown, because hiding it would leave somebody
|
||||
* unable to see that anything is there. A picker's only purpose is choosing
|
||||
* something to send, and a row that can never be chosen is furniture.
|
||||
*/
|
||||
@Test
|
||||
fun `rows that could never be sent are left out of the picker`() {
|
||||
refs.files["doc://gone"] = "x".toByteArray()
|
||||
vault.reference("doc://gone", "vanished.pdf", open)
|
||||
vault.add("readable".toByteArray(), "fine.txt", open)
|
||||
refs.files.remove("doc://gone")
|
||||
|
||||
assertEquals(listOf("fine.txt"), vault.allFiles().map { it.name },
|
||||
"a file that has moved was offered as sendable")
|
||||
|
||||
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
|
||||
assertTrue(stranger.allFiles().isEmpty(),
|
||||
"a row this device cannot decrypt was offered as sendable")
|
||||
}
|
||||
|
||||
// ---- The tree --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user