Status: M1 (regression-tested forbid posture) and M2 (
cargo-geigeradvisory CI lane + workspace number) are complete. Promotion of the threshold to a blocking gate is a separate, future runbook.
Every crate in this workspace declares #![forbid(unsafe_code)] at lib-root. This is the strongest available compile-time guarantee against unsafe Rust:
forbidcannot be locally overridden by#[allow(unsafe_code)]on a function or block. Once forbidden, anunsafe { … }block in that crate is a hard compile error, no exceptions.deny(the weaker form) can be locally overridden. SunLit deliberately picksforbidto remove the judgment call.
| Crate | Posture | Notes |
|---|---|---|
security_core |
forbid(unsafe_code) |
foundation crate; no FFI |
secure_errors |
forbid(unsafe_code) |
also deny(clippy::all, clippy::pedantic) |
security_events |
forbid(unsafe_code) |
|
secure_boundary |
forbid(unsafe_code) |
|
secure_authz |
forbid(unsafe_code) |
|
secure_data |
forbid(unsafe_code) |
crypto crate; AEAD via RustCrypto and (gated) aws-lc-rs for FIPS — neither requires application-layer unsafe |
secure_output |
forbid(unsafe_code) |
|
secure_identity |
forbid(unsafe_code) |
|
secure_device_trust |
forbid(unsafe_code) |
|
secure_network |
forbid(unsafe_code) |
TLS via rustls |
secure_resilience |
forbid(unsafe_code) |
|
secure_privacy |
forbid(unsafe_code) |
|
secure_reference_service |
forbid(unsafe_code) |
private workspace crate |
secure_smoke_service |
forbid(unsafe_code) |
private workspace crate; added in M1 |
crates/security_core/tests/no_unsafe_code.rs runs as part of the standard cargo test --workspace chain on every PR. It contains two tests:
every_workspace_crate_forbids_unsafe_code— reads each crate'slib.rs(ormain.rs) and asserts#![forbid(unsafe_code)]appears within the first 20 lines, before any item declaration. Failure names the offending crate and the file path.no_unsafe_keyword_in_workspace_sources— recursively scans every crate'ssrc/for theunsafekeyword in code form (unsafe {,unsafe fn,unsafe impl,unsafe trait). Comment lines are skipped. The assertion is "must be empty"; any match fails the build with a file:line citation.
Adding a new workspace crate requires updating the CRATES_REQUIRING_FORBID constant in that test file. The constant is intentionally manual — adding a new crate to the workspace should be a deliberate decision about its unsafe posture.
The workspace root has no [package] block (it is a virtual manifest). Putting the test inside security_core/tests/ gives it the same execution surface — every CI test run executes it — without the structural cost of converting the root into a hybrid root-package. security_core is the foundation crate every other crate depends on, so the test runs whenever any workspace test runs.
cargo-geiger measures the count of unsafe blocks in the dependency tree (transitive). SunLit's source code is unsafe-free; dependencies are not, so the published number is non-zero and the goal is to make the change visible on every PR.
| Field | Value |
|---|---|
| Tool | cargo-geiger |
| Version (pinned) | 0.13.0 (per research dossier — active but partial maintenance; no drop-in successor; staying on it is the recommended posture) |
| Root package | secure_reference_service (depends on every library crate; closest analogue to a downstream consumer's BOM) |
| Invocation | cd crates/secure_reference_service && cargo geiger --all-features --output-format Json --update-readme=false |
| CI lane | .github/workflows/ci.yml → supply-chain job → "Cargo geiger" step (advisory, continue-on-error: true, 10-minute timeout) |
| Artifact | output/cargo-geiger.json uploaded by the actions/upload-artifact step (30-day retention) |
| Local equivalent | bash scripts/audit.sh (or pwsh scripts/audit.ps1) runs the same invocation and writes the same path |
--all-features is the deliberate choice: the number reflects the worst case across every published feature of the reference service. The reference service depends transitively on every published library; consumers who pull a narrower set of crates will encounter a smaller footprint.
cargo-geiger requires a single root package — it cannot consume a virtual manifest. The reference service is the chosen root for two reasons: (1) it depends on every library crate, so its dep graph is a superset of any other single library's dep graph; (2) it is the canonical "what does a downstream service look like" example shipped with the workspace, so the published number reflects what an integrator's audit would also report. A future enhancement could iterate per published library and report a max-of, but the reference-service number is the current published value.
| Metric | Value (as of 2026-05-05, root = secure_reference_service, --all-features) |
|---|---|
| Unsafe expressions used / available | 22 636 / 48 192 |
| Unsafe items used / available | 242 / 1 200 |
Unsafe fn used / available |
582 / 745 |
| Unsafe methods used / available | 74 / 82 |
Unsafe trait impl used / available |
828 / 1 274 |
| SunLit crates with unsafe usage | 0 of 14 (every workspace crate is :) — forbid(unsafe_code) declared, no unsafe used) |
The headline is 22 636 transitive unsafe expressions used. Common offenders are core ecosystem crates (tokio, time, serde_json, tracing-subscriber, hyper) — all well-audited primitives. The CI artifact (cargo-geiger.json) is the source of truth on every main-branch run; this section is updated on a release-cycle cadence to track the long-term trend.
M2 adds the opt-in secure_data pq feature (ml-kem, x25519-dalek, hkdf, sha2). The milestone attempted to measure the feature-local dependency tree directly:
| Field | Value |
|---|---|
| Invocation | cd crates/secure_data && timeout 60 cargo geiger --features pq --update-readme=false |
| Result | timed out with exit 124 before producing a count |
| Tool behavior | cargo-geiger v0.13.0 repeatedly emitted Failed to match (ignoring source) lines for registry packages in the resolved workspace graph |
| Retry evidence | a second --output-format Json run with a 180-second timeout also exited 124 and left a zero-byte JSON artifact |
| Follow-up | keep the reference-service advisory lane as the tracked workspace metric; retry this feature-local measurement when cargo-geiger can complete on the current graph |
This is recorded as an evidence caveat, not as a changed threshold. The project source posture is unchanged: every SunLit crate still declares #![forbid(unsafe_code)], and the PQ implementation adds no application-layer unsafe.
| Threshold | Value | Source of truth |
|---|---|---|
| Baseline | 22 636 | CI artifact + this section |
| Informational threshold | 24 900 (= baseline + 10 % headroom) | this section |
| Action triggered when number exceeds threshold | reviewer downloads CI artifact and diffs against the previous main-branch run; classifies the new unsafe (acceptable / replaceable / questionable); posts the finding in the PR | (operator behavior) |
The threshold starts informational (advisory). It is a delta-detector, not a gate. A PR that pushes the number above the threshold means a transitive dependency introduced new unsafe code; the reviewer reads the artifact diff to decide whether the change is acceptable.
The advisory CI step uses continue-on-error: true so failures (e.g., new unsafe in a dep, or geiger itself failing) do not block merges. They surface as a yellow check on the PR, with the JSON artifact downloadable from the run summary for diff review.
Promotion to a blocking CI gate is a separate runbook, planned after the metric has been observed for at least one release cycle and false-positive patterns are understood. Promotion criteria:
- The metric has been stable across ≥1 release cycle.
- Common transient noise sources (e.g., toolchain-version-driven changes in
proc-macro2/serde) are characterised. - A defined response procedure exists for "geiger went up" events (review artifact diff, classify, decide accept-or-revert).
- The release-process doc names cargo-geiger as a release gate.
- Click the cargo-geiger artifact on the PR's CI run.
- Compare against the artifact from main branch's last run.
- The diff identifies the dep that introduced new unsafe.
- Decide: accept (the dep is necessary; the unsafe is in a well-audited primitive), revert (the dep is replaceable), or escalate (the dep is questionable; open a discussion).
Should a future capability legitimately require unsafe code (e.g., a no_std embedded port, a SIMD primitive, or a low-level FFI shim that cannot be expressed safely):
- The new crate does not start with
#![forbid(unsafe_code)]. It starts with#![deny(unsafe_code)]. - Each
#[allow(unsafe_code)]site is named with a doc comment that includes:- the safety argument (why this
unsafeis sound) - the regression test that exercises the soundness condition
- the threat-model row (
tm-<slug>-…) that the unsafe site addresses
- the safety argument (why this
- The crate is added to
docs/dev-guide/unsafe-budget.mdwith the exception, the rationale, and a link to the named call sites. CRATES_REQUIRING_FORBIDin the regression test is updated accordingly.
This keeps the explicit-exception path documented and reviewable, rather than silent.
- Runbook:
docs/slo/future/RUNBOOK-forbid-unsafe-and-geiger.md - Research dossier:
docs/slo/research/forbid-unsafe-and-geiger/ - Supply-chain policy:
deny.toml - Threat model:
THREAT_MODEL.md