Skip to main content

Module fixed_storage

Module fixed_storage 

Source
Expand description

Marker trait asserting that a secret’s storage owns no heap allocation.

Fixed<T> is documented as having no reallocation surface, which is what lets SECURITY.md exempt it from the heap-residue weakness that Dynamic<T> carries. That exemption was a claim about the shape people were expected to use, not something the type system checked: Fixed<T> is bounded only by Zeroize, so Fixed<Vec<u8>> and Fixed<String> compiled, and fixed_newtype!(pub Name, generic Vec<u8>) was a documented path straight to them. Measured, they leak exactly as much as the Dynamic equivalent — and worse, because Dynamic<Vec<u8>> has a safe-growth std::io::Write impl and Fixed<Vec<u8>> has nothing.

FixedStorage is what makes the exemption true. Fixed::new requires it, so the compiler now rejects an inner type that owns a heap allocation.

§The contract

Implementing FixedStorage for a type asserts that the type owns no heap allocation. Nothing else, and note that this is stricter than “cannot be resized”.

The first version of this trait asked only for a fixed capacity, and blessed Box<[T]> on the reasoning that a boxed slice has its length fixed at construction. That was wrong, and measurably so: the residue never needed a capacity change. Whole-value replacement abandons the allocation just as well, and the wrapper wipes whatever it holds at drop, not what it used to hold. On a Fixed<Box<[u8]>> carrying a 1024-byte secret, with_secret_mut(|slot| *slot = other_boxed_slice) released the original block with 1024 of 1024 bytes intact, and so did the same assignment through expose_secret_mut. The inline contrast, Fixed<[u8; 1024]> assigned the same way, freed nothing at all — there is no allocation to abandon. So Box<[T]> is no longer implemented, and the predicate is heap ownership rather than resizability.

The test to apply to your own type: does Self own a heap allocation? If yes, do not implement this — reach for Dynamic, which documents the residue.

§This is an assertion, not an enforcement

Like CloneableSecret, the compiler checks that you wrote the impl, not that the claim is true. A type with a Vec field that implements FixedStorage anyway will compile and will leak, which is measured and deliberate: the alternative is a closed set of blessed types, and that would break the one thing the generic arm of fixed_newtype! exists for — holding a custom secret on a target with no allocator.

What the marker buys is that the claim is now written down at a greppable line in the crate making it, rather than being an unstated assumption. That is the same trade the other opt-in markers in this crate make.

§What is implemented for you

  • the integer, floating-point, bool, char and () primitives;
  • the twelve NonZero integers;
  • [T; N] for any N, when T: FixedStorage — which covers [u8; 32] and the non-byte arrays the generic arm exists for, such as [i16; 256];
  • tuples up to ten elements of FixedStorage types, matching zeroize’s own ceiling;
  • Option<T> when T: FixedStorage — a discriminant adds no heap storage;
  • Wrapping<T> and MaybeUninit<T> when T: FixedStorage — both are T’s own storage;
  • zeroize::Zeroizing<T> when T: FixedStorage;
  • Fixed<T> itself when T: FixedStorage, so the wrapper nests.

§Why that list has to be this long

The bound is an allow-list, which means it fails closed: a type is refused both when it owns a heap allocation and when nothing has asserted that it does not. For a type you define, the second case costs one line. For a type you do not own it costs nothing, because you cannot pay it — impl FixedStorage for NonZeroU32 in your crate is E0117, the orphan rule, and no amount of effort downstream changes that.

So every heap-free type reachable through zeroize’s own Zeroize impls has to be blessed here or it is simply unavailable as a Fixed inner type, with the caller’s only escape being to change their own type. That is the reason for the NonZero, Wrapping, MaybeUninit and Zeroizing impls above, and for tuples reaching ten rather than four: each was a shape that compiled before this bound existed and that no downstream crate could have restored. Ten is not arbitrary — zeroize implements Zeroize for tuples up to ten elements and no further, so an eleventh element fails the Zeroize bound before this one is ever consulted.

The deny-list alternative — accept everything except a known set of heap owners — was rejected deliberately. It has no false rejections, but it fails open: a custom type wrapping a Vec would be accepted in silence, which is the exact weakness this trait exists to close.

Nothing heap-owning is implemented, deliberately. Box<[T]> was and is not: see the contract above for the measurement that removed it. Box<[u8; N]> never arises anyway, because zeroize does not implement Zeroize for a Box of a sized type. For a heap-only secret of fixed size, the supported shape is Dynamic<[u8; N]>.

A custom inner type needs one line:

use secure_gate::{FixedStorage, Fixed, RevealSecret, SentinelValue};
use zeroize::Zeroize;

struct Poly([i16; 256]);

impl Zeroize for Poly {
    fn zeroize(&mut self) {
        self.0.zeroize();
    }
}
impl SentinelValue for Poly {
    fn sentinel_value() -> Self {
        Poly([0i16; 256])
    }
}
// Asserted here: `Poly` owns no heap allocation.
impl FixedStorage for Poly {}

let p = Fixed::new(Poly([7i16; 256]));
assert_eq!(p.with_secret(|poly| poly.0[0]), 7);

§Naming the type still compiles

The bound is on Fixed::new, not on the struct, which keeps the property the zero-size guard already documents: type Bad = Fixed<Vec<u8>>; is a legal type expression and no guard in the type could make it otherwise. What the bound removes is every value of it.

Traits§

FixedStorage
Marker trait asserting that a type owns no heap allocation.