AI & Machine Learning Ops/Why Rust’s Allocator API Has Been Nightly for a Decade
Why Rust’s Allocator API Has Been Nightly for a Decade — and What That Means for AI-Generated Code
Security architecture · Rust · AI-assisted development
Executive Summary
Rust’s safe/unsafe split is a single binary label standing in for a much wider range of actual risk — allocation behaviour, I/O, raw-pointer access and more, all collapsed into one keyword. That’s a big part of why allocator_api, a feature proposed in 2015, is still nightly-only: the community has never settled whether its core trait method should be marked unsafe or not, and once a trait like this stabilizes its shape is effectively permanent. The debate stalls; the feature stays gated. Rust, in this one specific and well-documented sense, has become rusty.
That gap matters more than it used to, because a growing share of Rust is now drafted by AI coding assistants rather than typed by hand. We open-sourced two small crates — capability-attr and sensitive-ifc — that sidestep the stalled debate entirely: stable-Rust, compile-time checks that declare and enforce a function’s actual capability and data-classification scope, without waiting for allocator_api to resolve. They are not a replacement for AppArmor or SELinux, and we say so explicitly in our own architecture decision record — this is a second layer, not a substitute for the first.
Update: both crates only ever verified an annotation a human already wrote. We’ve since shipped taint-generate and taint-refactor — tools that auto-write those annotations and auto-patch a found violation across an entire crate — plus extended taint-check itself to track taint crate-wide instead of one function at a time. Details, and a real pre-existing bug we found and fixed while building them, are below.
Watch: The Argument, in Four Parts
Four short reels covering the same argument as the write-up below — narrated with an offline text-to-speech voice, not a recording of a person.
Part 1 — Why Rust’s Allocator API Has Been Nightly for a Decade
Part 2 — The Orthogonal Fix, and Why It Still Needs AppArmor/SELinux
Part 3 — We Automated Annotating Your Code
Part 4 — Auto-Fixing Violations, and a Bug We Found Along the Way
One Keyword, Too Many Kinds of Risk
unsafe means, precisely, “the compiler cannot prove the absence of undefined behaviour here.” That’s a narrow and useful guarantee. But it is Rust’s only machine-checked way to flag “this needs extra scrutiny” — so in practice it gets asked to cover allocation behaviour, I/O side effects and raw-pointer access all at once. A function can be perfectly memory-safe and still allocate from the wrong pool, perform I/O it shouldn’t, or leak a credential — none of which the safe/unsafe label was ever built to express.
allocator_api is the clearest case study. It would let Vec<T>, Box<T> and friends be generic over a custom allocator instead of the global one — exactly what a bounded-memory or embedded target needs. It has never shipped on stable Rust.
| When | What happened |
|---|---|
| 2015 | RFC 1398 proposes allocator_api — Vec<T>, Box<T> and friends generic over a custom allocator, not just the global one. |
| 2015–2026 | Tracking issue rust-lang/rust#32838 stays open. The blocking question never resolves: should Allocator::allocate be unsafe fn or safe fn? |
| Today | allocator_api is still nightly-only. Once a trait like this stabilizes, its shape is effectively permanent — so the whole feature waits on one yes/no question about one keyword. |
Ten-plus years nightly-only, blocked on one yes/no question about one keyword. That’s not a criticism of Rust’s core safety guarantees — those remain best-in-class. It’s a criticism of asking a single binary flag to settle debates it was never designed to carry.
Why This Matters More in the AI Programming World
AI coding assistants now draft a meaningful share of production Rust. When a task needs raw performance, FFI, or systems-level control, an assistant reaches for the only lever stable Rust actually gives it: unsafe. That block then conflates “this touches a raw pointer” with “this allocates from a custom pool” with “this does uncontrolled I/O” — and a human reviewer, or another model reviewing the first one’s output, gets a single yes/no signal exactly where the volume of code to review has gone up and the time available per line has gone down.
Review capacity, not code-generation speed, is the actual bottleneck in that world. A finer-grained, compiler-checked signal is worth more there than it was when most Rust was written and reviewed by the same small group of humans who already knew what each unsafe block was really doing.
The Orthogonal Fix: Sidestep the Debate, Don’t Wait for It
capability-attr’s #[capability(alloc(..), io(..), ptr(..))] attribute doesn’t argue that Allocator::allocate should be marked one way or the other. It lets a function declare and enforce the actual scope it’s using, on stable Rust, today — regardless of how, or whether, that upstream debate ever resolves.
#[capability(alloc(pool = "arena"), io(none), ptr(none))]
fn build_response(req: &Request) -> Response {
// Compiler-enforced: this function may only allocate from
// the "arena" pool. Any I/O call or raw pointer use here
// is a compile error, not a code-review hope.
...
}sensitive-ifc applies the same move one layer up, for data classification instead of side-effect scope: Sensitive<T, L> and Redacted<T> newtypes make it a compile error to accidentally Display or serialize classified data — no taint-tracking RFC required to land first. Both are Phase 1 of a larger design; the full rationale, including what was deliberately deferred and why, is documented in our public RFCs and architecture decision records alongside the crates’ own source.
Where This Ends: Defense in Depth, Not a Replacement
Compile-time capability checking and OS-level Mandatory Access Control sound similar enough to blur together — they aren’t the same layer, and one is not a substitute for the other. We treat this as a standing architecture decision, not a footnote: adopting these crates should never mean dropping an AppArmor or SELinux profile.
Compile-time (capability-attr / sensitive-ifc)
Catches: A function exceeding its declared alloc/io/ptr scope, or a Sensitive<T, L> value reaching Display/serialization unexplained — in code built with the attribute applied.
Cannot see: Anything about a binary it didn't compile: a compromised process, a dependency that skipped the attribute, or any other process on the host.
Runtime, OS-enforced (AppArmor / SELinux)
Catches: What any process on the host actually does at the syscall boundary — file access, network, capabilities — regardless of source language or whether the binary shipped with source at all.
Cannot see: Nothing that stays inside the process boundary the kernel already permitted; it has no notion of which internal function allocated what.
A compromised binary, a dependency that skipped the attribute, or any other process on the host is entirely outside what a compile-time macro can ever see. That’s the kernel’s job. Both layers stay in place, together.
From Verify to Generate: taint-check Goes Crate-Wide
capability-attr and taint-check only ever verified an attribute a human already wrote — they never decided where one belonged, and taint-check’s AST walk, macro and CLI alike, only ever saw one module’s own direct children. We’ve since extended taint-check to follow mod foo; declarations across an entire crate and track taint through interprocedural, let-bound calls — not just simple assignments — via a new taint-check --crate src/lib.rs mode.
On top of that, two new tools close the remaining gap between finding a problem and fixing it.
taint-generate: Auto-Write the Annotations
There are two very different kinds of “generate” here. Capability attributes are deterministic: taint-generate runs the exact same body inspector capability-attr’s own proc-macro uses to verify a declared capability set, and writes an attribute matching what it actually found — not a guess.
Taint attributes are heuristic. There is no way to derive from a function body alone that a parameter is a password or that a function is a logging sink, so this pass matches parameter/function names against a small, fixed keyword list. That will both miss real cases and flag false ones — a function named handle_login gets flagged as a sink candidate, because "login" contains "log". Review every generated taint attribute before trusting it.
Neither pass ever touches a function or module that already carries any annotation of the kind it’s about to generate — a module with one existing attribute is skipped entirely, not partially filled in. Writing to disk is surgical too: only the exact byte span of each changed item is replaced, so the rest of the file — comments, formatting, everything else — is untouched.
taint-refactor: The Riskiest Tool in the Workspace
Given one taint violation found by taint-check’s crate-wide scan, taint-refactor re-scans the entire crate for every occurrence of the same (label, sink) pattern and generates a patch for each one: a placeholder #[taint_sanitizer] function, plus a rewritten call site that routes through it.
This is, deliberately, the tool we ship the strongest warning around. The generated sanitizer is a naive, fixed-string placeholder with no idea whether that redaction is right for a given value or context. Wrapping a value can also change its type — confirmed directly while building it: a call site expecting &str needs a & added back once the placeholder starts returning a String. Nested mod { ... } violations are skipped and reported, not guessed at. Re-run your build and test suite after applying any patch, and review every line before trusting it.
A Real Bug Found and Fixed Along the Way
Manually verifying taint-refactor’s own output against taint-check surfaced a genuine, pre-existing correctness gap in taint-check itself — present since it first shipped crate-wide tracking, never previously exercised by any existing test or hand-written example: sink(sanitizer(tainted)), called directly with no intermediate let binding, was still flagged as a violation.
The sink-argument check used a broad, sanitizer-unaware scan — deliberately conservative, to catch format! calls and arbitrary nesting — while only the let-binding path was sanitizer-aware. Fixed narrowly: a sink’s argument is now also checked for whether it is, itself, after unwrapping &/(...), a direct call to a registered sanitizer, short-circuiting only that one shape — the broad conservative scan is untouched for everything else. This is exactly the shape taint-refactor generates, so the fix was load-bearing for the feature actually working, not a nice-to-have caught along the way.
Try It
All crates are open source (Apache-2.0) and published on crates.io:
cargo add rusty-capability-attrcargo add rusty-sensitive-ifccargo add rusty-taint-check-macroscargo install rusty-taint-check— CLI, single-file or--cratemodecargo install rusty-taint-generate— auto-write missing annotationscargo install rusty-taint-refactor— auto-patch a found violation (review its output before trusting it)
A slide deck version of this argument is available as a downloadable PDF.
Related
This sits alongside our AI & Machine Learning Ops practice: as more production code is AI-drafted, the review and guardrail layer around it matters as much as the model choosing what to write.