secure-gate
Secure wrappers for secrets with explicit access and mandatory zeroization — a no_std-compatible, zero-overhead library with audit-friendly access patterns.
[!WARNING] Security Notice: This crate has not undergone independent audit. Review the code and SECURITY.md before production use.
Quick Start
use ;
pub type Password = ; // heap, length varies with the input
pub type Aes256Key = ; // on the stack, exactly 32 bytes
let mut pw: Password = "hunter2".into;
// Generate key material from the system RNG rather than a literal (needs `rand`).
let mut key: Aes256Key = from_random;
// Scoped access — preferred; the borrow cannot outlive the closure
let long_enough = pw.with_secret; // validate in-closure; never log a secret's length
// Mutable scoped access — rotate in fresh material. The old key is overwritten in
// place and never leaves the wrapper. For Dynamic<String> / Dynamic<Vec<u8>>, prefer
// capacity-stable mutations or pre-allocate before wrapping; see SECURITY.md.
let next: Aes256Key = from_random;
next.with_secret;
// Direct reference — auditable escape hatch (e.g. FFI, third-party APIs)
assert_eq!;
pw.expose_secret_mut.clear;
Core Concepts
Fixed<T> (stack-allocated) and Dynamic<T> (heap, requires alloc) share the same access interface:
Debugoutput →[REDACTED].len()/.is_empty()viaSecretLen— without exposing contents (length itself can still be sensitive)- Zeroize on drop (always)
- Access via
.with_secret(|s| ...)(preferred) or.expose_secret()(auditable escape hatch) - Owned extraction via
.into_inner()→ the plainT; nothing is copied and protection ends at the call - Streaming I/O via
impl Writeand.as_reader()forDynamic<Vec<u8>>(requiresstd)
Preferred: scoped access
use ;
let mut key: = new;
// Read — the closure borrow cannot outlive the call, so validate inside it and
// return only the verdict, never the bytes.
let looks_degenerate = key.with_secret;
assert!; // every byte is 0xAB
// Mutate in place — the secret is never copied out to be modified.
key.with_secret_mut;
RustCrypto in-place cipher ops
BlockEncrypt/BlockDecrypt take &mut GenericArray<u8, U16>, and
GenericArray::from_mut_slice borrows rather than copies — so the cipher can run
inside the wrapper and the plaintext never leaves it:
use ;
use Aes128;
use ;
let cipher = new;
let mut block: = new;
block.with_secret_mut;
Copying out instead — block.with_secret(|b| aes::Block::from(*b)) — is the footgun:
the resulting Block is an ordinary stack value that nothing wipes on drop. See the
Fixed rustdoc
for both shapes side by side.
Direct reference — auditable escape hatch
// Use only when a long-lived reference is unavoidable (FFI, third-party APIs)
use ;
let key: = new;
let raw: & = key.expose_secret;
Owned consumption — transfer ownership
// When you need to move the secret value out (FFI hand-off, type migration)
use ;
let key: = new;
let owned: = key.into_inner;
// Protection ends here: `owned` is a plain array and you own its lifetime.
// Nothing was copied — the bytes were moved out and a sentinel left behind.
assert_eq!;
Named secret types
Two ways to give a secret a name, differing in exactly one respect — whether the compiler can tell two same-shaped secrets apart.
A plain type alias over Fixed or Dynamic is a name and nothing more: the alias is the wrapper, so it carries every guarantee the wrapper carries — zeroize on drop, redacted Debug, access only through RevealSecret — and it stays interchangeable with its base type. Two aliases over the same underlying type are the same nominal type and assignable to each other — use them for readability and audit grep targets. Documentation goes on the alias as an ordinary doc comment, which is also where the doc string the removed *_alias! macros took now belongs:
use ;
/// 32-byte AES-256 key.
pub type Aes256Key = ;
/// Variable-length password.
pub type Password = ;
A plain type alias is the right reach when a value is sensitive enough to want zeroize-on-drop and a redacted Debug, but has no role it could be confused with — a session blob, a cached token, a nonce store. You get the protection and a self-documenting name, the alias stays interchangeable with its base type so it crosses into APIs you do not own without ceremony, and there is no cross-contamination to prevent because nothing else shares its shape and meaning.
Reach for a newtype the moment two values of the same shape mean different things. That is the case the compiler can help with, and the only one where the extra surface pays for itself.
Newtypes (fixed_newtype!, dynamic_newtype!) expand to structs instead, so two of the same shape are distinct types. Reach for these when distinct cryptographic roles share a shape — an encryption key and a MAC key are both Fixed<[u8; 32]>, and under an alias the compiler cannot tell them apart:
use fixed_newtype;
fixed_newtype!;
fixed_newtype!;
// seal(&mac, &enc) does not compile — the roles cannot be swapped by accident.
Both newtype macros also accept generic T in place of the byte size fixed_newtype! expects or the String / Vec<u8> dynamic_newtype! expects, for a secret whose inner type is neither bytes nor a string — fixed_newtype!(pub Poly, generic [i16; 256]); for an ML-KEM secret polynomial on a target with no allocator, where Fixed is the only wrapper there is. The generic arm emits exactly the surface that is meaningful for an arbitrary inner type: scoped access, redacted Debug, zeroize on drop and new, with no SecretLen and no encoders, since neither has a meaning without a known shape.
"Neither bytes nor a string" is enforced, not just advised: dynamic_newtype!(pub X, generic Vec<u8>) and generic String are a compile error at the declaration, because each is the same growable payload as the shaped arm with strictly less API — for Vec<u8>, without the io::Write impl that is the only growth path wiping the buffer it abandons. Write Vec<u8> or String and the safe surface comes with it. Like the shaped arms, that check matches literal tokens, so an alias or a path-qualified spelling (alloc::vec::Vec<u8>) still reaches the reduced arm; macro expansion runs before type resolution, so no macro can close that gap.
Generated newtypes carry the same guarantees as the wrapper (zeroize on drop, redacted Debug, access only via RevealSecret), are #[repr(transparent)] so they cost nothing at runtime, and have no Deref — the separation is total, not by-value-only. No From<Wrapper> or Deref is generated, so an alias-typed value cannot become a newtype through .into() and a newtype never coerces back to its base; base-wrapper access is opt-in per newtype and split by direction (derive: [FromWrapper] to construct from the base, derive: [IntoWrapper] to reach it; WrapperAccess is both) and should be audited like expose_secret(). In a mixed tree the base type is the pool every plain alias lives in: FromWrapper on a boundary type accepts all of them, and IntoWrapper on a secret role downgrades it to the least-sensitive alias sharing its base — neither token is the sufficient default more often than it looks.
See [fixed_newtype!] and [dynamic_newtype!] in the API docs.
Zero-size behavior note
A zero-length Fixed cannot be built at all. Fixed::new and Fixed::new_with each carry a const assertion that the value being wrapped has a nonzero size, so Fixed<[u8; 0]> — and any other zero-sized inner type — is a compile error at the first construction. The assertion is a post-monomorphization error, which is what makes it cover generic code too. Where it points is worth knowing before you go looking. The error's own span is the assertion inside this crate, not your code; a separate while instantiating note is what names the Fixed::new call the monomorphization reached. That note does not name the instantiation that caused it. Given a generic fn build<const N: usize>() -> Fixed<[u8; N]>, calling build::<0>() reports against the Fixed::new line inside build, and neither build::<0>() nor its caller appears anywhere in the output. fixed_newtype!(Name, 0) additionally fails at the declaration, via a const-eval index-out-of-bounds guard in the macro, so that spelling reports the problem at the line you wrote rather than at the first call.
Two limits on when it fires, both measured rather than assumed. First, a post-monomorphization error is raised during codegen, so cargo check does not report it, and neither does an editor driven by cargo check. Second, and more consequential: it only fires for a codegen root. A non-generic #[inline] function in a library is not one, and every method these macros generate carries an inline attribute, #[inline] or #[inline(always)]. So a library crate that writes fixed_newtype!(pub Empty, generic [u8; 0]); alongside #[inline] pub fn empty() -> Empty { Empty::new([]) } compiles, tests and publishes with cargo build, cargo build --release and cargo test all green. The error surfaces only when a downstream crate instantiates it, and the diagnostic points into secure-gate and at the dependency's macro invocation — not at the consumer's own call site, and not in the crate that wrote the bug.
What counts as a codegen root is itself a property of the compiler and the profile, so do not read the #[inline] above as the whole condition. Dropping the attribute does not reliably restore the error. Measured on 1.85: a library exporting a plain, non-generic, non-inline pub fn empty() -> Empty { Empty::new([]) } does fail cargo build and cargo test, and passes cargo build --release with exit 0 — rustc's cross-crate-inlining heuristic (1.75 and later) drops a small function from the exported root set in an optimized build, so the release profile has no root to instantiate. The same crate on 1.70 fails in both profiles. The conclusion to carry is about the class, not the spelling: whether a library's own CI sees the error depends on attributes, generics, compiler version and optimization level together, and none of those is a guarantee. Only instantiating the type in a test or binary is.
So state the guarantee precisely: no value of a zero-sized Fixed can exist at runtime, because nothing can construct one. A binary or a test that builds one fails to compile. What the guard does not do is stop a library from exporting an unusable zero-sized API with green CI. Neither limit is a design choice: a condition on a generic parameter has nothing to evaluate until that parameter is known, and nothing on stable Rust moves it earlier. fixed_newtype!(Name, 0) is the exception that does fire under cargo check in the declaring crate, because the size is a literal at that point — which is the argument for the macro's own guard earning its keep alongside this one.
Naming the type still compiles: type Empty = Fixed<[u8; 0]>; is a legal type expression, and no guard placed in the type can make the type itself unnameable. What the assertion removes is every value of it — there is no way to obtain an Empty to hold, encode, compare or drop — which is the property that matters, and it holds for a plain type alias and a newtype alike because both funnel through the same two constructors.
Dynamic has no compile-time equivalent, and the reason is about the payload rather than the wrapper: the emptiness of a Vec or a String is a runtime property, and an empty Dynamic<String> is a legitimate value to hold before validation, so there is nothing for a compile-time check to decide. One honest gap remains, rather than a non-problem: a statically zero-sized inner type. Dynamic<Zst> constructs where Fixed<Zst> is now rejected, so do not reach for a zero-sized inner type expecting to be stopped. A zero-length Dynamic then behaves normally rather than failing: len() is 0, Debug is still [REDACTED], ct_eq against another empty is true, to_hex() returns "", and drop is clean. Nothing reports a problem, which is precisely why this is worth stating — the failure is silent and semantic, not a panic you would notice. Validate that the effective length is > 0 in your own tests whenever it comes from configuration.
See also the Best Practices section in SECURITY.md for the equivalent guidance.
Polymorphic / generic code
use SecretLen;
// Length is metadata, not contents — but for variable-length secrets it can
// still be sensitive. Validate against it; don't log it.
What You Get
- Zero-cost safety — mandatory zeroization on drop;
no_std/no_allocsupport. - Audit-first API — a held secret cannot leak via
Deref:Fixed/Dynamicimplement none. Access requires explicitwith_secretscopes or an auditableexpose_secretescape hatch.into_innerhands ownership to the caller and ends protection; encoders returnEncodedSecret, which does deref and stays wiped until it drops — see Where accident-prevention ends. - Named secret types — a plain
typealias overFixed/Dynamic(pub type Aes256Key = Fixed<[u8; 32]>;) inherits redactedDebugand zeroize-on-drop and stays interchangeable with its base type, so same-shape aliases (e.g. twoFixed<[u8; 32]>aliases) are one and the same type. When distinct cryptographic roles share a shape,fixed_newtype!/dynamic_newtype!generatestructs instead, so the compiler rejects a swapped key role at the call site. - Batteries included — optional, zero-overhead support for serde, constant-time comparison (
subtle), and secure encoding (hex, base32, base64url, bech32/m). - No unsafe code — enforced with
#![forbid(unsafe_code)].
Installation
Default (alloc enabled — Fixed<T> + Dynamic<T> + full zeroization):
[]
= "0.9.0-rc.9"
No-heap / embedded (Fixed<T> only — pure stack / no_std):
= { = "0.9.0-rc.9", = false }
Batteries-included:
= { = "0.9.0-rc.9", = ["full"] }
Encoding & Decoding
secure-gate provides symmetric, zero-overhead encoding and decoding for five formats: hex, base32 (RFC 4648 §6), base64url, bech32 (BIP-173), and bech32m (BIP-350). All operations are explicit. Decoding is always fallible; on the encode side only bech32 and bech32m return a Result, because they can reject an invalid HRP or an over-long payload — to_hex, to_hex_upper, to_base32 and to_base64url cannot fail.
Available traits
| Format | Encode | Decode | Feature |
|---|---|---|---|
| Hex | ToHex |
FromHexStr |
encoding-hex |
| Base32 (RFC 4648 §6) | ToBase32 |
FromBase32Str |
encoding-base32 |
| Base64URL | ToBase64Url |
FromBase64UrlStr |
encoding-base64 |
| Bech32 (BIP-173) | ToBech32 |
FromBech32Str |
encoding-bech32 |
| Bech32m (BIP-350) | ToBech32m |
FromBech32mStr |
encoding-bech32 |
Base32 is here for TOTP/HOTP interop: otpauth:// key URIs (RFC 6238 / RFC 4226) carry the shared secret as uppercase, unpadded Base32, and Base32 is the densest encoding that fits QR alphanumeric mode.
Encoding (to string)
The wrapper encoding methods are trait impls, so the trait must be in scope — use secure_gate::{Case, ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m}; — before key.to_base32() resolves. Every one of them returns [EncodedSecret], which wipes itself on drop and prints [REDACTED]. Read it through the deref (&*encoded is a &str) and call .into_inner() only when an API demands an owned String.
use ;
#
Every encoder returns [EncodedSecret] — Zeroizing<String> with a redacted Debug and no Display — because an encoded secret is a second full copy of the secret and deserves the same wiping as the first. Read it with &*encoded (it derefs to str), which is what serde_json and every database driver want; call .into_inner() for an owned String, which is the named moment protection ends. The same methods exist on the wrappers (Fixed / Dynamic) and on the encoding traits (ToHex, ToBase32, ToBase64Url, ToBech32, ToBech32m).
Direct Constructors (Recommended)
Both Fixed<[u8; N]> and Dynamic<Vec<u8>> offer one-shot constructors from strings. Both use panic-safe Zeroizing-wrapped decode buffers internally. Fixed also supports a no-alloc path that decodes directly into stack storage when alloc is disabled.
| Format | Method | Notes |
|---|---|---|
| Hex | try_from_hex(s) |
HexError |
| Base32 | try_from_base32(s) |
Base32Error (RFC 4648 §6, uppercase, unpadded) |
| Base64URL | try_from_base64url(s) |
Base64Error (unpadded, URL-safe) |
| Bech32 (BIP-173) | try_from_bech32(s, hrp) |
HRP validated; Bech32Error::UnexpectedHrp |
| Bech32 (unchecked) | try_from_bech32_unchecked(s) |
No HRP; Bech32Error |
| Bech32m (BIP-350) | try_from_bech32m(s, hrp) |
HRP validated; Bech32Error::UnexpectedHrp |
| Bech32m (unchecked) | try_from_bech32m_unchecked(s) |
No HRP; Bech32Error |
Security notes:
- Prefer HRP-validated constructors to prevent cross-protocol confusion attacks.
- Use
_uncheckedonly when HRP is validated upstream. - The decode constructors in this table stage into
Zeroizingbuffers, so a panic between a successful decode and wrapper construction still wipes them. (Fixed::new/Dynamic::newtake an already-built value and have no such buffer.) - Encoded output is protected by default: every encoder returns [
EncodedSecret], wiped on drop..into_inner()is the named point where that ends (seeSECURITY.md).
Serde
serde-deserialize decodes directly to the inner type. After deserialization completes, temporary buffers for Dynamic<Vec<u8>> and Dynamic<String> are Zeroizing-wrapped — oversized buffers are zeroized even on rejection. The default limit is MAX_DESERIALIZE_BYTES (1 MiB); call Dynamic::deserialize_with_limit to set a custom ceiling. Serialization requires the SerializableSecret marker trait.
Note:
MAX_DESERIALIZE_BYTES(anddeserialize_with_limit) is enforced after the upstream deserializer has fully materialized the payload. It is a result-length acceptance bound, not a pre-allocation DoS guard. For untrusted input, enforce size limits at the transport or parser layer upstream.
See [SerializableSecret] in the API docs for the full example.
Random Generation
from_random() uses the system RNG (SysRng), panics on failure, and is heap-free for Fixed<T> (no_std / no_alloc). from_rng fills from any TryCryptoRng + TryRng and returns Result (e.g. seeded StdRng in tests). Dynamic::from_random / from_rng require alloc (implicit — Dynamic<T> itself already requires it). See [Fixed::from_random], [Fixed::from_rng], [Dynamic::from_random], and [Dynamic::from_rng] in the API docs.
Security Model
- Explicit access only — all caller-facing access requires
.with_secret()/.expose_secret(); no silent leaks. Internal impls (Clone,Serialize) access.innerdirectly but require opt-in marker traits. - Zeroize on drop — always active; inner type must implement
Zeroize - Timing-safe equality —
ct-eqfeature (.ct_eq()) routes throughexpose_secret(), honoring the explicit-access model - No unsafe code — enforced with
#![forbid(unsafe_code)]
For Dynamic<Vec<_>> and Dynamic<String>, avoid capacity-changing mutations
after wrapping unless your deployment handles allocator-level residue. Capacity-changing
means more than growing: reserve abandons the old buffer while writing no payload, and
shrink_to_fit / shrink_to abandon it while the buffer only ever got smaller, carrying
the discarded tail with it. The buffer the wrapper holds afterwards is still zeroized on
drop, spare capacity included, so the exposure is confined to the abandoned buffers — one
per move, and whether a move happens at all depends on whether the allocator can resize
the chunk in place. For known-size heap-only key material, prefer Dynamic<[u8; N]>
(boxed array — no realloc surface). Fixed<T> has no realloc surface either, and that is
now enforced rather than assumed: Fixed::new requires FixedStorage on the inner type, so
Fixed<Vec<u8>> and fixed_newtype!(pub Name, generic Vec<u8>) are compile errors instead
of silent instances of the same weakness. A custom inner type asserts the property in one
line. See SECURITY.md for the realloc threat-model note and operational mitigations.
Inherent Rust limitations
Three universal in-memory-secret limits apply (same across C, C++, Go, and
Rust): stack-move residue (mitigated by Fixed::new_with, pass-by-reference,
or switching to Dynamic<T>), heap-reallocation residue (mitigated by
pre-sizing, Dynamic<[u8; N]>, or the
zeroizing-alloc global allocator),
and swap / core dumps (OS-level — mlock, encrypted swap, disabled core
dumps).
Read SECURITY.md for the full threat model and mitigations, including the dedicated § Inherent Rust Limitations section.
Audit Surface (Secret Materialization)
Encoding and decoding methods are convenience wrappers that internally use scoped with_secret access — they do not bypass the security model, but return the fully materialized encoded value.
They exist because users who call them have already decided to reveal the secret — the wrapper reduces boilerplate and avoids long-lived raw references.
Every encoder returns [EncodedSecret] (wrapping Zeroizing<String> with a redacted Debug and no Display), so encoded output is wiped on drop by default.
Audit every exposure point by searching your codebase for:
- Access:
expose_secret,expose_secret_mut,with_secret,with_secret_mut - Extract:
into_inner(hands the plain secret to the caller; protection ends),as_reader(yields a reader over the secret bytes) - Encode:
to_hex,to_hex_upper,to_base32,to_base64url,try_to_bech32,try_to_bech32m, and their_sized::<N>forms — all returningEncodedSecret - Decode:
try_from_hex,try_from_base32,try_from_base64url,try_from_bech32*(including_unchecked)
Best practice: Prefer scoped methods (with_secret / with_secret_mut) when possible — they keep exposure minimal.
What changed in 0.9.0
Edition 2024, MSRV 1.85, rand 0.10 (OsRng → SysRng), dep bumps.
Across the release candidates: SecretLen split out of RevealSecret (which now covers
every inner type); Base32 (RFC 4648 §6) added behind encoding-base32; wrapper encoders
are ToHex / ToBase32 / ToBase64Url / ToBech32 / ToBech32m trait impls; fixed_newtype! / dynamic_newtype! for nominal secret roles; no Display
on EncodedSecret.
Two breaking changes are worth reading before you upgrade. Every encoder now returns
EncodedSecret and the *_zeroizing twins are gone, so the short name is the safe one.
And into_inner returns the plain value rather than a wrapper that kept wiping —
protection now ends at that call, where earlier release candidates continued it.
Full details in CHANGELOG.md. Users on Rust < 1.85: pin secure-gate = "0.8".
Branch support
| Branch | Version | Rust edition | MSRV | Status |
|---|---|---|---|---|
main |
0.9.x | 2024 | 1.85 | Active development |
release/0.8 |
0.8.x | 2021 | 1.70 | Lockstep with main; no dependency or toolchain bumps |
Rust ≥ 1.85: use secure-gate = "0.9".
Rust < 1.85: pin secure-gate = "0.8".
The two lines exist because rand 0.10 landed mid-development, and rather than raise the
toolchain floor for everyone the crate split: main on Rust 1.85 / edition 2024,
release/0.8 on 1.70 / edition 2021. They are the same API on two compilers — the
0.8.x / 0.9.x split is the MSRV split — and until both reach a stable release they are
finished in parallel: every change on main is re-derived onto release/0.8 (the
ledger in docs/audits/pr-182-backport-ledger.md records how), with one standing
exclusion — dependency and toolchain bumps that would break 1.70 are never ported.
After the stable releases, release/0.8 becomes mostly a maintenance line — security and
important bug fixes as patch releases — though a feature worth having may still be
backported. It will receive patches for as long as the dependencies it relies on remain
compatible with Rust 1.70.
Migrating from secrecy
The secure-gate-compat shim crate, which provided drop-in replacements for secrecy
v0.8 and v0.10, has been removed. It was experimental and never published. If you need
it, it is recoverable from git history — git checkout v0.9.0-rc.8 -- secure-gate-compat
restores the last version, along with its migration guide. (v0.9.0-rc.8 is the last tag
that carries it; the crate was deleted before rc.9.)
Features
Common stacks: default (alloc), features = ["full"], or default-features = false for heap-free Fixed only.
| Feature | Description |
|---|---|
alloc (default) |
Heap-allocated Dynamic<T> + full zeroization of Vec/String spare capacity |
std |
Full std support (implies alloc). Enables std::io::Read/Write for Dynamic<Vec<u8>> via as_reader() and direct Write impl. Use default-features = false for no-heap builds. |
rand |
from_random() (system SysRng) and fallible from_rng() for any TryRng + TryCryptoRng; no_std compatible for Fixed<T> (no heap required). Dynamic::from_random() / from_rng() require alloc (implicit — Dynamic<T> itself requires it). |
ct-eq |
ConstantTimeEq — timing-safe comparison via expose_secret() (subtle) |
encoding |
Meta: all encoding sub-features (hex, base32, base64url, bech32). Encoding traits require alloc; Fixed::try_from_* decoding is no-alloc. |
encoding-hex |
ToHex / FromHexStr — constant-time via base16ct |
encoding-base32 |
ToBase32 / FromBase32Str — constant-time via base32ct; RFC 4648 §6, uppercase and unpadded |
encoding-base64 |
ToBase64Url / FromBase64UrlStr — constant-time via base64ct |
encoding-bech32 |
BIP-173 (ToBech32 / FromBech32Str) and BIP-350 (ToBech32m / FromBech32mStr). One feature: the two are the same code, one checksum constant apart. _sized::<N> on every method sets the code length. |
serde |
Meta: serde-deserialize + serde-serialize |
serde-deserialize |
Direct deserialization; Zeroizing-wrapped buffers; 1 MiB default limit (MAX_DESERIALIZE_BYTES); use deserialize_with_limit for custom ceilings |
serde-serialize |
Serialize secrets (requires SerializableSecret marker on inner type) |
cloneable |
CloneableSecret opt-in cloning |
full |
All features except std |
no_std compatible — the crate is #![no_std] unless the std feature is enabled, verified in CI by cross-building for thumbv7em-none-eabihf. Fixed<T> with rand works heap-free (on bare-metal targets, getrandom additionally requires a user-configured platform backend for from_random; from_rng with a caller-supplied RNG has no such requirement). Dynamic<T>, encoding traits, and serde require alloc. Fixed::try_from_* decoding works without alloc using constant-time stack-based decoders. Disabled features have zero overhead.
Contributing
MSRV & Lockfile
This crate (main, 0.9.x) enforces MSRV 1.85 (rust-version = "1.85" in Cargo.toml). Rust 1.85 is the minimum that supports Rust edition 2024.
Always use the MSRV toolchain to update Cargo.lock:
CI
The CI pipeline (main branch) runs lint, test (19 feature combinations), rustdoc, MSRV (1.85), AddressSanitizer heap verification, and libFuzzer/Miri targets. See .github/workflows/.
The rustdoc job builds with --all-features, matching [package.metadata.docs.rs]: the docs.rs feature set is the enforced documentation contract. Intra-doc links that only break in minimal builds (alloc alone, for instance) are best-effort and deliberately not fixed — see #175.
License
MIT OR Apache-2.0