"Trust, but verify." —— Reagan, to Gorbachev.
From the first day of writing singlefs, a pile of problems sat in front of me. For example:
- singlefs's on-disk format is nothing like any other filesystem's, so existing verification standards can't simply be borrowed.
- singlefs's code gushes out amid the roar of AI, and human eyes reviewing it can't keep up with the pace.
- singlefs is written starting from the lowest layer, the transaction, and you can't tell whether the data in memory is right by telepathy.
- ……
So how do you prove the system isn't wrong?
Worse, in a filesystem it is easy to verify that something "was written", and hard to verify that something "was written wrong". The bytes you wrote read back just fine. Much later you may suddenly discover that the data was never actually stored — so what was it that got stored back then? Or every write goes through normally, the tests are all green, and one day after a power loss the disk never mounts again.
How do we prove we're right?
Luckily, singlefs has self-describing headers and a reverse index. Any block on disk can raise both hands and answer "who I am, whom I belong to, which generation I'm from". The data can account for where it went.
So let's start proving ourselves.
1. Reconciling against a real device: is what the disk received what the program wrote?
Data can state its own identity, but checking block by block is far too slow, and it can only check the result, not the process. So we need a rig that records the stream of data a transaction writes, has two unrelated parties each record the same process, and compares the two item by item. If they match, then at least what the program believes it wrote is what the disk actually received: nothing landed in the wrong place, and nobody quietly changed the order.
Concretely, we boot a QEMU VM with two virtual disks (virtio) and the write cache set to write back (like real hardware, writes go into the cache first). In front of each disk sits a layer of QEMU's built-in write-logging filter (blklogwrites). It stands outside the VM and logs every write (with its data) and every FLUSH.
The two recordings share not a single line of code:
- The program's copy: the program under test's own recorder, rerun on the host with the same arguments, producing the same bytes.
- The disk's copy: the device log QEMU recorded outside the VM.
The comparison rule lives in crates/singlefs-checker-tier/src/device_log.rs: the program's copy must be an item-by-item prefix of the disk's copy, and after the prefix only FLUSHes may follow (the system adds one on its own when the VM shuts down).
pub fn compare_allowing_trailing_flushes(
expected: &[DeviceEvent], // what the program thinks it sent
observed: &[DeviceEvent], // what the disk actually received
) -> DeviceLogComparison {
if observed.len() >= expected.len() && observed[..expected.len()] == *expected {
let tail = &observed[expected.len()..];
// once the prefix matches, any non-FLUSH event in the tail is a divergence
if let Some(position) = tail.iter().position(|event| *event != DeviceEvent::Flush) {
let index = expected.len() + position;
return DeviceLogComparison {
divergence: Some((index, None, Some(observed[index].clone()))),
trailing_flushes: position,
};
}
return DeviceLogComparison { divergence: None, trailing_flushes: tail.len() };
}
// the prefix itself doesn't match: report the first divergence
DeviceLogComparison {
divergence: first_divergence(expected, observed),
trailing_flushes: 0,
}
}
Matching alone isn't enough, because a gate that is always green is no gate at all. So we run six scenarios in total, two of which are deliberately broken control groups:
| Scenario | What it does | Expected |
|---|---|---|
direct |
Create a new pool and write the first file, bypassing the page cache and writing straight to disk | Green |
file-overwrite |
On top of that, overwrite once more | Green |
second-instance |
Then reboot, mount writable, and commit once more | Green |
raise-rollback-floor |
Commit once more, and raise the floor on "how far back you can roll back" | Green |
skip-new-pool-file-creation-barrier |
Deliberately drop one write barrier | Must be red, and red only on the first disk |
page-cache |
Deliberately go through the system page cache | Must be red (a 32K write and a 16K write get merged into one 48K write) |
There is also a third, independent path: the FLUSH count in the VM's /sys/block statistics must equal the number of barriers the program issued plus the number of FUA (forced-persistence) writes.
The point of the two control groups is to prove that when a barrier is dropped or writes are merged, this reconciliation can tell. If it said green even with a barrier missing, then "it matches" would prove nothing.
A QEMU bug, while we're here: when the number of data segments in a single write reaches 1023 or more, this write-logging filter exceeds the system's limit of 1024 segments per I/O. The host returns an invalid-argument error, the VM gets an I/O error, and formatting (mkfs) hits a wall. We patched QEMU: when over the limit, copy the data into one contiguous buffer first, then log it. The script that builds QEMU checks sha256 at every step.
This lets us prove: every write, barrier, forced persistence and discard the program issues reaches a real virtual block device intact and in the same order; merged writes and dropped barriers can both be told apart by the red/green controls.
What it can't prove: this gate does not simulate power loss. And a FLUSH on the virtual disk ultimately lands as a single sync call on the host; the cache of the host's own physical disk is out of reach. So what it verifies is the virtual block device layer, not the persistence semantics of a real disk.
2. Memory ordering: does the order hold?
Having shown the writes are fine, the next question is: is the order right?
An atomic commit in a COW filesystem ultimately rests on one ordering: write the content first, then write the pointer to it. If a reader sees the new pointer, it must be able to see the new content.
CPUs and compilers both reorder reads and writes for performance. Whether this ordering holds under the CPU memory model is left to herd7. herd7 is a memory-model checker, and we use the Linux kernel memory model (LKMM). Tests are written as small programs called litmus tests, kept under litmus/: 8 files in 4 pairs:
| Test | Bad outcome that must never occur |
|---|---|
commit-publish |
The reader sees the new version number but reads the old block content |
…-journal-implies-units |
The reader sees a journal record but can't read the data blocks the record names |
…-root-implies-units |
The reader sees the new root pointer but can't read the data blocks under the root |
publish-returns-after-the-rotation-barrier |
The commit has already returned success, yet the reader still can't see it |
The simplest one (litmus/commit-publish.litmus):
P0(int *blk, int *gen) // writer: commits a transaction
{
WRITE_ONCE(*blk, 1); // write the block content first
smp_wmb(); // write barrier
WRITE_ONCE(*gen, 1); // then write the version number pointing to it
}
P1(int *blk, int *gen) // reader: mount or concurrent read
{
int r0;
int r1;
r0 = READ_ONCE(*gen);
smp_rmb(); // read barrier
r1 = READ_ONCE(*blk);
}
exists (1:r0=1 /\ 1:r1=0) // bad outcome: saw the new version number, read the old block
It declares Never: herd7 must rule that this bad outcome cannot occur under any reordering.
This one file alone isn't enough. There are two more rules.
First, every "never occurs" gets a control group. commit-publish-nofence.litmus is the same code with both barriers removed, and it must be ruled "sometimes occurs". A script checks statement by statement that the control group is exactly the original minus its barriers. The reason is written in the control group's file header:
If this one is also ruled Never, the pattern can't happen in the first place, and commit-publish being ruled Never proves nothing.
This rule was learned the hard way: once, 10 unrelated "never occurs" tests all got through the gate on the strength of a single "sometimes occurs".
Second, bind them to the real code. Each litmus file header says which functions it models:
* singlefs-expect: Never
* singlefs-models: crates/singlefs-core/src/transaction.rs :: publish_first_file writer (P0)
* singlefs-models: crates/singlefs-core/src/recovery.rs :: choose_root reader (P1: pick the root first, then read units down the tree)
The named function must be found exactly once in that file. Another test parses the writer's steps out of the litmus file and compares them item by item with the commit order the program actually recorded. Change the code, and this check has to be redone.
So we've proven that at the CPU memory-model layer, the COW commit order holds: a reader will not see the pointer and then fail to read the content.
What it can't prove: herd7 deals with reordering between the CPU and memory, not the order in which things are persisted to disk. "The pointer has been persisted but the data hasn't" is the business of FLUSH and forced persistence, which belongs to the real-device reconciliation before and the crash point replay next. Also, the binding to the code only compares the writer's steps; on the reader's side it only checks that the function exists.
3. Crash point replay: one cut at every moment the power could fail
The order is fine too. So can we recover correctly after a power loss?
The idea is simple: record every write request to the block device, cut it off at every moment the power could fail, then remount, recover, and check whether the recovered disk is valid and consistent with the history.
It sounds unremarkable, but it is the most terrifying part of the whole project.
Record: run the write path all the way through once, recording every write, barrier and forced persistence.
Cut: each barrier or forced persistence cuts the write stream into segments. The disk at the moment of power loss = all earlier segments fully persisted + each write in the current segment either "landed / didn't land", in any combination. A write is only "protected" by a later barrier on its own disk; as long as one disk hasn't let it through, the segment isn't closed. An in-place overwrite has one more outcome than an ordinary write — half-written, so neither old nor new can be read. That is tearing.
How do you count the combinations? The comment in the code says it bluntly (crates/singlefs-checker-tier/src/crash.rs):
/// Each write in the segment takes any of its states, in any combination, minus the one where the whole segment persisted:
/// with m of the n writes being in-place overwrites, 3^m · 2^(n−m) − 1 states; when m = 0, any proper subset, 2^n − 1.
EveryProperSubset,
3^m · 2^(n−m) − 1
Each in-place overwrite has three outcomes — "didn't land, landed, torn" — and an ordinary write has two. Multiply them, then subtract the one where "everything landed". Exponential explosion.
Judge: for every power-loss state, run four things in order:
- Recovery, twice: once reading the journal, once not.
- Outcome check: is the recovered content one of the versions this history allows to appear?
- Pool-level checker: is the on-disk structure valid? (Covered in detail in part seven.)
- Record checker: are the writes of the root pointer and journal records really on disk, and is any data block missing?
The record checker deserves a word of its own. It does not go through the recovery code and does not parse the trees. It takes only four things — the disk before the power loss, the recorded write stream, the disk after the power loss, and which writes had persisted — and compares bytes. This path is a must: when we deliberately removed the barrier in front of the root pointer, the outcome check didn't flag a single state red; only the record checker went red, on exactly 1 state.
Scale:
- One full run on 2026-09-30: 7 items, two machines, five GPUs, exhaustive:
CRASH_AMPLIFICATION_TOTAL items=7 failed=0 states=2563727926 red=0 seconds=27288
2.56 billion power-loss states, 0 red, 7 hours 35 minutes.
- Today 47 crash cases are registered, plus 25 scaled-up items.
- The full run before a commit on 2026-10-25: of 38 cases, the first 6 ran to completion, 4.5 billion states in total, 0 violations, of which 3.0 billion directly reused earlier verdicts. It was then stopped, and the remaining 31 never ran.
That is why milestone three nearly tore this part down and rewrote it around pruning and reuse: without that, no number of hosts would be enough. To put it fancily: heterogeneous GPU computing and a reusable verdict library.
Heterogeneous GPU computing: split the work between CPU and GPU. The CPU records efficiently; the GPU judges. Measured: fully judging one state on the CPU (writable mount + pool-level checker) takes about 19.5 ms; on the GPU, about 4 µs — a gap of nearly five thousand times. So recovery, the outcome check, the record checker and the pool-level checker's 109 rules were written all over again, in another language with other algorithms: six GPU shaders (WGSL), about 23,000 lines in total. The GPU version must agree with the CPU version state by state and bit by bit, with dedicated cross-check tests. If a table won't fit in VRAM, it reports "can't judge" and the whole run stops; it never quietly falls back to the CPU.
Reusable verdict library: each block's verdict is stored by key in a long-lived library. The key layout (crates/singlefs-checker-tier/src/verdict_store.rs):
block key (72 bytes) = digest of this segment's writes and the starting disk image (32) ‖ plan digest (32) ‖ block start (8)
verdict key = block key ‖ judging-method fingerprint (32)
The "judging-method fingerprint" is computed like this: take every function used to judge this block, strip comments, fingerprint each one, add the compile switches, the toolchain version and the versions of the dependencies used, and digest them together. File paths, function names, line numbers and commit hashes never enter the key. So when you change one function, only the verdicts that use it need to be rerun; everything else is reused as is. We used to take the shortcut of using a digest of the whole library as the key; changing one character of the judging method invalidated everything, and we ended up re-judging an extra 3.9 billion states — function-level fingerprints were forced out of us that way.
On top of that, every run randomly draws a few already-judged entries from the library as sentinels, and forces one more to be re-judged. If a sentinel gets re-judged, or the forced re-judgement disagrees with the library entry, the reuse mechanism itself is broken, and this gate goes red immediately.
This lets us prove that for the write paths the tests cover, at the model level, a power loss at any moment can be recovered from correctly. But the volume is so large that full runs can only catch up in batches each milestone, or a little at a time when the machines are idle.
What it can't prove: it cuts the write stream the program recorded itself, assuming that recording is genuine — that part is backed by the first gate, the real-device reconciliation. Whether a real disk's FLUSH actually works is out of range. The fast tier we run day to day saves time by treating data blocks as either "landed whole" or "not landed at all", so it can't see the "data block only half landed" class; that's why commits still require the full run. And as of today, the full run hasn't been completed.
4. Model differential testing: no reference answer, so build one
Everything above only proves that the program operates according to its own understanding. But is that understanding itself right?
We wrote an ideal filesystem in memory that cares only about semantics — not performance, and not crashes: crates/singlefs-harness/src/model.rs, 13,535 lines. It lives in memory, so it runs stably and its answers are unambiguous.
Then the same random sequence of operations drives both the ideal model and the real implementation at once (the implementation runs on an in-memory test disk). Before calling the implementation at each step, we ask the model: should this step "succeed", "be refused", or is "anything within some range" acceptable? The model's answer looks like this:
/// The model's answer for one step.
pub struct ModelAnswer {
pub operation: ModelOperationKind,
/// Reasons it must be refused: if non-empty, it must be refused
pub required_refusals: BTreeSet<ModelRefusalReason>,
/// Roots that should be written on success, in order
pub expected_roots: Vec<ModelRoot>,
/// What should be read back after a cold start
pub expected_read_back: Option<ModelReadBack>,
/// When raising the rollback floor, what the ceiling should be
pub rollback_floor_ceiling: Option<ModelCheckpointTxg>,
// ……
}
When the implementation finishes, its result is translated and compared item by item against the model's answer. Disagreements fall into 28 kinds: should have succeeded but refused, should have refused but succeeded, wrong refusal reason, secretly wrote to disk before refusing, wrong roots written, wrong file content, wrong read-back after a cold start…… Any error the model has never seen is recorded as "unexplained" and not let go.
There are currently 20 kinds of random operations, with 16 different mixes. The most commonly used mix, when "mounted, with files present", is: overwrite 52, raise the rollback floor 16, roll back 10, mount 6, delete a file 3, crash recovery that discards the newest version 3.
Two design points worth mentioning:
- Generation and execution are separate: at generation time each step records only "what to do" and a selector; the concrete arguments are computed from the disk state at the moment of execution. So after a failure, deleting earlier steps leaves the later ones still meaningful, and the case can be shrunk all the way down to a minimal reproduction.
-
Seeds are never redrawn: the starting point of the random seeds is hard-coded as the constant
7_463_871_032_432_355_113. To test more, new ranges are appended after it; old seeds stay reproducible forever.
Only when the model and the implementation agree step by step can we say our semantics — that is, the intent of each operation — was faithfully carried out by the implementation.
There is also a smaller RefFS (reference filesystem, crates/singlefs-reference-filesystem). It has no external dependencies at all, uses only the Rust standard library, and walks through POSIX operations in memory. It answers just one question: is the content read back after a power loss within the allowed range?
/// States recovery may read back after a crash: {S_j : j = lo, or j is exactly the end of some explicit publish or rollback, lo ≤ j ≤ hi}, in ascending index order.
pub struct AllowedSet {
pub lower_bound: StateIndex,
pub upper_bound: StateIndex,
pub allowed: Vec<AllowedState>,
}
In plain words: between "the last version known to be persisted" and "the moment of power loss", the version right after each explicit commit may be what recovery produces; nothing else may. The ideal model handles "what it should be without a power loss"; RefFS handles "what may be seen after a power loss". They split the work.
What it can't prove: we wrote the model, and we wrote the implementation, and both understand the semantics from the same design documents. If both misunderstand together, both go green together. Also, right now we only check that the differential testing "ran", not that every kind of operation was actually "reached" by it; a newly added write path may never have been differentially tested for a single step.
5. Formal verification: mathematics doesn't lie
But what if one of the most critical judgements in the implementation is wrong precisely on inputs that random operations will never hit?
Tests can only try finitely many inputs. Formal verification is different: first write down, in mathematics, the properties this code must satisfy (the spec), then have a prover prove that the code satisfies them for every possible input. If it can't be proven, the code isn't accepted.
We prove Rust code with Verus. Take replay_core (crates/singlefs-replay-prefix/src/lib.rs), the core function that, during recovery from a power loss, "takes the replayable stretch out of the journal". Its signature carries preconditions (requires) and conclusions (ensures):
pub fn replay_core<...>(
root_instance: PrefixKernelInstanceGeneration,
root_checkpoint_txg: PrefixKernelCheckpointTxg,
chosen_root_source: RootSource,
records: &[CoreRecord<RootSource>],
// ……
) -> (replayed: Result<CoreOutcome<RootSource>, CoreFailure>)
requires
records_ascend_by_instance_and_counter(records@), // precondition: journal records are in order
every_counter_has_a_successor(records@),
ensures
replay_follows_the_prefix_rules(...), // which stretch is taken follows the rules
replaying_an_applied_record_twice_leaves_the_root_as_once(...), // replaying the same record twice equals once
replaying_from_the_landed_root_is_a_fixed_point(...), // replaying again from the result changes nothing
// and one more: the replay result is never older than the starting point
Two shorter ones:
// Whether a record may be reclaimed is this one comparison; not one character more or less
pub fn released_record_can_be_reclaimed(released: bool, generation: u64, floor: u64) -> (answer: bool)
ensures answer == (released && generation <= floor)
// Losing power twice during garbage collection gives the same result as losing it once
run_ring_next(run_ring_next(state, RunRingStep::Crash), RunRingStep::Crash)
== run_ring_next(state, RunRingStep::Crash)
The key point: what is proven is exactly the code that runs, not a separately written model. The recovery path calls these functions directly; in a normal build, Verus's spec and proof parts are erased, and the remaining code is compiled into the product as usual. To keep "the version that was proven" and "the version that runs" from quietly drifting apart, the intermediate code produced by the two builds is also compared item by item and required to be identical.
All the proven code lives in one crate, crates/singlefs-replay-prefix, which depends only on Verus's standard library and has four files:
| File | Lines | Proof fns | What it proves |
|---|---|---|---|
lib.rs |
4342 | 25 | The three steps of recovery from power loss: take the replayable stretch from the journal, choose one of several candidate roots, scan the journal ring to build a table |
compaction_plan_proofs.rs |
1588 | 16 | During space compaction, which stretch to choose and how to move it |
gc_ring_interval_proofs.rs |
1762 | 20 | A protocol model of the garbage collector's relocation process |
gc_crash_proofs.rs |
167 | 2 | That comparison before reclaiming |
113 proof obligations in total, and zero occurrences across the four files of back-door constructs like "assume it holds" or "skip the proof".
But formal verification isn't almighty. Its most insidious failure isn't "can't prove it" — it's proving it while proving nothing. So four more things are guarded:
One, the vacuity check. If a precondition can never hold, anything can be "proven" from it. So for each precondition, an assert(false) is inserted at the top of the function, one separate copy per location, and the proof must fail. If it passes, the precondition contradicts itself, and that proof goes red.
Two, must-red worlds. 231 deliberately broken locations are registered. After breaking each one, Verus must report an error, and the error must land on the designated function. For example:
break: negate the conclusion
original: is_last_record == is_last_record_of_its_publish(*record),
mutated: is_last_record == !is_last_record_of_its_publish(*record),
expect: 7 proof errors
Only a proof that can show it goes red counts as a proof.
Three, a pinned toolchain. The Verus version, the versions and content fingerprints of the six packages it depends on, and the Rust compiler version — if any one doesn't match, it turns red immediately.
Four, a pinned table of exhaustive counterexamples. We ran a separate experiment that exhaustively enumerates the real journal-replay function within a bounded size: about 49.75 million valid inputs and about 64.51 million adversarial inputs. Every counterexample found is pinned into a table exactly as is; it has 360 rows now. On every commit, the current code replays it row by row.
I have to admit formal verification is expensive, and to this day it covers only a few of the most central judgements: which stretch to take from the journal, which root to choose, scanning the journal ring, the compaction plan, the reclamation threshold. How on-disk bytes get parsed into these functions' inputs, disk barriers, encoding and decoding, checksums, multiple disks, integer overflow — none of that is in the proof. The README in that code directory says so itself: "this cannot be called a complete crash-recovery proof of the whole filesystem".
One more thing must be made clear: Verus proves "the code conforms to the spec"; it cannot prove "the spec itself is right". If the spec is wrong, the proof still goes through. Whether the spec is right depends on the reasoning in the design documents, and on the earlier gates corroborating one another.
6. Mutation: judging the judges
So, could the verification itself be fake? Tests that don't reach the code, assertions written backwards, sample points that happen to fall where two variants give the same result — in all these cases the tests are still all green, but they judge nothing.
The idea is simple: deliberately break the code and see whether verification turns red. If it turns red, that check can tell this kind of error apart. If it stays green after you break things, that check is decoration.
Concretely, there are two things.
One, every bug fixed leaves a landmine behind. crates/mutations.tsv has 5,981 rows in total. Each row says which file to change, which original text to replace with what, which tests to run, and which test must go red. For example:
name : free count not decremented after allocation (the space-accounting check must go red)
file : crates/singlefs-core/src/allocator.rs
original : self.free_slots -= span;
mutated : // mutation: free count not decremented
must fail : cold_start_reads_the_second_content_and_the_pool_checker_stays_green
The table header records where it came from: every time a round of adversarial review finds a problem and it gets fixed, a mutation is left behind that "goes red if you revert it"; if the fix is quietly undone, this goes off first. Some day someone (most likely some agent) quietly reverts a fix, and the landmine goes off first. The original text must be found exactly once in the file; if the code changes and the original text can no longer be found, that row is recorded as "anchor lost", which is also red.
Two, broad mutation. Landmines are planted by people, so there are always places nobody thought of. There's therefore also a tool that generates mutations automatically (scripts/mutation/broad-mutation.py, using cargo-mutants under the hood): flip a comparison operator, add or subtract one from a constant, delete a statement…… Counted once on 2026-09-30, it could generate 1,765 mutations from the checker and 3,326 from the core code. Every one must reach a verdict, and a single survivor blocks the commit. A few details:
- Timing out or hitting the memory cap does not count as caught, unless it's rerun once with a bigger budget, times out both times, and the evidence is complete.
- Verdicts are reused by function fingerprint: if the test that caught a mutation hasn't changed and the function containing it hasn't changed, it isn't rerun.
- Every row in the verdict library carries a signature computed with a key kept outside the repository. What it guards against isn't hackers; it's an agent casually editing one line of a verdict.
The "must-red worlds" from the previous gate follow the same idea: break the proof, and the prover must report an error.
When a mutation survives, the laziest move is to write "these two variants are equivalent anyway" and walk away. The project's rules forbid that and split "survived" into eight categories. The most commonly abused one is this: it only happens to give the same result on the few test inputs I picked; change the input and it differs. In that case you must add an input; you may not fudge it as equivalent. The criterion is one sentence:
When a mutation isn't caught, ask once: with a different sample point, is it still the same value?
What it can't prove: mutation proves that "assertions can go red", not that "coverage is complete". Breakages not written in the table will never be tried, and the kinds of mutations that can be generated automatically are limited too.
7. The pool-level checker: pick up any disk
Finally: pick up any disk — how do you decide whether it's a valid singlefs disk?
That's the pool-level checker. One thing first: it is not something that runs at mount time. Mounting goes through the core's own recovery and validation code; the checker is an independent auditor, called from tests, crash point replay and the GPU cross-checks. Mounting and auditing each judge the same thing once, with two separate bodies of code.
There is only one entry point (crates/singlefs-checker/src/walk.rs):
pub fn check_pool_image(reader: &dyn ImageReader) -> Vec<(&'static str, InvariantVerdict)>
The input is bytes only: ImageReader gives just "how many disks, how big each is, and read some bytes at some position on some disk". It touches no runtime memory state at all. The output is one verdict per rule:
/// The verdict of one invariant on one image. "Not applicable" must carry a reason; it may never silently count as holding.
pub enum InvariantVerdict {
Holds, // holds
Violated(String), // violated, saying where
NotApplicable(&'static str), // not applicable, reason required
PartiallyJudged(String), // everything judged is right, but part couldn't be judged: not red, not holding either
}
It first reads the system configuration disk by disk, then picks the newest valid root, scans the journal and replays that version, and then walks the disk once in each of two directions:
- Follow the references: starting from the root, walk every tree — the inode tree, the deletion records, the snapshot lineage and the rest — all the way to the leaves.
- Scan block by block: ignoring who references whom, read the self-describing header of every block on disk one by one and rebuild the whole index.
The indexes built from the two directions must be exactly identical. Finally, the space usage found by the scan is reconciled, disk by disk, against the accounts recorded on disk.
The list of rules the checker judges lives in crates/singlefs-checker/invariants/: 109 implemented, matching IMPLEMENTED_INVARIANTS: [&str; 109] in the code one to one. A few of them:
- The identity recorded in each block's header must match the place that references it. This is the opening's "any block, picked up on its own, can answer who I am, whom I belong to, which generation I'm from", turned into code.
- Besides the newest valid root, there must be at least one older valid root. If not, it's red, because that means "the next bad write leaves no way back".
- The space the accounts call "allocated" must equal the space computed by actually following all the references.
- Per disk, "allocated − pending release" must equal the number of blocks the newest version actually occupies. It reads the accounts on disk, not the allocator in memory.
Every implemented rule comes with a broken disk that was "deliberately damaged to trigger exactly that rule".
The pool-level checker may not share a single line of logic with the core code; it shares only one module of format constants. Its Cargo.toml has exactly one dependency:
[dependencies]
singlefs-format = { path = "../singlefs-format" }
That format module isn't even allowed to contain if, match or for; it holds only constants. The design discipline has this line:
What is forbidden is not "traversal". It is two things: "not yet having the answer at the moment you need it", and "the auditor and the audited using the same code". The runtime and the checker must use different algorithms.
If the two shared a piece of parsing code and that code was wrong, both would be wrong the same way, and the reconciliation would still be all green. So the gate holds the line strictly: apart from the format constants, the two modules' dependencies may not overlap at all; and the functions called by the "follow the references" direction and the "scan block by block" direction may overlap only in format parsing and constants.
The checker is also a specification: it defines "what this format actually is". The format of all of singlefs is defined by the checker's source code; the documentation and everything else take it as the reference.
What it can't prove: it only looks at one static disk. Not a single encryption-related rule is implemented yet, and the rule that "forward references and the reverse index agree with each other" is still on the to-do list. The rules say it outright: "the checker reported nothing" does not mean "the disk is healthy". Errors not written into the list are invisible to it.
Finally
Every gate has its own shortcomings, and none of them can guarantee the code is correct on its own. But they back each other up:
- Formal verification proves the most central judgements but not the glue code around them → crash point replay and the pool-level checker cover the glue.
- Crash point replay assumes the recorded write stream is genuine → the real-device reconciliation records another copy from outside the door.
- herd7 watches CPU reordering, not the order of persistence to disk → the real-device reconciliation and crash point replay can catch that.
- Drop one barrier and the outcome check can't see it → the record checker can.
- The ideal model and the implementation might misunderstand together → RefFS is a new mirror.
- Any gate could stay green forever → control groups, must-red worlds, vacuity checks and the mutation tables each prove, gate by gate, that they can go red.
The system these gates form is like the Berlin Wall: it's not tall. Want to get over it? Big Brother is watching you.
How would you prove this wall is solid, Comrade Gorbachev?
Top comments (0)