The local Files vault in Kotlin #78

Merged
paulhitt merged 1 commits from feat/files-local-vault into main 2026-09-04 20:49:56 +00:00
paulhitt commented 2026-09-04 19:14:06 +00:00 (Migrated from gitlab.com)

The index, folders, both ways of holding a file, and the delete
semantics — everything iOS's LocalVault does, minus the screen.

One deliberate divergence, and it is a fix rather than a port.
descendants(of:) on iOS bounds a cyclic index with guardRail < 10_000.
That terminates and then returns ten thousand copies of the same two ids
— and the screen calling it is the one offering "delete this folder and
everything in it", so a corrupt index turns that into an offer to delete
10,000 things. Bounding by what has already been visited terminates AND
returns the right answer, with no magic number. Filed as ios#66.

The test that found it writes a real cycle into a real index. It matters
because the index is a file on disk: the input is not only ever our own
writes.

The index file is device-local and is NOT an interchange format. The
node FIELDS match iOS's exactly so a courier transfer stays a copy rather
than a translation; the JSON around them does 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 promote an accident
to a format. Anybody building vault migration should move nodes through a
declared wire shape, not by copying this file.

ReferenceResolver is required with no default. A no-op default would
make every reference row read as "missing" in an app that simply forgot to
wire it up — a vault that looks like it has lost half its contents, with
nothing anywhere saying why.

Two tests worth naming:

  • Nothing on disk contains the plaintext. A round trip passes just as
    happily if add wrote the file out unencrypted and read it straight
    back, so the bytes are searched for across the whole container, and the
    index is checked for the filename.
  • Deleting a reference row does not reach the person's storage at all.
    Asserting only that their file survived would be a test that cannot
    fail: the delete path holds no call to remove it, and the protection is
    structural — the resolver has no way to delete anything. Asserting zero
    resolver calls is the property that constrains future code, and it was
    verified by routing the delete path through the resolver and watching it
    go red.

29 tests. Each guard proved by breaking the code: the label key salted
with the level alone, a node key wrapped to its parent's label, and a bulk
delete that ignores unselected survivors each fail exactly the test that
names them.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

The index, folders, both ways of holding a file, and the delete semantics — everything iOS's `LocalVault` does, minus the screen. One deliberate divergence, and it is a fix rather than a port. `descendants(of:)` on iOS bounds a cyclic index with `guardRail < 10_000`. That terminates and then returns ten thousand copies of the same two ids — and the screen calling it is the one offering "delete this folder and everything in it", so a corrupt index turns that into an offer to delete 10,000 things. Bounding by what has already been visited terminates AND returns the right answer, with no magic number. Filed as ios#66. The test that found it writes a real cycle into a real index. It matters because the index is a file on disk: the input is not only ever our own writes. The index file is device-local and is NOT an interchange format. The node FIELDS match iOS's exactly so a courier transfer stays a copy rather than a translation; the JSON around them does 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 promote an accident to a format. Anybody building vault migration should move nodes through a declared wire shape, not by copying this file. `ReferenceResolver` is required with no default. A no-op default would make every reference row read as "missing" in an app that simply forgot to wire it up — a vault that looks like it has lost half its contents, with nothing anywhere saying why. Two tests worth naming: - **Nothing on disk contains the plaintext.** A round trip passes just as happily if `add` wrote the file out unencrypted and read it straight back, so the bytes are searched for across the whole container, and the index is checked for the filename. - **Deleting a reference row does not reach the person's storage at all.** Asserting only that their file survived would be a test that cannot fail: the delete path holds no call to remove it, and the protection is structural — the resolver has no way to delete anything. Asserting zero resolver calls is the property that constrains future code, and it was verified by routing the delete path through the resolver and watching it go red. 29 tests. Each guard proved by breaking the code: the label key salted with the level alone, a node key wrapped to its parent's label, and a bulk delete that ignores unselected survivors each fail exactly the test that names them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paulhitt commented 2026-09-04 19:14:06 +00:00 (Migrated from gitlab.com)

assigned to @paulhitt

assigned to @paulhitt
paulhitt commented 2026-09-04 20:49:54 +00:00 (Migrated from gitlab.com)

deleted the feat/files-engine branch. This merge request now targets the main branch

deleted the `feat/files-engine` branch. This merge request now targets the `main` branch
paulhitt commented 2026-09-04 20:49:56 +00:00 (Migrated from gitlab.com)

mentioned in commit 3615f314b9

mentioned in commit 3615f314b906651e464241a7cc2f39506b08e729
paulhitt (Migrated from gitlab.com) merged commit 3615f314b9 into main 2026-09-04 20:49:56 +00:00
paulhitt commented 2026-09-09 19:01:28 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
Sign in to join this conversation.