The Hardening Checklist
The audit. Work through it once, honestly, and the answer to "is this production-ready?" becomes a list of checked boxes and a scope statement rather than an opinion.
1. Panics
The rule: no script input, however malformed, may panic. A panic in a library is an aborted host process.
#![allow(unused)] fn main() { // In vm.rs, heap.rs, table.rs, lexer.rs, parser.rs, compiler.rs: #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing, clippy::integer_arithmetic)] }
That is aggressive, and the point is that every exception becomes an #[allow] with a comment
saying why. The comment is worth more than the lint.
| Legitimate exception | The comment must say |
|---|---|
slots[i] after a generation check | "the check on the previous line proves i is in range" |
expect after a TypeId comparison | "type_id was checked immediately above" |
Indexing code[ip] in the dispatch loop | "the validator proved ip < code.len() and every jump target is in range — and validate() runs before run()" |
Audit the remaining ones by grepping:
rg -n 'unwrap\(\)|expect\(|panic!|unreachable!|\[[a-z_]+\]' src/vm.rs src/heap.rs src/table.rs \
| grep -v '^\s*//'
And a #[deny] is a claim a fuzzer should try to falsify. Five targets, ten minutes each, in CI
nightly.
2. unsafe
#![allow(unused)] fn main() { // src/lib.rs #![cfg_attr(not(feature = "jit"), forbid(unsafe_code))] }
The default build contains zero unsafe. That is a real, checkable property and it is a direct
consequence of the handle-based heap.
Say it in the README.
The jit feature is the exception, and every unsafe block in it needs the five-part treatment:
- Why safe Rust is insufficient (executing generated machine code).
- The invariant (the code was produced by our own Cranelift module; the signature matches the
Proto's arity; the memory isPROT_EXECand no longer writable). - The region, minimized to the
transmuteand the call. - Focused tests.
- How violating the invariant causes unsoundness.
3. Dependencies
cargo tree --depth 1 # the default build should be EMPTY
cargo audit # RUSTSEC advisories
cargo deny check # licences, bans, advisories, sources
deny.toml states the policy, not just the current state:
[bans]
multiple-versions = "deny" # two versions of one crate is a smell and a size cost
wildcards = "deny"
[licenses]
allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-3-Clause", "Unicode-3.0"]
[advisories]
yanked = "deny"
Three dependencies across the whole curriculum, each behind a feature and each with an ADR:
cranelift-* (jit), serde (serde), and possibly a faster hasher — which, as
Lab 16 established,
is a security decision as much as a performance one.
4. The Public API
cargo public-api # what is actually exported
cargo semver-checks check-release # did we break it?
cargo doc --no-deps --document-private-items # and read it
Three properties to verify by hand:
- No internal type is reachable.
GcRef,Heap,Vm,Chunk,Op,Table,Protoare allpub(crate). Test it against thepublic-apioutput. - Every
pubitem has a doc comment with an example, and the examples run (cargo test --doc). - Non-exhaustive where it should be.
#[non_exhaustive]onErrorKindandLimitsso adding a variant or a field is not a breaking change. Decide this before 1.0, because after it you cannot.
5. Determinism
#![allow(unused)] fn main() { #[test] fn the_corpus_is_deterministic_across_seeds_runs_and_engines() { for case in corpus() { let runs: Vec<_> = (0..5).map(|i| Engine::with_seed(i * 7919).run(&case.src)).collect(); assert!(runs.windows(2).all(|w| w[0] == w[1]), "{} is not deterministic", case.name); } } }
Determinism is a security and testability property, not a nicety, and by now it has decided five
design questions: the recursion limit, the execution budget, table iteration order, string ordering,
and the exclusion of math.random from SAFE. It is stated as a principle in
docs/architecture.md.
The audit: grep for every source of nondeterminism and confirm each is either absent or injected.
rg -n 'SystemTime|Instant::now|thread_rng|random|HashMap.*iter\(\)' src/
That last pattern is the interesting one: iterating a HashMap in a way that reaches observable
output is a determinism bug even if it feels internal.
6. Thread Safety
Engine is !Send + !Sync, asserted by a test, documented in ADR-011, and stated in the README
including the honest cost (a tokio task cannot hold one across an .await).
If the send feature exists, it needs its own audit: every Rc became an Arc, every registered
function is Send, and the benchmark showing what the atomics cost is committed.
7. CI
- run: cargo fmt --check
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- run: cargo test --workspace --all-features
- run: cargo test --doc
- run: cargo doc --workspace --no-deps
- run: ./scripts/boundary-audit.sh
- run: cargo audit
- run: cargo deny check
- run: cargo semver-checks check-release
- run: cargo bench -- --save-baseline ci && cargo criterion --baseline main
- run: cargo test --features gc-stress # nightly, the whole corpus
- run: cargo fuzz run run -- -max_total_time=600 # nightly, all five targets
If a row of the production bar is not in this list, it is aspirational. That is the test for whether the checklist is real.
8. The Documents
| Document | Contains | Written in |
|---|---|---|
README.md | the production profile, above the fold; the API example; the !Send note | Lab 23 |
docs/architecture.md | the grammar, precedence, every normative rule, the determinism principle | Lab 8 |
docs/bytecode.md | the opcode table and the validator rules | Lab 9 |
docs/gc.md | roots, edges, thresholds, the allocation-hazard rule | Lab 15 |
docs/embedding.md | the host guide: marshaling, userdata, reload, budget guidance | Labs 19–22 |
docs/sandboxing.md | assets, adversary, controls (linked to tests), non-goals | Lab 23 |
docs/limitations.md | everything that does not work, and why | continuously |
appendix/lua-differences.md | every divergence, with a reason and an id | continuously |
docs/releasing.md | semver policy, the release checklist, what is stable | this lab |
CHANGELOG.md | keepachangelog format | this lab |
docs/adr/ | fourteen ADRs | continuously |
docs/limitations.md is the one that earns trust, and it should be written as you go — each
lab adds a line — rather than at the end, where it reads like an apology. A sample of what should be
in it by now:
- No coroutines. (capstone project 4)
- No `goto`. The compiler has no way to close upvalues on an arbitrary jump.
- No weak tables or `__gc` finalizers. Determinism and resurrection; see docs/gc.md.
- Strings are bytes; no Unicode-aware operations. `#s` counts bytes.
- Stop-the-world GC. Pause is proportional to the live set; see the measured
distribution in docs/learning/11-gc.md.
- `Engine` is !Send + !Sync. One engine per thread.
- Host function cost is not bounded by the instruction budget. See docs/sandboxing.md.
- Tail calls are not eliminated; deep tail recursion hits the depth limit.
- Table iteration is insertion-ordered — a DIVERGENCE from Lua, and a promise we
cannot withdraw.
9. The Semver Policy
Decide, write down, and enforce:
| Stable | Unstable |
|---|---|
The Engine API surface | EmberError's message text |
ErrorKind variants (#[non_exhaustive]) | The bytecode format across minor versions |
| The language's documented semantics | Instruction counts |
| The default capability set (adding to it is breaking) | Anything pub(crate) |
"Adding a default capability is a breaking change" is the unusual row and the important one: a
host that audited SAFE and deployed it must not silently gain a capability in a patch release. It
is the kind of promise that only exists if you write it down before someone wants to add os.time
"just for convenience".
The Honest Answer
At the end of this checklist, the answer to "is Ember production-ready?" is:
For the stated profile — embedded policy scripting for trusted to partially-trusted scripts inside a Rust service — yes, subject to the limitations in
docs/limitations.mdand the non-goals indocs/sandboxing.md.For hostile multi-tenant execution — no, and it is not designed to be.
Being able to give that answer, in that shape, is the deliverable. It is also the shape of the answer you should demand from every dependency you adopt for the rest of your career.
Validation / Self-check
- What does
#![forbid(unsafe_code)]in the default build depend on? Which design decision made it possible? - Give three legitimate
#[allow(clippy::indexing_slicing)]sites and the comment each needs. - What does
deny.tomlstate thatcargo auditdoes not? - Why
#[non_exhaustive]onErrorKind, and why must that be decided before 1.0? - Name the five design questions determinism has decided.
- Which row of the production bar is not in your CI? What are you going to do about it?
- Why should
docs/limitations.mdbe written continuously rather than at the end? - Why is adding a capability to
SAFEa breaking change? - State the honest production-readiness answer in its two halves.
Next: Lab 24 — Diagnostics.