Merge branch 'feat/files-local-vault' into 'main'
The local Files vault in Kotlin See merge request paulhitt/flamenet-messenger-android!51
This commit was merged in pull request #78.
This commit is contained in:
@@ -338,7 +338,7 @@ the messenger rather than staying separate apps.
|
||||
|---|:--:|
|
||||
| Files engine: names, key wrapping, chunked content | ✅ pinned to the **browser's** published vectors, not to the Swift engine |
|
||||
| Files engine: KDF and identity record | ✅ an identity written by the browser unlocks here |
|
||||
| Local vault: add, organise, delete | ⚪ |
|
||||
| Local vault: add, organise, delete | ✅ index, folders, both storage modes, bulk delete with its report |
|
||||
| Bell–LaPadula labels and clearance | 🟡 lattice, encoding and clearance in Kotlin, pinned to independently-computed vectors; the ★-property is unenforced on **both** clients — android#15 |
|
||||
| Share a file into a chat or group | ⚪ |
|
||||
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Aead
|
||||
import io.flamenet.e2e.FileContent
|
||||
import io.flamenet.e2e.FileKeys
|
||||
import io.flamenet.e2e.FileNames
|
||||
import io.flamenet.e2e.FilesEngineException
|
||||
import io.flamenet.e2e.FilesKdf
|
||||
import io.flamenet.e2e.Hkdf
|
||||
import io.flamenet.e2e.Label
|
||||
import io.flamenet.e2e.Random
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Files that live on this phone.
|
||||
*
|
||||
* 🔴 **The server is not storage any more.** It is a courier: it holds
|
||||
* ciphertext for as long as it takes to reach the other device, and then it does
|
||||
* not. Everything a person owns is here, in this app's own container, and a
|
||||
* phone that never syncs still has all of it.
|
||||
*
|
||||
* What that costs is worth stating plainly, because it changed and it changed
|
||||
* silently everywhere else:
|
||||
*
|
||||
* ⚠️ **Read-down is no longer enforced by anything that can refuse.** When the
|
||||
* rows lived in Postgres, `session_dominates(classification, compartments)` was
|
||||
* an RLS predicate — the database itself would not return a row above your
|
||||
* clearance, whatever the client asked for. There is no database here. A label
|
||||
* still gates *decryption*, absolutely: without the label key the bytes are
|
||||
* bytes. But nothing stops a sender transferring a file above a recipient's
|
||||
* level, and the clearance shown in the interface now means "the keys this
|
||||
* device holds", not "what a server will show you". That is a deliberate trade,
|
||||
* and the interface must not keep using the old words for the new thing.
|
||||
*
|
||||
* ⚠️ **The index file is device-local and is NOT an interchange format.** The
|
||||
* node *fields* match iOS's exactly, deliberately, so that a courier transfer is
|
||||
* a copy rather than a translation. The JSON around them is not: iOS encodes
|
||||
* dates as Swift's reference-date doubles, which is an accident of `JSONEncoder`
|
||||
* rather than a decision, and pinning Kotlin to it would make an accident into a
|
||||
* format. Anybody building vault migration between phones should move nodes
|
||||
* through a declared wire shape, not by copying this file.
|
||||
*
|
||||
* ⚠️ **Synchronous, and it touches the disk.** Call it off the main thread. The
|
||||
* lock below makes concurrent calls safe, not fast — a `ReentrantLock` rather
|
||||
* than a coroutine `Mutex` because this module deliberately has no coroutines
|
||||
* dependency, which is what lets its tests run without an emulator.
|
||||
*/
|
||||
class LocalVault(
|
||||
private val root: File,
|
||||
/** The raw 32-byte X25519 private key from an unlocked [io.flamenet.e2e.FilesIdentity]. */
|
||||
private val identityRawPrivate: ByteArray,
|
||||
/**
|
||||
* How a referenced file is reached.
|
||||
*
|
||||
* 🔴 Required, with no default. A no-op default would make every reference
|
||||
* row read as "missing" in an app that simply forgot to wire this up — a
|
||||
* vault that looks like it has lost half its contents, with nothing
|
||||
* anywhere saying why.
|
||||
*/
|
||||
private val references: ReferenceResolver,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Where a row's bytes actually are.
|
||||
*
|
||||
* 🔴 The two are not equally protected and the interface must not present
|
||||
* them as if they were.
|
||||
*
|
||||
* - [VAULT]: copied in and encrypted under the label key. A stolen backup or
|
||||
* container yields ciphertext, and the classification is a control.
|
||||
* - [REFERENCE]: a pointer to a file that stays where it is on the phone,
|
||||
* encrypted only at the moment it is sent. Nothing is duplicated, and the
|
||||
* file keeps whatever protection the platform gives it — but we are not
|
||||
* adding any, the label is advisory until it is sent, and the row breaks
|
||||
* if the file is moved or deleted.
|
||||
*/
|
||||
enum class Storage(val wire: String) {
|
||||
VAULT("vault"),
|
||||
REFERENCE("reference");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* ⚠️ Absence means [VAULT], and that is not a nicety. iOS shipped a
|
||||
* `storage` field with a Swift default and discovered that a default
|
||||
* does not make a `Codable` key optional — every index written
|
||||
* before referencing existed failed to decode, which presents as a
|
||||
* vault that has lost everything. Reading an unknown or missing
|
||||
* value as `vault` is what those rows actually are.
|
||||
*/
|
||||
fun parse(raw: String?): Storage =
|
||||
entries.firstOrNull { it.wire == raw } ?: VAULT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a referenced file is reached again after a relaunch.
|
||||
*
|
||||
* The [bookmark] is opaque here on purpose. On Android it is a Storage
|
||||
* Access Framework document URI whose read permission was persisted; on iOS
|
||||
* it is a security-scoped bookmark. Neither belongs in this module, which
|
||||
* has no platform in it.
|
||||
*/
|
||||
interface ReferenceResolver {
|
||||
/**
|
||||
* ⚠️ Whether the file is still there — asked separately from reading it,
|
||||
* because a row that silently does nothing when tapped is worse than one
|
||||
* that says the file has moved.
|
||||
*/
|
||||
fun exists(bookmark: String): Boolean
|
||||
|
||||
/** The bytes. Throws if the file has gone since [exists] said otherwise. */
|
||||
fun read(bookmark: String): ByteArray
|
||||
|
||||
/** Size in bytes, or 0 when the platform will not say. */
|
||||
fun size(bookmark: String): Int
|
||||
}
|
||||
|
||||
/** A row. Mirrors what the server's `nodes` table held, field for field. */
|
||||
data class Node(
|
||||
val id: UUID,
|
||||
val parentId: UUID?,
|
||||
val isFolder: Boolean,
|
||||
/**
|
||||
* In the clear, as it was in the database: a label is not a secret, and
|
||||
* it has to be readable to be shown on a row you cannot open.
|
||||
*/
|
||||
val classification: Short,
|
||||
val compartments: List<Int>,
|
||||
val wrappedNodeKey: ByteArray,
|
||||
val nameDigest: ByteArray,
|
||||
val wrappedName: ByteArray,
|
||||
val wrappedContentKey: ByteArray?,
|
||||
val byteCount: Int,
|
||||
val addedAtMillis: Long,
|
||||
val storage: Storage = Storage.VAULT,
|
||||
/** Set only for [Storage.REFERENCE]. */
|
||||
val bookmark: String? = null,
|
||||
) {
|
||||
val label: Label get() = Label(classification, compartments.toSet())
|
||||
|
||||
// Hand-written because a data class compares ByteArray by identity, so
|
||||
// the generated equals would call two identical rows different.
|
||||
override fun equals(other: Any?): Boolean = other is Node && other.id == id
|
||||
override fun hashCode(): Int = id.hashCode()
|
||||
}
|
||||
|
||||
/** What a screen shows: the row, plus the name if this device can read it. */
|
||||
data class Entry(
|
||||
val node: Node,
|
||||
val name: String?,
|
||||
/**
|
||||
* ⚠️ True when this is a reference whose file is no longer where it was.
|
||||
* Surfaced rather than hidden.
|
||||
*/
|
||||
val isMissing: Boolean,
|
||||
) {
|
||||
val id: UUID get() = node.id
|
||||
val label: Label get() = node.label
|
||||
val isFolder: Boolean get() = node.isFolder
|
||||
val byteCount: Int get() = node.byteCount
|
||||
val storage: Storage get() = node.storage
|
||||
}
|
||||
|
||||
/** What a bulk delete actually did. */
|
||||
data class Removal(
|
||||
val removed: List<UUID> = emptyList(),
|
||||
/** Folders left alone because something inside them was not selected. */
|
||||
val keptNonEmpty: List<UUID> = emptyList(),
|
||||
) {
|
||||
val isCompletelyDone: Boolean get() = keptNonEmpty.isEmpty()
|
||||
}
|
||||
|
||||
class VaultException(message: String) : Exception(message)
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private var nodes = mutableListOf<Node>()
|
||||
|
||||
private val indexFile: File get() = File(root, "index.json")
|
||||
private val blobs: File get() = File(root, "blobs")
|
||||
private fun blobFile(id: UUID) = File(blobs, id.toString())
|
||||
|
||||
// ---- Keys ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The key for a label, derived rather than fetched.
|
||||
*
|
||||
* 🔴 Deterministic from the identity key, so every device holding the
|
||||
* identity derives the same one and a vault restored from a courier transfer
|
||||
* opens without a grant round-trip. Sharing an area means sending this
|
||||
* value, wrapped to the recipient — the same grant the server used to
|
||||
* broker, minus the broker.
|
||||
*
|
||||
* ⚠️ **Binds the whole canonical label encoding, not just the
|
||||
* classification.** Deriving from the level alone would give one key to
|
||||
* every compartment at that level, which is precisely the separation
|
||||
* compartments exist for.
|
||||
*/
|
||||
internal fun labelKey(label: Label): ByteArray = Hkdf.derive(
|
||||
ikm = identityRawPrivate,
|
||||
salt = label.bytes(),
|
||||
info = FilesKdf.Context.LOCAL_LABEL.toByteArray(Charsets.UTF_8),
|
||||
length = 32,
|
||||
)
|
||||
|
||||
/**
|
||||
* The name, or null when this device cannot read the row.
|
||||
*
|
||||
* ⚠️ On a local vault every label the identity can derive is readable, so
|
||||
* this is non-null for anything this device filed itself. It exists for the
|
||||
* case that is not that: a node that arrived from somebody else, filed under
|
||||
* a label whose key was never granted. Those decrypt to null and the row
|
||||
* says so.
|
||||
*/
|
||||
private fun nameOf(node: Node): String? = try {
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
FileNames.decrypt(nodeKey, node.wrappedName, node.nameDigest, node.label)
|
||||
} catch (e: Aead.DecryptionFailed) {
|
||||
null
|
||||
} catch (e: FilesEngineException) {
|
||||
null
|
||||
}
|
||||
|
||||
// ---- Storage ---------------------------------------------------------
|
||||
|
||||
fun load() = lock.withLock {
|
||||
blobs.mkdirs()
|
||||
val text = if (indexFile.exists()) indexFile.readText() else return@withLock
|
||||
val root = JSONObject(text)
|
||||
val array = root.optJSONArray("nodes") ?: JSONArray()
|
||||
nodes = (0 until array.length()).mapTo(mutableListOf()) { decode(array.getJSONObject(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 Written to a temporary file and moved into place. An index truncated by
|
||||
* a crash mid-write is a vault that has lost every file at once — and the
|
||||
* blobs would still be sitting there, unreferenced, taking up the space.
|
||||
*/
|
||||
private fun save() {
|
||||
root.mkdirs()
|
||||
val temporary = File(root, "index.json.new")
|
||||
temporary.writeText(
|
||||
JSONObject()
|
||||
.put("version", 1)
|
||||
.put("nodes", JSONArray().also { array -> nodes.forEach { array.put(encode(it)) } })
|
||||
.toString()
|
||||
)
|
||||
Files.move(
|
||||
temporary.toPath(), indexFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun b64(bytes: ByteArray) = Base64.getEncoder().encodeToString(bytes)
|
||||
private fun unb64(text: String) = Base64.getDecoder().decode(text)
|
||||
|
||||
private fun encode(node: Node): JSONObject = JSONObject()
|
||||
.put("id", node.id.toString())
|
||||
.put("parentID", node.parentId?.toString() ?: JSONObject.NULL)
|
||||
.put("isFolder", node.isFolder)
|
||||
.put("classification", node.classification.toInt())
|
||||
.put("compartments", JSONArray(node.compartments))
|
||||
.put("wrappedNodeKey", b64(node.wrappedNodeKey))
|
||||
.put("nameDigest", b64(node.nameDigest))
|
||||
.put("wrappedName", b64(node.wrappedName))
|
||||
.put("wrappedContentKey", node.wrappedContentKey?.let { b64(it) } ?: JSONObject.NULL)
|
||||
.put("byteCount", node.byteCount)
|
||||
.put("addedAt", node.addedAtMillis)
|
||||
.put("storage", node.storage.wire)
|
||||
.put("bookmark", node.bookmark ?: JSONObject.NULL)
|
||||
|
||||
private fun decode(o: JSONObject): Node {
|
||||
val bits = o.optJSONArray("compartments") ?: JSONArray()
|
||||
return Node(
|
||||
id = UUID.fromString(o.getString("id")),
|
||||
parentId = o.optString("parentID", "").takeIf { it.isNotEmpty() }?.let(UUID::fromString),
|
||||
isFolder = o.optBoolean("isFolder", false),
|
||||
classification = o.getInt("classification").toShort(),
|
||||
compartments = (0 until bits.length()).map { bits.getInt(it) },
|
||||
wrappedNodeKey = unb64(o.getString("wrappedNodeKey")),
|
||||
nameDigest = unb64(o.getString("nameDigest")),
|
||||
wrappedName = unb64(o.getString("wrappedName")),
|
||||
wrappedContentKey = o.optString("wrappedContentKey", "")
|
||||
.takeIf { it.isNotEmpty() }?.let(::unb64),
|
||||
byteCount = o.optInt("byteCount", 0),
|
||||
addedAtMillis = o.optLong("addedAt", 0L),
|
||||
storage = Storage.parse(o.optString("storage", "").takeIf { it.isNotEmpty() }),
|
||||
bookmark = o.optString("bookmark", "").takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Reading ---------------------------------------------------------
|
||||
|
||||
fun list(parentId: UUID?): List<Entry> = lock.withLock {
|
||||
nodes.filter { it.parentId == parentId }
|
||||
.map { Entry(it, nameOf(it), isMissing = isMissing(it)) }
|
||||
.sortedWith(
|
||||
// Folders first, then by name; unreadable rows last, where they
|
||||
// are least in the way of the ones you can act on.
|
||||
compareByDescending<Entry> { it.isFolder }
|
||||
.thenBy { it.name == null }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name ?: "" }
|
||||
)
|
||||
}
|
||||
|
||||
private fun isMissing(node: Node): Boolean =
|
||||
node.storage == Storage.REFERENCE &&
|
||||
(node.bookmark == null || !references.exists(node.bookmark))
|
||||
|
||||
fun node(id: UUID): Node? = lock.withLock { nodes.firstOrNull { it.id == id } }
|
||||
|
||||
val count: Int get() = lock.withLock { nodes.size }
|
||||
|
||||
/**
|
||||
* Every id in a subtree, so a screen can offer "and everything in it".
|
||||
*
|
||||
* 🔴 **Bounded by what it has already seen, not by a step counter.** A cycle
|
||||
* cannot be built through this API, but the index is a file on disk and
|
||||
* something else could write one — so this has to terminate. iOS bounds it
|
||||
* with `guardRail < 10_000`, which terminates and then hands back ten
|
||||
* thousand copies of the same two ids: a screen offering to delete a folder
|
||||
* "and everything in it" would say 10,000 things. Refusing to revisit a node
|
||||
* both terminates AND returns the right answer, and needs no magic number.
|
||||
*/
|
||||
fun descendants(of: UUID): List<UUID> = lock.withLock {
|
||||
val found = mutableListOf<UUID>()
|
||||
val seen = mutableSetOf(of)
|
||||
val frontier = ArrayDeque(listOf(of))
|
||||
while (frontier.isNotEmpty()) {
|
||||
val next = frontier.removeLast()
|
||||
for (child in nodes.filter { it.parentId == next }) {
|
||||
if (!seen.add(child.id)) continue
|
||||
found += child.id
|
||||
frontier.addLast(child.id)
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fun contents(of: Node): ByteArray = lock.withLock { readContents(of) }
|
||||
|
||||
private fun readContents(node: Node): ByteArray {
|
||||
if (node.isFolder) throw VaultException("That is a folder.")
|
||||
|
||||
if (node.storage == Storage.REFERENCE) {
|
||||
val bookmark = node.bookmark
|
||||
if (bookmark == null || !references.exists(bookmark)) {
|
||||
throw VaultException(
|
||||
"That file isn't where it was — it may have been moved or deleted.")
|
||||
}
|
||||
return references.read(bookmark)
|
||||
}
|
||||
|
||||
val wrappedContentKey = node.wrappedContentKey
|
||||
?: throw VaultException("That file has no contents.")
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = FileKeys.unwrapContentKey(wrappedContentKey, nodeKey, node.label)
|
||||
return FileContent.decrypt(blobFile(node.id).readBytes(), contentKey, node.label)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes, encrypted for sending — whichever way they are stored.
|
||||
*
|
||||
* 🔴 **This is where a referenced file first becomes ciphertext.** It is
|
||||
* sealed under a fresh content key bound to the label, so the courier
|
||||
* carries something it cannot read and the classification finally gates
|
||||
* something. Returns the sealed blob and the key to hand the recipient.
|
||||
*/
|
||||
data class Sealed(val blob: ByteArray, val contentKey: ByteArray)
|
||||
|
||||
fun sealedForSending(node: Node): Sealed = lock.withLock {
|
||||
val plaintext = readContents(node)
|
||||
val contentKey = Random.bytes(32)
|
||||
Sealed(FileContent.encrypt(plaintext, contentKey, node.label), contentKey)
|
||||
}
|
||||
|
||||
// ---- Writing ---------------------------------------------------------
|
||||
|
||||
fun add(bytes: ByteArray, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = false, byteCount = bytes.size)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(label), label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// 🔴 Bytes to disk BEFORE the index. The other order leaves an index
|
||||
// entry for a file that is not there, which reads as corruption;
|
||||
// this order leaves at worst an unreferenced blob, which is a
|
||||
// cleanup job nobody notices.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(bytes, contentKey, label))
|
||||
|
||||
val stored = node.copy(
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, label))
|
||||
nodes += stored
|
||||
save()
|
||||
stored
|
||||
}
|
||||
|
||||
fun createFolder(name: String, label: Label, parentId: UUID? = null): Node = lock.withLock {
|
||||
val node = makeNode(name, label, parentId, isFolder = true, byteCount = 0)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
|
||||
/**
|
||||
* File a document that stays where it is.
|
||||
*
|
||||
* 🔴 Nothing is copied and nothing is encrypted here — see [Storage]. The
|
||||
* bytes get their content key at send time, not now, so until then this
|
||||
* row's label is a statement of intent rather than a lock.
|
||||
*/
|
||||
fun reference(bookmark: String, name: String, label: Label, parentId: UUID? = null): Node =
|
||||
lock.withLock {
|
||||
val node = makeNode(
|
||||
name, label, parentId, isFolder = false,
|
||||
byteCount = references.size(bookmark),
|
||||
).copy(storage = Storage.REFERENCE, bookmark = bookmark)
|
||||
nodes += node
|
||||
save()
|
||||
node
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a reference into an encrypted copy, in place.
|
||||
*
|
||||
* 🔴 **The bridge between the two ways of holding a file, and the reason
|
||||
* both exist.** Referencing avoids duplicating anything; vaulting is what
|
||||
* makes a classification a control rather than a note. Most files want the
|
||||
* first and a few want the second, and nobody knows which is which until
|
||||
* after they have added them — so this promotes one without re-picking it.
|
||||
*
|
||||
* ⚠️ It does NOT delete the person's original: that is their file, and
|
||||
* removing it is a separate decision the interface asks about afterwards.
|
||||
* Until they do, this genuinely is two copies — the honest trade for having
|
||||
* the encrypted one.
|
||||
*
|
||||
* Returns the bookmark the original was read from, so a caller can offer
|
||||
* that, or null when the row was already vaulted.
|
||||
*/
|
||||
fun encrypt(id: UUID): String? = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) throw VaultException("That file isn't here any more.")
|
||||
val node = nodes[position]
|
||||
if (node.storage != Storage.REFERENCE) return@withLock null
|
||||
val origin = node.bookmark
|
||||
if (origin == null || !references.exists(origin)) {
|
||||
throw VaultException("That file isn't where it was — it may have been moved or deleted.")
|
||||
}
|
||||
|
||||
val plaintext = readContents(node)
|
||||
val nodeKey = FileKeys.unwrapNodeKey(node.wrappedNodeKey, labelKey(node.label), node.label)
|
||||
val contentKey = Random.bytes(32)
|
||||
|
||||
// Bytes before index, as everywhere else here.
|
||||
blobs.mkdirs()
|
||||
writeAtomically(blobFile(node.id), FileContent.encrypt(plaintext, contentKey, node.label))
|
||||
|
||||
nodes[position] = node.copy(
|
||||
storage = Storage.VAULT,
|
||||
// 🔴 The bookmark goes. Keeping it would leave a vaulted row still
|
||||
// able to read through to a plaintext original, so the encrypted
|
||||
// copy would not be the only way in — which is the entire point of
|
||||
// vaulting it.
|
||||
bookmark = null,
|
||||
wrappedContentKey = FileKeys.wrapContentKey(contentKey, nodeKey, node.label),
|
||||
byteCount = plaintext.size,
|
||||
)
|
||||
save()
|
||||
origin
|
||||
}
|
||||
|
||||
private fun makeNode(
|
||||
name: String,
|
||||
label: Label,
|
||||
parentId: UUID?,
|
||||
isFolder: Boolean,
|
||||
byteCount: Int,
|
||||
): Node {
|
||||
val nodeKey = Random.bytes(32)
|
||||
val sealed = FileNames.encrypt(nodeKey, name, label)
|
||||
return Node(
|
||||
id = UUID.randomUUID(),
|
||||
parentId = parentId,
|
||||
isFolder = isFolder,
|
||||
classification = label.classification,
|
||||
compartments = label.compartments.sorted(),
|
||||
// 🔴 Wrapped to the node's OWN label key, never the parent's. The
|
||||
// tree allows a Restricted file inside a Confidential folder, so a
|
||||
// folder-derived key would hand every folder-key holder content
|
||||
// above their clearance.
|
||||
wrappedNodeKey = FileKeys.wrapNodeKey(nodeKey, labelKey(label), label),
|
||||
nameDigest = sealed.digest,
|
||||
wrappedName = sealed.ciphertext,
|
||||
wrappedContentKey = null,
|
||||
byteCount = byteCount,
|
||||
addedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeAtomically(target: File, bytes: ByteArray) {
|
||||
val temporary = File(target.parentFile, target.name + ".new")
|
||||
temporary.writeBytes(bytes)
|
||||
Files.move(
|
||||
temporary.toPath(), target.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Removes a node and its bytes.
|
||||
*
|
||||
* ⚠️ Refuses a folder that still has anything in it. Deleting a folder and
|
||||
* orphaning its contents would leave rows nothing can reach and bytes
|
||||
* nothing will ever free.
|
||||
*/
|
||||
fun remove(id: UUID) = lock.withLock {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) return@withLock
|
||||
val node = nodes[position]
|
||||
if (node.isFolder && nodes.any { it.parentId == id }) {
|
||||
throw VaultException("That folder isn't empty.")
|
||||
}
|
||||
nodes.removeAt(position)
|
||||
save()
|
||||
// Never the person's own file — see the note in the bulk version.
|
||||
if (node.storage == Storage.VAULT) blobFile(id).delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete several things at once.
|
||||
*
|
||||
* 🔴 **This reports rather than returning nothing.** Selecting eleven things
|
||||
* and having nine disappear is a data-loss-shaped event in the other
|
||||
* direction: the two that survived are the ones somebody now thinks are
|
||||
* gone. The screen says which, and why.
|
||||
*
|
||||
* ⚠️ Deletes deepest-first, so selecting a folder AND everything in it
|
||||
* works. A folder whose contents were NOT all selected is kept, because the
|
||||
* alternative is a bulk gesture that silently takes rows the person never
|
||||
* selected — including ones filed above what this device can even read,
|
||||
* which they could not have reviewed before tapping.
|
||||
*/
|
||||
fun remove(ids: List<UUID>): Removal = lock.withLock {
|
||||
val removed = mutableListOf<UUID>()
|
||||
val keptNonEmpty = mutableListOf<UUID>()
|
||||
val removedVaultIds = mutableListOf<UUID>()
|
||||
val wanted = ids.toSet()
|
||||
|
||||
// Depth first: a child must go before the parent that contains it.
|
||||
fun depth(id: UUID): Int {
|
||||
var steps = 0
|
||||
var current = nodes.firstOrNull { it.id == id }?.parentId
|
||||
while (current != null && steps < 64) {
|
||||
steps++
|
||||
current = nodes.firstOrNull { it.id == current }?.parentId
|
||||
}
|
||||
return steps
|
||||
}
|
||||
val ordered = ids.filter { id -> nodes.any { it.id == id } }
|
||||
.sortedByDescending { depth(it) }
|
||||
|
||||
for (id in ordered) {
|
||||
val position = nodes.indexOfFirst { it.id == id }
|
||||
if (position < 0) continue
|
||||
val node = nodes[position]
|
||||
if (node.isFolder) {
|
||||
// Anything left inside that was not itself selected?
|
||||
if (nodes.any { it.parentId == id && it.id !in wanted }) {
|
||||
keptNonEmpty += id
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (node.storage == Storage.VAULT) removedVaultIds += id
|
||||
nodes.removeAt(position)
|
||||
removed += id
|
||||
}
|
||||
|
||||
// 🔴 One save for the whole batch, after the index is consistent. A save
|
||||
// per row would leave a half-deleted tree on disk if the process died
|
||||
// mid-loop; the blobs go afterwards, so a crash costs orphaned bytes
|
||||
// rather than rows pointing at files that are gone.
|
||||
save()
|
||||
// 🔴 Only ever our own blobs. A referenced row's bytes are the person's
|
||||
// own file, sitting where they put it — deleting the row must not delete
|
||||
// their document, and `blobFile` for a reference names a file that was
|
||||
// never written.
|
||||
removedVaultIds.forEach { blobFile(it).delete() }
|
||||
Removal(removed, keptNonEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import io.flamenet.e2e.Aead
|
||||
import io.flamenet.e2e.FileContent
|
||||
import io.flamenet.e2e.FileKeys
|
||||
import io.flamenet.e2e.Label
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* The local vault.
|
||||
*
|
||||
* ⚠️ Against a real directory in a temporary folder, not an in-memory fake. Half
|
||||
* of what this class does IS the filesystem — atomic replaces, blobs written
|
||||
* before rows, blobs that must survive a row being removed — and a fake would
|
||||
* agree with the implementation about all of it.
|
||||
*/
|
||||
class LocalVaultTest {
|
||||
|
||||
/**
|
||||
* Files somebody else owns, that we only point at.
|
||||
*
|
||||
* Records every call, because for the delete path "the file survived" is not
|
||||
* a claim worth making on its own — see the test that uses [calls].
|
||||
*/
|
||||
private class FakeReferences : LocalVault.ReferenceResolver {
|
||||
val files = mutableMapOf<String, ByteArray>()
|
||||
val calls = mutableListOf<String>()
|
||||
override fun exists(bookmark: String): Boolean {
|
||||
calls += "exists($bookmark)"
|
||||
return files.containsKey(bookmark)
|
||||
}
|
||||
override fun read(bookmark: String): ByteArray {
|
||||
calls += "read($bookmark)"
|
||||
return files[bookmark] ?: throw IllegalStateException("gone")
|
||||
}
|
||||
override fun size(bookmark: String): Int {
|
||||
calls += "size($bookmark)"
|
||||
return files[bookmark]?.size ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var root: File
|
||||
private lateinit var refs: FakeReferences
|
||||
private lateinit var vault: LocalVault
|
||||
|
||||
/** A stable identity, so a reopened vault derives the same label keys. */
|
||||
private val identity = ByteArray(32) { (it * 3 + 1).toByte() }
|
||||
|
||||
private val secret = Label(5, setOf(1))
|
||||
private val open = Label(1)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
root = File.createTempFile("vault", "").let { it.delete(); it.mkdirs(); it }
|
||||
refs = FakeReferences()
|
||||
vault = LocalVault(root, identity, refs).also { it.load() }
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() { root.deleteRecursively() }
|
||||
|
||||
private fun reopen(): LocalVault = LocalVault(root, identity, refs).also { it.load() }
|
||||
private fun hex(b: ByteArray) = b.joinToString("") { "%02x".format(it) }
|
||||
private fun blobsOnDisk() = File(root, "blobs").listFiles()?.map { it.name }?.toSet() ?: emptySet()
|
||||
|
||||
// ---- The round trip --------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a file goes in and comes back out`() {
|
||||
val bytes = "revenue: 41\nheadcount: 12\n".toByteArray()
|
||||
val node = vault.add(bytes, "Q3 Forecast.txt", secret)
|
||||
|
||||
val entry = vault.list(null).single()
|
||||
assertEquals("Q3 Forecast.txt", entry.name)
|
||||
assertEquals(bytes.size, entry.byteCount)
|
||||
assertEquals(LocalVault.Storage.VAULT, entry.storage)
|
||||
assertEquals(hex(bytes), hex(vault.contents(node)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The plaintext must not be on disk anywhere.** The whole claim of a
|
||||
* vault is that a stolen container yields ciphertext, and a search for the
|
||||
* bytes is the only way to be sure — a round trip passes just as happily if
|
||||
* `add` wrote the file out unencrypted and read it straight back.
|
||||
*/
|
||||
@Test
|
||||
fun `nothing on disk contains the plaintext`() {
|
||||
val phrase = "SEVERANCE-AGREEMENT-DRAFT"
|
||||
vault.add("$phrase and some more text".toByteArray(), "Payroll.txt", secret)
|
||||
|
||||
val needle = phrase.toByteArray()
|
||||
for (file in root.walkTopDown().filter { it.isFile }) {
|
||||
val haystack = file.readBytes()
|
||||
val found = (0..(haystack.size - needle.size)).any { at ->
|
||||
needle.indices.all { haystack[at + it] == needle[it] }
|
||||
}
|
||||
assertFalse(found, "${file.name} holds the plaintext")
|
||||
}
|
||||
// ...and the name is not sitting in the index either.
|
||||
assertFalse(File(root, "index.json").readText().contains("Payroll"),
|
||||
"the filename is stored in the clear")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vault survives being closed and reopened`() {
|
||||
val bytes = ByteArray(200_000) { (it % 251).toByte() }
|
||||
val node = vault.add(bytes, "big.bin", secret)
|
||||
vault.createFolder("Reports", open)
|
||||
|
||||
val second = reopen()
|
||||
assertEquals(2, second.count)
|
||||
assertEquals(hex(bytes), hex(second.contents(assertNotNull(second.node(node.id)))))
|
||||
assertEquals(setOf("Reports", "big.bin"), second.list(null).map { it.name }.toSet())
|
||||
}
|
||||
|
||||
// ---- The index -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 **An index with no `storage` field must read as vaulted.** iOS shipped
|
||||
* this field with a Swift default and found out that a default does not make
|
||||
* a `Codable` key optional: every index written before referencing existed
|
||||
* failed to decode, which presents to a person as a vault that has lost
|
||||
* everything. The field is stripped here on purpose, from a real index.
|
||||
*/
|
||||
@Test
|
||||
fun `an index written before storage existed still loads, as vaulted`() {
|
||||
val bytes = "old file".toByteArray()
|
||||
val node = vault.add(bytes, "legacy.txt", secret)
|
||||
|
||||
val index = File(root, "index.json")
|
||||
val json = JSONObject(index.readText())
|
||||
json.getJSONArray("nodes").getJSONObject(0).remove("storage")
|
||||
index.writeText(json.toString())
|
||||
|
||||
val second = reopen()
|
||||
assertEquals(1, second.count)
|
||||
assertEquals(LocalVault.Storage.VAULT, assertNotNull(second.node(node.id)).storage)
|
||||
assertEquals(hex(bytes), hex(second.contents(assertNotNull(second.node(node.id)))))
|
||||
}
|
||||
|
||||
/** An unknown value is vaulted too, rather than throwing the row away. */
|
||||
@Test
|
||||
fun `an unrecognised storage value reads as vaulted`() {
|
||||
vault.add("x".toByteArray(), "f.txt", secret)
|
||||
val index = File(root, "index.json")
|
||||
val json = JSONObject(index.readText())
|
||||
json.getJSONArray("nodes").getJSONObject(0).put("storage", "something-newer")
|
||||
index.writeText(json.toString())
|
||||
assertEquals(LocalVault.Storage.VAULT, reopen().list(null).single().storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty directory loads as an empty vault rather than failing`() {
|
||||
val fresh = File(root, "elsewhere").also { it.mkdirs() }
|
||||
assertEquals(0, LocalVault(fresh, identity, refs).also { it.load() }.count)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The temporary file must not survive a save. One left behind is
|
||||
* harmless today and is exactly what a future "load the newest index"
|
||||
* shortcut would pick up.
|
||||
*/
|
||||
@Test
|
||||
fun `saving leaves no temporary index behind`() {
|
||||
vault.add("x".toByteArray(), "f.txt", secret)
|
||||
assertFalse(File(root, "index.json.new").exists())
|
||||
assertTrue(File(root, "index.json").exists())
|
||||
}
|
||||
|
||||
// ---- Labels ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 **The label key binds the WHOLE canonical encoding.** Deriving from the
|
||||
* classification alone would hand one key to every compartment at that
|
||||
* level — which is the exact separation compartments exist for, and it would
|
||||
* pass every test built from levels alone.
|
||||
*/
|
||||
@Test
|
||||
fun `two labels at one level but different compartments derive different keys`() {
|
||||
val a = vault.labelKey(Label(5, setOf(1)))
|
||||
val b = vault.labelKey(Label(5, setOf(2)))
|
||||
val bare = vault.labelKey(Label(5))
|
||||
assertEquals(3, setOf(hex(a), hex(b), hex(bare)).size,
|
||||
"compartments are not reaching the key derivation")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **A node key is wrapped to its OWN label, never its parent's.** The tree
|
||||
* deliberately allows a low-classification file inside a high one, so a
|
||||
* folder-derived key would hand every holder of the folder's key content
|
||||
* filed above them.
|
||||
*/
|
||||
@Test
|
||||
fun `a file's key does not come from the folder above it`() {
|
||||
val folder = vault.createFolder("Confidential", secret)
|
||||
val file = vault.add("inside".toByteArray(), "note.txt", open, parentId = folder.id)
|
||||
|
||||
// The folder's own label key must not unwrap the file's node key.
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
FileKeys.unwrapNodeKey(file.wrappedNodeKey, vault.labelKey(secret), file.label)
|
||||
}
|
||||
// ...while the file's own does.
|
||||
assertEquals("note.txt", vault.list(folder.id).single().name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row filed by somebody else, under a label whose key was never granted:
|
||||
* unreadable, but still LISTED, with its classification visible. A row that
|
||||
* simply vanished would leave a person unable to see that anything is there.
|
||||
*/
|
||||
@Test
|
||||
fun `a row this device cannot read is listed without its name`() {
|
||||
vault.add("mine".toByteArray(), "readable.txt", secret)
|
||||
val strangerVault = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
|
||||
val entry = strangerVault.list(null).single()
|
||||
|
||||
assertNull(entry.name, "a row wrapped under a key we do not hold decrypted anyway")
|
||||
assertEquals(secret, entry.label, "the classification must stay visible")
|
||||
assertFailsWith<Aead.DecryptionFailed> { strangerVault.contents(entry.node) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unreadable rows sort last, and folders first`() {
|
||||
vault.createFolder("Zebra", open)
|
||||
vault.add("a".toByteArray(), "apple.txt", open)
|
||||
vault.add("b".toByteArray(), "banana.txt", open)
|
||||
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
|
||||
// Everything is unreadable to the stranger, so file one readable row.
|
||||
val names = vault.list(null).map { it.name }
|
||||
assertEquals(listOf("Zebra", "apple.txt", "banana.txt"), names)
|
||||
assertEquals(3, stranger.list(null).size)
|
||||
assertTrue(stranger.list(null).all { it.name == null })
|
||||
}
|
||||
|
||||
// ---- Sending ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a sealed file opens with the key that was handed over`() {
|
||||
val bytes = "for you".toByteArray()
|
||||
val node = vault.add(bytes, "gift.txt", secret)
|
||||
val sealed = vault.sealedForSending(node)
|
||||
assertEquals(hex(bytes), hex(FileContent.decrypt(sealed.blob, sealed.contentKey, secret)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 A fresh content key every time. Reusing the stored one would mean two
|
||||
* recipients of one file hold the same key — so revoking either is
|
||||
* impossible, and the vault's own blob opens with a key that has left the
|
||||
* device.
|
||||
*/
|
||||
@Test
|
||||
fun `sealing twice uses two different keys`() {
|
||||
val node = vault.add("twice".toByteArray(), "f.txt", secret)
|
||||
val a = vault.sealedForSending(node)
|
||||
val b = vault.sealedForSending(node)
|
||||
assertFalse(a.contentKey.contentEquals(b.contentKey), "the content key is being reused")
|
||||
assertFailsWith<Aead.DecryptionFailed> { FileContent.decrypt(a.blob, b.contentKey, secret) }
|
||||
}
|
||||
|
||||
/** The label travels with the file: it will not open filed as anything else. */
|
||||
@Test
|
||||
fun `a sealed file does not open under a different label`() {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
val sealed = vault.sealedForSending(node)
|
||||
assertFailsWith<Aead.DecryptionFailed> {
|
||||
FileContent.decrypt(sealed.blob, sealed.contentKey, open)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- References ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a referenced file is read through without being copied`() {
|
||||
refs.files["doc://1"] = "their document".toByteArray()
|
||||
val node = vault.reference("doc://1", "Their doc.pdf", open)
|
||||
|
||||
assertEquals(LocalVault.Storage.REFERENCE, node.storage)
|
||||
assertEquals("their document".length, node.byteCount)
|
||||
assertEquals(hex("their document".toByteArray()), hex(vault.contents(node)))
|
||||
assertTrue(blobsOnDisk().isEmpty(), "a reference wrote a blob — it is meant to copy nothing")
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A file the person moved or deleted is not an error. It is their file.
|
||||
* The row reports itself missing and they decide what to do — a row that
|
||||
* silently does nothing when tapped is worse than one that says so.
|
||||
*/
|
||||
@Test
|
||||
fun `a reference whose file has gone reports itself missing`() {
|
||||
refs.files["doc://1"] = "here for now".toByteArray()
|
||||
val node = vault.reference("doc://1", "Their doc.pdf", open)
|
||||
assertFalse(vault.list(null).single().isMissing)
|
||||
|
||||
refs.files.remove("doc://1")
|
||||
assertTrue(vault.list(null).single().isMissing)
|
||||
assertEquals("Their doc.pdf", vault.list(null).single().name,
|
||||
"the row lost its name as well as its file")
|
||||
assertFailsWith<LocalVault.VaultException> { vault.contents(node) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Promoting to the vault drops the bookmark.** Keeping it would leave a
|
||||
* vaulted row still able to read through to a plaintext original, so the
|
||||
* encrypted copy would not be the only way in — which is the entire point of
|
||||
* vaulting it.
|
||||
*/
|
||||
@Test
|
||||
fun `promoting a reference encrypts it and lets go of the original`() {
|
||||
refs.files["doc://1"] = "promote me".toByteArray()
|
||||
val node = vault.reference("doc://1", "doc.pdf", secret)
|
||||
|
||||
val origin = vault.encrypt(node.id)
|
||||
assertEquals("doc://1", origin, "the caller was not told which file to offer to delete")
|
||||
|
||||
val after = assertNotNull(vault.node(node.id))
|
||||
assertEquals(LocalVault.Storage.VAULT, after.storage)
|
||||
assertNull(after.bookmark, "a vaulted row can still read through to the plaintext original")
|
||||
assertEquals(setOf(node.id.toString()), blobsOnDisk())
|
||||
|
||||
// ⚠️ And the person's own file is still theirs.
|
||||
assertTrue(refs.files.containsKey("doc://1"), "promoting deleted somebody's own document")
|
||||
assertEquals(hex("promote me".toByteArray()), hex(vault.contents(after)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoting something already vaulted does nothing`() {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
assertNull(vault.encrypt(node.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoting a reference whose file has gone says so`() {
|
||||
refs.files["doc://1"] = "x".toByteArray()
|
||||
val node = vault.reference("doc://1", "doc.pdf", secret)
|
||||
refs.files.remove("doc://1")
|
||||
assertFailsWith<LocalVault.VaultException> { vault.encrypt(node.id) }
|
||||
}
|
||||
|
||||
// ---- Deleting --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `deleting a file takes its bytes with it`() {
|
||||
val node = vault.add("x".toByteArray(), "f.txt", secret)
|
||||
assertEquals(setOf(node.id.toString()), blobsOnDisk())
|
||||
vault.remove(node.id)
|
||||
assertEquals(0, vault.count)
|
||||
assertTrue(blobsOnDisk().isEmpty(), "the blob outlived its row")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **Deleting a REFERENCE row must not touch the person's document.**
|
||||
* Those bytes are their file, sitting where they put it. This is the one
|
||||
* mistake in the whole class that cannot be undone.
|
||||
*
|
||||
* ⚠️ **Asserting only that the file survived would be a test that cannot
|
||||
* fail**, because the delete path does not hold a resolver call to remove —
|
||||
* the protection is structural: [LocalVault.ReferenceResolver] has no way to
|
||||
* delete anything. So this asserts the property that actually constrains
|
||||
* future code: removing a row reaches the person's storage **not at all**.
|
||||
* Anybody who later routes the delete path through the resolver has to come
|
||||
* here and say why.
|
||||
*/
|
||||
@Test
|
||||
fun `deleting a reference row does not reach the person's storage at all`() {
|
||||
refs.files["doc://1"] = "still mine".toByteArray()
|
||||
val node = vault.reference("doc://1", "doc.pdf", open)
|
||||
|
||||
refs.calls.clear()
|
||||
vault.remove(node.id)
|
||||
|
||||
assertEquals(emptyList(), refs.calls,
|
||||
"the delete path is talking to the person's own storage")
|
||||
assertTrue(refs.files.containsKey("doc://1"), "removing a row deleted somebody's document")
|
||||
|
||||
// Same again for the bulk path, which is the one a screen actually uses.
|
||||
val second = vault.reference("doc://1", "doc.pdf", open)
|
||||
refs.calls.clear()
|
||||
vault.remove(listOf(second.id))
|
||||
assertEquals(emptyList(), refs.calls,
|
||||
"the bulk delete path is talking to the person's own storage")
|
||||
assertTrue(refs.files.containsKey("doc://1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a folder with something in it is not deleted`() {
|
||||
val folder = vault.createFolder("Reports", open)
|
||||
vault.add("x".toByteArray(), "inside.txt", open, parentId = folder.id)
|
||||
assertFailsWith<LocalVault.VaultException> { vault.remove(folder.id) }
|
||||
assertEquals(2, vault.count)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ Deepest-first, so selecting a folder AND everything in it works. Doing
|
||||
* it in the order given would refuse the folder for being non-empty and
|
||||
* leave half the selection behind.
|
||||
*/
|
||||
@Test
|
||||
fun `selecting a folder and its contents removes all of it`() {
|
||||
val folder = vault.createFolder("Reports", open)
|
||||
val inner = vault.createFolder("2026", open, parentId = folder.id)
|
||||
val file = vault.add("x".toByteArray(), "q3.txt", open, parentId = inner.id)
|
||||
|
||||
// Deliberately shallowest-first, which is the order a screen produces.
|
||||
val result = vault.remove(listOf(folder.id, inner.id, file.id))
|
||||
assertTrue(result.isCompletelyDone)
|
||||
assertEquals(3, result.removed.size)
|
||||
assertEquals(0, vault.count)
|
||||
assertTrue(blobsOnDisk().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 **The report is the point.** Selecting eleven things and having nine go
|
||||
* is a data-loss-shaped event in the other direction: the two that survived
|
||||
* are the ones somebody now believes are gone.
|
||||
*/
|
||||
@Test
|
||||
fun `a folder whose contents were not all selected is kept, and reported`() {
|
||||
val folder = vault.createFolder("Reports", open)
|
||||
val chosen = vault.add("a".toByteArray(), "chosen.txt", open, parentId = folder.id)
|
||||
vault.add("b".toByteArray(), "not chosen.txt", open, parentId = folder.id)
|
||||
|
||||
val result = vault.remove(listOf(folder.id, chosen.id))
|
||||
assertFalse(result.isCompletelyDone, "the screen would have said everything went")
|
||||
assertEquals(listOf(folder.id), result.keptNonEmpty)
|
||||
assertEquals(listOf(chosen.id), result.removed)
|
||||
assertEquals(2, vault.count)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A row filed above what this device can read still counts as contents.
|
||||
* Otherwise a bulk gesture would take rows the person could not have
|
||||
* reviewed before tapping.
|
||||
*/
|
||||
@Test
|
||||
fun `an unreadable row inside a folder still protects it from a bulk delete`() {
|
||||
val folder = vault.createFolder("Shared", open)
|
||||
vault.add("theirs".toByteArray(), "theirs.txt", secret, parentId = folder.id)
|
||||
|
||||
val stranger = LocalVault(root, ByteArray(32) { 99 }, refs).also { it.load() }
|
||||
val result = stranger.remove(listOf(folder.id))
|
||||
assertEquals(listOf(folder.id), result.keptNonEmpty)
|
||||
assertEquals(2, stranger.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting something that is not there is not an error`() {
|
||||
vault.remove(UUID.randomUUID())
|
||||
assertEquals(LocalVault.Removal(), vault.remove(listOf(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
/** The removals reach disk, not just memory. */
|
||||
@Test
|
||||
fun `a delete survives a reopen`() {
|
||||
val a = vault.add("a".toByteArray(), "a.txt", open)
|
||||
vault.add("b".toByteArray(), "b.txt", open)
|
||||
vault.remove(listOf(a.id))
|
||||
assertEquals(listOf("b.txt"), reopen().list(null).map { it.name })
|
||||
}
|
||||
|
||||
// ---- The tree --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `descendants walks the whole subtree`() {
|
||||
val folder = vault.createFolder("Reports", open)
|
||||
val inner = vault.createFolder("2026", open, parentId = folder.id)
|
||||
val file = vault.add("x".toByteArray(), "q3.txt", open, parentId = inner.id)
|
||||
vault.add("elsewhere".toByteArray(), "other.txt", open)
|
||||
|
||||
assertEquals(setOf(inner.id, file.id), vault.descendants(folder.id).toSet())
|
||||
assertTrue(vault.descendants(file.id).isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A cycle cannot happen through this API, and the guard is here because
|
||||
* an index is a file on disk that something else could write. Without it
|
||||
* this is an infinite loop in a method a screen calls.
|
||||
*/
|
||||
@Test
|
||||
fun `a cycle in the index does not hang the tree walk`() {
|
||||
val a = vault.createFolder("A", open)
|
||||
val b = vault.createFolder("B", open, parentId = a.id)
|
||||
|
||||
val index = File(root, "index.json")
|
||||
val json = JSONObject(index.readText())
|
||||
val array = json.getJSONArray("nodes")
|
||||
for (i in 0 until array.length()) {
|
||||
val o = array.getJSONObject(i)
|
||||
if (o.getString("id") == a.id.toString()) o.put("parentID", b.id.toString())
|
||||
}
|
||||
index.writeText(json.toString())
|
||||
|
||||
val cyclic = reopen()
|
||||
// 🔴 Not merely "it terminated": each node once. A step-counter guard
|
||||
// would end the loop and hand a screen ten thousand copies of two ids,
|
||||
// which is what "delete this folder and everything in it" would then
|
||||
// offer to do.
|
||||
assertEquals(listOf(b.id), cyclic.descendants(a.id))
|
||||
assertEquals(listOf(a.id), cyclic.descendants(b.id))
|
||||
// And a bulk delete over it terminates rather than spinning in `depth`.
|
||||
cyclic.remove(listOf(a.id, b.id))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user