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 exceptionThe 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:

  1. Why safe Rust is insufficient (executing generated machine code).
  2. The invariant (the code was produced by our own Cranelift module; the signature matches the Proto's arity; the memory is PROT_EXEC and no longer writable).
  3. The region, minimized to the transmute and the call.
  4. Focused tests.
  5. 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, Proto are all pub(crate). Test it against the public-api output.
  • Every pub item has a doc comment with an example, and the examples run (cargo test --doc).
  • Non-exhaustive where it should be. #[non_exhaustive] on ErrorKind and Limits so 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

DocumentContainsWritten in
README.mdthe production profile, above the fold; the API example; the !Send noteLab 23
docs/architecture.mdthe grammar, precedence, every normative rule, the determinism principleLab 8
docs/bytecode.mdthe opcode table and the validator rulesLab 9
docs/gc.mdroots, edges, thresholds, the allocation-hazard ruleLab 15
docs/embedding.mdthe host guide: marshaling, userdata, reload, budget guidanceLabs 19–22
docs/sandboxing.mdassets, adversary, controls (linked to tests), non-goalsLab 23
docs/limitations.mdeverything that does not work, and whycontinuously
appendix/lua-differences.mdevery divergence, with a reason and an idcontinuously
docs/releasing.mdsemver policy, the release checklist, what is stablethis lab
CHANGELOG.mdkeepachangelog formatthis lab
docs/adr/fourteen ADRscontinuously

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:

StableUnstable
The Engine API surfaceEmberError's message text
ErrorKind variants (#[non_exhaustive])The bytecode format across minor versions
The language's documented semanticsInstruction 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.md and the non-goals in docs/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

  1. What does #![forbid(unsafe_code)] in the default build depend on? Which design decision made it possible?
  2. Give three legitimate #[allow(clippy::indexing_slicing)] sites and the comment each needs.
  3. What does deny.toml state that cargo audit does not?
  4. Why #[non_exhaustive] on ErrorKind, and why must that be decided before 1.0?
  5. Name the five design questions determinism has decided.
  6. Which row of the production bar is not in your CI? What are you going to do about it?
  7. Why should docs/limitations.md be written continuously rather than at the end?
  8. Why is adding a capability to SAFE a breaking change?
  9. State the honest production-readiness answer in its two halves.

Next: Lab 24 — Diagnostics.