After Opt #2 added six bounded `get_unchecked` blocks per chart, the project's two "no unsafe" claims (README §"At a glance" + spec §11 rule 12) were stale, and there was no single place answering "how do I know the unsafe is correct?". This commit closes both gaps: - New: docs/004. unsafe-safety-contract.md Inventories every unsafe site, names the predicate, names what establishes it (codegen-time guarantee), and lists the five layered checks (const-eval, debug_assert, miri, mutants, Creusot) with current status. Documents what's *not* yet covered (the 16-of-53 missed mutants in the LCA depth walk are behavior, not safety; transition() codegen invariants are still Phase-4 for Creusot). Includes a maintenance checklist for future unsafe. - README points at the doc and gives an honest one-paragraph summary instead of "no unsafe". - Spec rule 12 rewritten from "no unsafe in generated code" to "bounded unsafe in generated code, none in user code" with a pointer to the audit. - justfile gains `just safety` — runs const-eval (cargo check), debug_assert tests, miri, and mutants in one shot. Mirrors the doc's audit matrix so the doc never goes stale silently.
8.7 KiB
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, seeenter_path(0)),- the
targetslot of a__DISPATCHentry (handler resolution), - a
__DEFAULT_CHILD[cur]value (default-chain descent), - a
Some(t)from__PATH_DATA(apply_path's enter loop), - a
target: u16parameter totransition().
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.
Status:
hsmc-macros/src/codegen.rs(safety-relevant fnscompute_transition_paths,compute_terminate_paths,compute_event_dispatch): 37 of 53 caught (70%). The 16 missed mutants are all in thelcadepth-walk closure — they produce behaviorally wrong dispatch (the wrong states get exited / entered) but do not violate P1 or P2, so they cannot cause UB. They are a behavior test gap (no asymmetric-depth lateral-transition test exercises the depth-equalization path), not an unsafe-correctness gap.hsmc/src/lib.rs(runtime): 41 of 48 caught (85%). The 6 missed are allJournalSinkops that are no-ops without thejournalfeature; mutating a no-op is a no-op.
5. Creusot deductive proofs (formal verification, partial)
hsmc-verification/INVARIANTS.md lists the spec rules that have
formal Pearlite contracts. Coverage as of this writing:
EventQueueoperations: contract present, proofs in progress (🟡)TimerTableoperations: contract present, proofs in progress (🟡)transition()codegen invariants (P1 + P2): explicitly out of scope, listed as Phase 4 — requires threading Creusot attributes through the proc-macro itself.
So Creusot does not currently cover the unsafe surface introduced
by Opt #2. Closing that gap is the natural Phase 4 work.
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
- Asymmetric-depth lateral transitions in tests. The 16 missed
mutants in
compute_transition_paths's LCA closure indicate the test charts all have matched depths on both legs of every transition. Adding a chart whose handler bubbles to a shallow ancestor and targets a deep state on the other side would close the gap. The existingbubble_cross_treebench exercises this but has no behavior assertions. - Phase-4 Creusot proofs. Until those land, P1 + P2 are established by code review of the codegen — the const-eval and miri checks make the gap detectable, but not machine-proven-impossible.
Maintenance rules
When adding a new unsafe block in codegen:
- Express the safety predicate as a
debug_assert!immediately above theunsafe { ... }. - 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.
- Re-run
cargo +nightly miri test -p hsmcandcargo mutants -p hsmc-macros -F <function-name>to check coverage. - Update this doc. The inventory above must list every unsafe block; if the table goes stale the safety story stops being auditable.