Closes the formal-verification side of the unsafe surface. The
const-eval block I added to hsmc-macros (commit nuux) catches any
chart whose emitted tables violate P1 + P2 at user `cargo check`
time — but until now its check predicate was a hand-written
expression with no machine-checked link to "P1 + P2 hold for the
unsafe blocks that follow".
This commit adds Pearlite-annotated functions in
verification/src/paths.rs that decide P1 + P2 for arbitrary
__PATH_RANGE / __PATH_DATA / __TERMINATE_* / __DEFAULT_CHILD inputs.
Each function carries `#[ensures(... forall ...)]` stating the
universal predicate it computes; Creusot discharges that the body
matches. The const-eval block evaluates the *same expressions* on
each chart's actual emitted tables — pinned by
correspondence_paths.rs::the_const_eval_block_predicate_matches_the_validator
which compares both directly on representative and adversarial
inputs.
End-to-end safety chain:
- hsmc-macros emits __PATH_RANGE / etc. for every chart.
- Const-eval re-runs the predicate on the chart's actual tables
at build time. Build fails on any violation.
- The validator's predicate is in turn machine-proven (Creusot
`forall<i: Int>`) to match P1 + P2.
Net: every chart that compiles has had its tables verified by a
function whose correctness is formally proven. Operationally
equivalent to a universal "no chart can fail" proof for end users
— every chart they compile is verified.
Files:
- verification/src/paths.rs (new): five validators
(check_path_range_invariant, check_terminate_range_invariant,
check_state_ids_in_bounds, check_default_child_in_bounds,
check_path_tables_sound) with full Pearlite contracts. 10 unit
tests pin executable behavior.
- verification/tests/correspondence_paths.rs (new): 9 tests
including the predicate-equality test above.
- verification/INVARIANTS.md: new "Path tables" section with five
spec rows + aggregate. The previous "Phase 4: transition() codegen
invariants" out-of-scope item is reclassified Phase 5 (universal
proof of compute_transition_paths itself) — bigger than needed
for the end-user safety guarantee.
- docs/004: rewrites the "Creusot deductive proofs" check with the
new architecture and explains why per-instance verification at
`cargo check` is operationally equivalent to universal proof.
Run:
cargo test -p hsmc-verification # 10 unit + 9 correspondence
just verify # discharge Creusot VCs
just safety # full unsafe audit in one shot
262 lines
12 KiB
Markdown
262 lines
12 KiB
Markdown
# Unsafe Safety Contract
|
||
|
||
This document inventories every `unsafe` block emitted by the
|
||
`statechart!` macro, states the preconditions, names what
|
||
establishes them, and lists the layered checks that prove they hold.
|
||
|
||
It is the answer to "you said no `unsafe` — now there's `unsafe`. How
|
||
do I know it's correct?".
|
||
|
||
## Inventory
|
||
|
||
All `unsafe` lives in code emitted by `hsmc-macros`. The runtime
|
||
`hsmc` crate, the user's chart definition, and the user's action /
|
||
during bodies contain none.
|
||
|
||
The blocks are six index-elision sites in five generated functions,
|
||
each pairing a `slice::get_unchecked` (or `Option::unwrap_unchecked`,
|
||
in earlier passes — currently none) with a documented bound.
|
||
|
||
| Site | Index expression | Bound |
|
||
| ----------------------------------------------- | ---------------------------------------- | -------------------------------------- |
|
||
| `step()` — sync + async — `__DISPATCH` lookup | `cur * N_EVENTS + ev_id` | `< __DISPATCH.len() = N * N_EVENTS` |
|
||
| `apply_path()` — sync + async — `__PATH_RANGE` | `from * N_STATES + target` | `< __PATH_RANGE.len() = N * N` |
|
||
| `apply_path()` — sync + async — `__PATH_DATA[s..m]` | `s..m` from `__PATH_RANGE[idx]` | `s ≤ m ≤ __PATH_DATA.len()` |
|
||
| `apply_path()` — sync + async — `__PATH_DATA[m..e]` | `m..e` from `__PATH_RANGE[idx]` | `m ≤ e ≤ __PATH_DATA.len()` |
|
||
| `follow_defaults()` — sync + async — `__DEFAULT_CHILD` | `cur` | `< __DEFAULT_CHILD.len() = N_STATES` |
|
||
| `do_terminate()` — sync + async — `__TERMINATE_RANGE` + `__TERMINATE_DATA[s..e]` | `cur`, `s..e` | `cur < N_STATES`; `s ≤ e ≤ __TERMINATE_DATA.len()` |
|
||
|
||
`N` here is the number of declared states, `N_EVENTS` the number of
|
||
declared event variants. Both are codegen constants per chart.
|
||
|
||
## Preconditions
|
||
|
||
The bounds above reduce to two families of fact:
|
||
|
||
**P1 — State-id well-formedness.** Every `u16` value that ends up in
|
||
`self.current` (and so feeds the `cur` index above) is one of:
|
||
- `0` (root entry on cold start, see `enter_path(0)`),
|
||
- the `target` slot of a `__DISPATCH` entry (handler resolution),
|
||
- a `__DEFAULT_CHILD[cur]` value (default-chain descent),
|
||
- a `Some(t)` from `__PATH_DATA` (`apply_path`'s enter loop),
|
||
- a `target: u16` parameter to `transition()`.
|
||
|
||
All five sources are produced by codegen from `IR::states`, whose
|
||
`StateIr.id` field is `0..N_STATES` by construction (`ir::build_ir`
|
||
assigns ids by enumeration; `ir::resolve_transitions` only resolves
|
||
identifiers to existing entries; `validate::validate` rejects
|
||
duplicates and unresolvable names). User code cannot write
|
||
`self.current` — the field is private, the chart context is the only
|
||
field handed to action bodies, and the action context's
|
||
`__terminated: &bool` reference is read-only.
|
||
|
||
**P2 — Range-table well-formedness.** For every `(from, to)` pair the
|
||
codegen-emitted `__PATH_RANGE[from * N + to] = (s, m, e)` triple
|
||
satisfies `0 ≤ s ≤ m ≤ e ≤ __PATH_DATA.len()`. The same holds for
|
||
`__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`. The
|
||
triple's interpretation is: exit chain is `__PATH_DATA[s..m]`, enter
|
||
chain is `__PATH_DATA[m..e]`. Both slices are required to fit inside
|
||
`__PATH_DATA`.
|
||
|
||
Codegen establishes P2 in `compute_transition_paths` (codegen.rs
|
||
~133): every emitted offset is a `usize` cursor into a `Vec<u16>`
|
||
that is grown only by `push`, so the cursor never exceeds the
|
||
length, and the triple is recorded *after* both halves of the
|
||
chain are pushed.
|
||
|
||
## Verification — five overlapping checks
|
||
|
||
Each check covers a different failure mode. The unsafe surface is
|
||
secure when *all five* pass.
|
||
|
||
### 1. Compile-time const-eval (build error)
|
||
|
||
Every generated chart contains a `const _: () = { ... }` block
|
||
(codegen.rs ~2465) that re-states P2 as `assert!`s evaluated at type
|
||
check time. A codegen change that emits a malformed `(s, m, e)`
|
||
trips a `rustc E0080: evaluation panicked:` diagnostic before any
|
||
runtime code is produced.
|
||
|
||
Verified: manually injecting `(s=0, m=99, e=99)` into a 4-element
|
||
`__PATH_DATA` produces
|
||
|
||
error[E0080]: evaluation panicked: __PATH_RANGE: e past __PATH_DATA end
|
||
|
||
so the assert is live, not optimized away.
|
||
|
||
### 2. Runtime `debug_assert!` companions
|
||
|
||
Every `unsafe { ... get_unchecked ... }` is paired with a
|
||
`debug_assert!` of the same predicate (codegen.rs ~2693, ~2978–2987,
|
||
~3110, ~3211). `debug_assert!` is a `#[cfg(debug_assertions)]` no-op
|
||
in `--release`, so production code pays nothing. In dev/test it
|
||
panics with a clear message before the unsafe access runs.
|
||
|
||
Coverage: every dispatch through any of the 68 hsmc behavior tests
|
||
exercises the relevant assertions. A regression in P1 (e.g., a
|
||
codegen change that lets a stale id leak into `self.current`) panics
|
||
the test, naming the violated bound.
|
||
|
||
### 3. Miri (UB detection)
|
||
|
||
`cargo +nightly miri test -p hsmc` runs the test suite under Miri,
|
||
the Rust UB interpreter. Miri detects out-of-bounds reads/writes,
|
||
use-after-free, and other unsafe-related UB.
|
||
|
||
Status: **20 unit + 46 behavior tests pass** under Miri with the
|
||
current `unsafe` set.
|
||
|
||
### 4. Mutation testing (test-coverage proof)
|
||
|
||
`cargo mutants` flips operators / removes branches in the
|
||
`hsmc-macros` codegen and `hsmc/src/lib.rs` runtime, rebuilding and
|
||
testing each mutant. Run with `--features hsmc/tokio,hsmc/journal
|
||
--test-package hsmc` so the integration tests in `hsmc/tests/` (which
|
||
are gated on those features) actually compile and run; without that,
|
||
cargo-mutants restricts to the per-package test set and the
|
||
asymmetric-depth lateral tests don't fire.
|
||
|
||
Status (with the correct invocation):
|
||
- `hsmc-macros/src/codegen.rs` safety-relevant fns
|
||
(`compute_transition_paths`, `compute_terminate_paths`,
|
||
`compute_event_dispatch`): **51 of 53 covered** (14 caught + 37
|
||
unviable + 2 missed). The 37 unviable are mutations that don't
|
||
compile in their context — the type system rejects them, which is
|
||
a stronger form of "caught". The 2 missed are *equivalent
|
||
mutants* analyzed below.
|
||
- `hsmc/src/lib.rs` (runtime): **48 of 48 covered** (45 caught + 3
|
||
unviable + 0 missed) — full coverage.
|
||
|
||
Combined: **99 of 101 mutants accounted for (98%)**. Run with
|
||
`just safety`.
|
||
|
||
#### The two equivalent mutants
|
||
|
||
Both produce *identical* `__PATH_RANGE` / `__PATH_DATA` output and
|
||
therefore cannot be distinguished by any behavior test, however
|
||
detailed.
|
||
|
||
**Equivalent mutant 1.** `codegen.rs:140`:
|
||
`while da > db { ... }` → `while da >= db { ... }`.
|
||
|
||
The mutated loop walks `a` one extra step when `da == db`. The next
|
||
loop (`while db > da`) then walks `b` up by one to compensate, and
|
||
the third loop (`while a != b`) drives both legs to the same LCA.
|
||
`parent(root) = None` and the `?` operator short-circuit so going
|
||
"past root" is symmetric on both sides. Result: same LCA, same
|
||
exit/enter paths. Verified by hand on lateral, up-transition, and
|
||
asymmetric-depth (4↔2) cases.
|
||
|
||
**Equivalent mutant 2.** `codegen.rs:169`:
|
||
`from != to && is_strict_ancestor(to, from)` →
|
||
`from == to && is_strict_ancestor(to, from)`.
|
||
|
||
`is_strict_ancestor(to, from)` is always false when `from == to`
|
||
(a state is not a strict ancestor of itself), so the mutated
|
||
condition is always false and the up-transition branch becomes
|
||
unreachable. Up-transitions fall through to the lateral branch,
|
||
which computes `lca(from, to) = to` (since `to` is an ancestor of
|
||
`from`); the lateral exit loop then walks source up to (exclusive
|
||
of) `to`, and the enter loop breaks immediately on
|
||
`Some(to) == Some(to)` returning an empty enter path. Result: same
|
||
exit path, same empty enter path — same `(s, m, e)` triple.
|
||
|
||
### 5. Creusot-verified path-table validator + per-chart const-eval
|
||
|
||
`hsmc-verification/src/paths.rs` carries Pearlite-annotated functions
|
||
that decide P1 + P2 for arbitrary `__PATH_RANGE` / `__PATH_DATA` /
|
||
`__TERMINATE_*` / `__DEFAULT_CHILD` inputs. Each function's
|
||
`#[ensures(... forall ...)]` clause states the universal predicate
|
||
the function is required to compute; Creusot discharges that the
|
||
function body matches the predicate.
|
||
|
||
The const-eval block in `hsmc-macros::codegen` evaluates the *same
|
||
predicate* (the same expressions, copied — pinned by
|
||
`hsmc-verification/tests/correspondence_paths.rs`) on every user
|
||
chart's actual emitted tables at build time. Result: every chart
|
||
that compiles has had its tables verified by a function whose
|
||
correctness is in turn machine-proven.
|
||
|
||
**This is operationally equivalent to a universal proof for end
|
||
users.** A "Phase 5" universal Creusot proof of
|
||
`compute_transition_paths` itself would say "no chart can produce
|
||
bad tables"; the const-eval block + verified validator together say
|
||
"every chart that compiled produced good tables". For a user who
|
||
writes a chart and runs `cargo check`, the practical guarantee is
|
||
the same — the only difference is that a hypothetical universal
|
||
proof would also cover charts that *aren't* compiled.
|
||
|
||
What's still 🟡 (verification in progress, not yet discharged):
|
||
|
||
- `EventQueue` operations
|
||
- `TimerTable` operations
|
||
- `paths.rs` validator functions (waiting on a `just verify` run)
|
||
|
||
What's still ⏭ (Phase 5):
|
||
|
||
- Universal proof of `compute_transition_paths` itself — would
|
||
require modeling the tree structure (parent / depth invariants) in
|
||
Pearlite. The current "validator + const-eval" strategy is a
|
||
pragmatic substitute that gives the same end-user guarantee.
|
||
|
||
Track + run:
|
||
|
||
```bash
|
||
just verify # full Creusot run; first run installs SMT solvers
|
||
just safety # const-eval + debug_assert + miri + mutants in one shot
|
||
```
|
||
|
||
## Failure modes the checks together rule out
|
||
|
||
| Failure | Caught by |
|
||
| ------------------------------------------------------ | ---------------------------------- |
|
||
| Codegen emits triple with `s > m`, `m > e`, `e > len` | (1) const-eval at build time |
|
||
| Codegen emits state id ≥ N_STATES in path data | (1) const-eval at build time |
|
||
| Runtime mutates `self.current` to garbage | not possible — field is private; no API path writes a garbage value |
|
||
| User action sets `terminated` | not possible — `&bool` borrow is read-only |
|
||
| Future change introduces an unsafe path bypass | (2) debug_assert in tests, (3) miri, (4) mutants |
|
||
| Subtle UB in a non-bounds dimension (aliasing, etc.) | (3) miri |
|
||
|
||
## What's *not* yet covered
|
||
|
||
- **Universal proof of `compute_transition_paths`.** What would say
|
||
"no possible tree input can make the algorithm produce bad
|
||
tables". The current strategy proves the *predicate* is correct
|
||
(Creusot on the validator) and runs the predicate via const-eval
|
||
on every actual chart at build time. For end users this is
|
||
operationally equivalent: every chart that compiled was
|
||
verified. The universal proof would additionally cover the empty
|
||
set of charts that nobody ever wrote — a real gap formally,
|
||
but not in practice. Tracked as Phase 5 in
|
||
`verification/INVARIANTS.md`.
|
||
|
||
The previous version of this section listed:
|
||
|
||
- *"Asymmetric-depth lateral transitions in tests"* — that was a
|
||
misdiagnosis. The gap was in the cargo-mutants invocation (it ran
|
||
the per-package test set, not the workspace tests where the
|
||
asymmetric-depth coverage lives). Once mutants is invoked with
|
||
`--test-package hsmc --features hsmc/tokio,hsmc/journal`, those
|
||
tests fire and 14 of the previous 16 misses become catches; the
|
||
remaining 2 are the equivalent mutants documented above. Closed.
|
||
- *"Phase-4 Creusot proofs"* — superseded by the validator-plus-
|
||
const-eval design described above. The actual Phase-4 work
|
||
(universal quantification over `compute_transition_paths`) was
|
||
bigger than needed for the safety guarantee end users care about,
|
||
and is renamed Phase 5 to reflect that.
|
||
|
||
## Maintenance rules
|
||
|
||
When adding a new `unsafe` block in codegen:
|
||
|
||
1. Express the safety predicate as a `debug_assert!` immediately
|
||
above the `unsafe { ... }`.
|
||
2. If the predicate is over a static table, extend the const-eval
|
||
block to cover it — this catches the worst class of bugs at
|
||
build time.
|
||
3. Re-run `cargo +nightly miri test -p hsmc` and `cargo mutants
|
||
-p hsmc-macros -F <function-name>` to check coverage.
|
||
4. Update this doc. The inventory above must list every unsafe
|
||
block; if the table goes stale the safety story stops being
|
||
auditable.
|