DurableStoreFilenameConformanceSuite
The contract every file-backed DurableStore must satisfy about the filenames it addresses entries by. Subclass and implement newDirectory, newStore and plantRawFile.
class SqliteFileStoreFilenameTest : DurableStoreFilenameConformanceSuite<File>() {
override fun newDirectory(): File = freshTempDir()
override suspend fun newStore(dir: File): DurableStore = SqliteFileStore(dir)
override fun plantRawFile(dir: File, name: String, bytes: ByteArray) =
File(dir, name).writeBytes(bytes)
}Why this is a suite and not two copies of six tests
#2506 existed because two independently written filename mappings had to agree by inspection and did not: Regex("[^a-zA-Z0-9_-]") on JVM folded every Cyrillic letter onto _, while Char.isLetterOrDigit() on Apple did not. #2511 fixed the mappings by sharing one encoder — and left the properties that verify them duplicated by hand, six of them, verbatim, in each file backend's own test file. A third file backend inherits none of those, so its author re-derives the six by hand: the identical structural setup, one level up (#2515).
That is the standing question "after fixing anything, ask what the fix itself is now unpinned on", and this suite is the answer for #2511.
What subclasses this, and what deliberately does not
Only a backend that puts a key on a filesystem. InMemoryDurableStore and IndexedDbDurableStore have no filenames, so they do not subclass this at all — which is a different and more honest answer than a nullable plantRawFile returning null to mean "not applicable". See plantRawFile for why that distinction is load-bearing.
This suite is additive to DurableStoreConformanceSuite, not a replacement: that one is where read/write/delete/restart live, stated against the interface and therefore runnable by every backend. A file backend runs both, as two test classes.
The knobs, and what each of them switches off
A fixture's configuration is a prescription too, and it drifts toward the setting where the property cannot fail. This suite has no numeric budget to get wrong, but it has four choices that are just as capable of switching a property off, so they are named here rather than left implicit:
Every entry carries a distinct byte value. A fold is invisible if the two entries it merges hold the same bytes: the read comes back with what was expected and nothing is wrong. Every write and every planted file below carries a value nothing else in that test uses, so a fold names which entry it destroyed rather than only that something is off.
Reads go through a SECOND store handle, opened on the same directory after the writes. A backend caching entries in memory would answer every read from the cache, and a fold that happened on the medium would be invisible to a suite that never left the writing handle. The in-tree backends hold no such cache; a future one might, and the cost of not relying on that is one extra newStore call.
The planted names are LEGACY names, not encoded ones. A name the current encoder can still produce would be reachable through DurableStore.write, which makes plantRawFile redundant and the property a restatement of a round trip. Every name planted below is one the encoder can no longer emit — that is the whole reason a raw-file hook has to exist.
newDirectory is called per property, not per class. Properties assert absence, and an absence assertion against a directory a previous property wrote into is reporting on test ordering. The properties that can cheaply check it assert their own emptiness precondition.
What this suite cannot detect, and where
Case. Two of the properties here concern a filesystem that compares names case-insensitively — APFS by default, exFAT, NTFS; not ext4, which is what a Linux CI runner almost always uses. Read plainly:
keysDifferingOnlyInCaseAddressDistinctEntries is unconditional about the store's own behaviour (a
lowercase()anywhere on the way to a filename reds it on every filesystem) and says nothing at all, on a case-sensitive runner, about the filesystem half of the same defect. Undoing the encoder's uppercase escaping reds it only where the filesystem folds.theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne measures which filesystem it is running on and asserts the documented outcome for that filesystem, in both arms. It deliberately does not skip: a skipped test and a silently-passing one are the same colour in a green run, and the case-sensitive arm has real content — it asserts the residual is absent there.
So a green from a Linux runner is worth strictly less than a green from a macOS one, and the difference is exactly the second failure mode of those two properties. The target-independent statement of the same boundary lives in :kuilt-store's own StoreKeyFilenameTest, which decides it from the encoded strings and needs no filesystem at all — but that test is a model of a case-folding filesystem, and this suite is the only place the model is checked against a real one.
Which lane executes which arm. ci-required's jobs all run on ubuntu-latest, so the only binding of this suite that executes there is the JVM/Android one, on a Linux filesystem — the case-sensitive arm. The Apple binding executes in apple-nightly.yml (a macos-latest runner, 0 7 * * * plus workflow_dispatch), which is deliberately not a required check: that workflow's header records why, and it is a concurrency ceiling rather than a judgement about this code — GitHub caps a plan at five concurrent macOS jobs, and an Apple lane on every PR would sit at that cap and queue every merge behind it.
The consequence to hold on to: a green PR check is not evidence about the case-folding arm. A regression in it merges, and the nightly off main catches it within about a day, opening or refreshing a tracking issue. Nothing here is unrun — it is run late, off the gate, which is a weaker thing than a required check and a stronger thing than "only on a developer's machine".
Note the suite never assumes any of this: it measures the filesystem it is on and asserts the arm that measurement licenses, so a lane whose filesystem is not what this paragraph expects still gets a correct verdict. What the paragraph governs is how much a given green is worth, not whether it is sound.
Containment. Nothing here establishes that a write landed inside the store's directory. DurableStore exposes no root and no listing, and plantRawFile is a fixture hook rather than an observation of the medium, so a write that escaped and a write that was contained read back identically. Each backend's own tests are where containment is provable.
Durability. Every read here happens in the same process as the write it follows, so the operating system's page cache satisfies it whether or not the bytes ever reached a device. See DurableStoreConformanceSuite for the same residual stated at length.
Mutation receipts
Measured over :kuilt-store:jvmTest --tests "*Filename*" (21 tests: this suite's 7 bound by FileChannelDurableStoreFilenameTest, plus StoreKeyFilenameTest's 14 encoder guards), with --no-build-cache, the results XML deleted before every run and the log grepped for compile errors — a mutation that does not compile leaves Gradle serving the previous run's XML, which reads as a verdict and is stale. Each applied alone and reverted, the revert verified with git status. Only this suite's reds are listed; the encoder guards red widely and are #2511's receipt, not this one's.
"APFS" marks a red that depends on the filesystem. Every row was re-measured on a case-sensitive APFS volume created for the purpose, which is what a Linux CI runner behaves like; the two rows where the verdict differs say so.
| Mutation | Reds in this suite, at assertion granularity |
|---|---|
isSafe: put _ back in the safe set | aKeyNeverAdoptsAnotherKeysLegacyOrphan both absence assertions; theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne its otel_logs assertion |
isSafe: put A–Z back in (undo #2511's uppercase escaping) | theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne on the Config assertion, on both filesystems; keysDifferingOnlyInCaseAddressDistinctEntries only on APFS — green on the case-sensitive volume. That asymmetry is the reason the Config assertion exists |
isSafe: put . back in | anEntryNeverLandsOnAnotherEntrysTempSidecar on "x.tmp" survived x's write — and nothing else here |
encodeStoreKeyName: lowercase() the name first | keysDifferingOnlyInCaseAddressDistinctEntries on "a", on both filesystems — the fold is the store's own; theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne Config only on APFS. The mirror image of the row above, which is why neither property replaces the other |
encodeStoreKeyName: fold every byte ≥ 0x80 onto one escape | keysDifferingOnlyInANonAsciiLetterAddressDistinctEntries on "мир" — and nothing else here |
isSafe: take - out of the safe set | aKeyAlreadyInsideTheSafeSetStillFindsItsOwnFile on "span-state" — and nothing else here |
encodeStoreKeyName: the whole fix deleted — the legacy JVM sanitiser restored verbatim | keysThatFoldedOntoOneFilenameAddressDistinctEntries 4 of 5; keysDifferingOnlyInANonAsciiLetterAddressDistinctEntries; keysDifferingOnlyInCaseAddressDistinctEntries; aKeyNeverAdoptsAnotherKeysLegacyOrphan both; theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne 4 assertions. aKeyAlreadyInsideTheSafeSetStillFindsItsOwnFile and anEntryNeverLandsOnAnotherEntrysTempSidecar stay green, correctly — the legacy scheme is the identity on spans/span-state and maps x.tmp to x_tmp, so neither failure is present |
FileChannelDurableStore.write: append straight to the destination, no temp file, no rename | anEntryNeverLandsOnAnotherEntrysTempSidecar on "x" ([1, 3] where [3] was written) — and nothing else here. The receipt that this property still bites after moving into a suite |
FileChannelDurableStore: ignore us.tractat.kuilt.store.StoreKey filename and re-derive its own sanitiser | keysThatFoldedOntoOneFilenameAddressDistinctEntries 4 of 5; keysDifferingOnlyInCaseAddressDistinctEntries; aKeyNeverAdoptsAnotherKeysLegacyOrphan both; theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne 4. Zero encoder guards red — this is the row that justifies the suite existing: a backend re-deriving its own mapping is invisible to every test that checks the encoder in isolation. keysDifferingOnlyInANonAsciiLetterAddressDistinctEntries stays green because isLetterOrDigit() is true for Cyrillic, which is the historical fact of #2506 |
| Fixture: plantRawFile does nothing | aKeyNeverAdoptsAnotherKeysLegacyOrphan on its control precondition; aKeyAlreadyInsideTheSafeSetStillFindsItsOwnFile both; theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne on its probe precondition. No absence assertion is reached — the preconditions fire first |
| Fixture: newStore ignores the directory it was handed | all seven, and the three plant-dependent ones on their preconditions |
| Fixture: newDirectory hands back one directory shared by every property | nothing. See below |
| Fixture (positive control): newDirectory hands back a directory a previous run left entries in | keysThatFoldedOntoOneFilenameAddressDistinctEntries, keysDifferingOnlyInANonAsciiLetterAddressDistinctEntries, keysDifferingOnlyInCaseAddressDistinctEntries and anEntryNeverLandsOnAnotherEntrysTempSidecar, each on its own freshness precondition, each naming its own key |
| Suite: flip the measured case-folding branch | theLegacyOverlapACaseFoldingFilesystemExposesIsExactlyTheDocumentedOne on the arm only — confirming which arm a given box takes rather than leaving it inferred. This row is also what found the precondition bug it now cannot reproduce: written as `caseFolding |
The row that reds nothing is the one worth reading. A fixture handing back one shared directory for every property moves no assertion at all, and the two rows below it are why that is a fact about the suite rather than a dead assertion: the freshness precondition is demonstrably live (the positive-control row reds four properties on it), and the shared-directory fixture is simply not a defect here, because every property uses a key set disjoint from every other's and plants immediately before it reads. So what newDirectory's freshness buys is protection against a dirty directory — a temp root that outlives the process, most of all, which is exactly the hazard the Apple fixture's own helper is built around — and not against sharing. Adding a property whose keys overlapped another's would change that, and is deliberately not done for the sake of a redder table.
Type Parameters
whatever this backend calls a directory — java.io.File on JVM/Android, a path String on Apple. Opaque to the suite: it is created by newDirectory and handed straight back to newStore and plantRawFile, so no path-string convention is imposed on a backend.
Functions
The one case where reading a legacy file is correct: the key was already inside the safe set, so both schemes are the identity on it and the "orphan" is that key's own file. spans and span-state carry over for free.
A file left behind by the legacy scheme must never be readable as a different key.
An entry's filename can never equal another entry's .tmp sidecar.
Two keys differing only in a non-ASCII letter.
Two keys differing only in case.
Five distinct keys the legacy filename mappings folded onto one file.
The legacy/new disjointness re-asked under the equality a case-insensitive filesystem actually uses — measured against a real filesystem rather than modelled.