Skip to main content

dynamic_newtype

Macro dynamic_newtype 

Source
macro_rules! dynamic_newtype {
    ($(#[$attr:meta])* $vis:vis $name:ident, generic Vec<u8>, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic Vec<u8>, $doc:literal) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic Vec<u8>, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic Vec<u8>) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic String, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic String, $doc:literal) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic String, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic String) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, String, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, String, $doc:literal) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, Vec<u8>, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, Vec<u8>, $doc:literal) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic $inner:ty, $doc:literal, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic $inner:ty, $doc:literal) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, String) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, String, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, Vec<u8>) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, Vec<u8>, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic $inner:ty) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, generic $inner:ty, derive: [$($opt:ident),* $(,)?]) => { ... };
    ($(#[$attr:meta])* $vis:vis $name:ident, $($rest:tt)+) => { ... };
}
Available on crate feature alloc only.
Expand description

Creates a distinct nominal type wrapping Dynamic<T>.

Generates a struct rather than a type alias: two dynamic_newtype! types over the same inner type are distinct, where two plain type aliases over Dynamic<T> are one type.

§Syntax

dynamic_newtype!(pub Name, String);                    // shaped: full String API
dynamic_newtype!(pub Name, Vec<u8>);                   // shaped: full byte API
dynamic_newtype!(pub Name, generic T);                 // reduced API, opted into
dynamic_newtype!(pub(crate) Name, String);             // crate-visible
dynamic_newtype!(Name, String);                        // private
dynamic_newtype!(pub Name, String, "doc string");      // with custom doc
dynamic_newtype!(pub Name, String, derive: [FromWrapper]);
dynamic_newtype!(pub Name, String, "doc", derive: [FromWrapper]);

Supported derive: options are Deserialize, FromWrapper, IntoWrapper, and WrapperAccess (= both directions), plus ConstantTimeEq on the generic arm only; Clone and Serialize are deliberately absent — see fixed_newtype! for the reasoning and the hand-written pattern, which applies identically here.

ConstantTimeEq is not an option on the String and Vec<u8> arms, and naming it is an error. Both wrappers implement it whenever the ct-eq feature is on, so a plain type Name = Dynamic<String>; always had ct_eq and the newtype — the spelling this crate recommends as the safer of the two — did not. Since these arms now emit the impl themselves, a second one from the derive: list is E0119: conflicting implementations — delete the token. It stays a real opt-in on the generic arm, where the inner type may or may not implement the trait.

Note that ct_eq on a variable-length secret short-circuits when the two lengths differ, so only the equal-length comparison is constant-time. Where the length itself is sensitive, reach for fixed_newtype!.

§The "doc string" slot takes exactly one string literal

That argument is matched as $doc:literal, so it accepts a single string literal and nothing else. concat!("a ", "b") is a macro call, not a literal, and will not match — nor will a const, or pieces joined together at compile time.

The error you get is misleading. When the doc argument fails to match, the input falls through to the catch-all arm, which reports that the inner type is not one of the shaped types and suggests writing String or Vec<u8> literally. The inner type is usually fine; the doc slot is what failed. If you see that message on a call whose inner type is plainly String or Vec<u8>, look at the doc argument first.

For anything longer than one literal — multiple paragraphs, generated text, #[doc = ...] — put ordinary attributes before the name instead. The macro accepts $(#[$attr:meta])* there, so normal /// comments work and are the better style for real prose:

use secure_gate::dynamic_newtype;

dynamic_newtype!(
    /// First line of prose.
    ///
    /// A second paragraph, as many lines as you like.
    pub SessionToken, String
);

The "doc string" form remains for short one-liners and for callers generating the whole invocation from another macro.

§Inner types must be written literally

Macros match tokens, not resolved types. String and Vec<u8> are matched as literal tokens, so they must be spelled exactly that way to get the full API for their shape:

use secure_gate::{dynamic_newtype, SecretLen};

dynamic_newtype!(pub ApiKey, String);       // full String API
dynamic_newtype!(pub Token, Vec<u8>);       // full byte API: hex, io::Write, …

let k: ApiKey = "sk_live".into();
assert_eq!(k.len(), 7);

A type alias (type MyStr = String) or a fully-qualified path (std::string::String) is a different token sequence, so it cannot reach those arms — and no macro can see through it, because macro expansion runs before type resolution. Rather than silently hand back a newtype missing half its API, such input is a compile error naming the fix.

generic is not a way to get past that error. generic Vec<u8> and generic String are themselves a compile error, because each is the same growable payload as the shaped arm with strictly less API — for Vec<u8>, no io::Write, which is the only growth path that wipes the buffer it abandons. Delete the word and the safe surface comes with it.

That reject matches literal tokens too, so it is a guard on the spelling, not on the type: generic MyBytes and generic alloc::vec::Vec<u8> are different token sequences and still land on the reduced arm, carrying a growable payload with no safe way to grow it. The macro cannot see through an alias or a path — that is the same limitation as above, not a separate one — and a token deny-list does not become type-level by getting longer. If the inner type is a String or a Vec<u8> under any spelling, write the literal token.

When the inner type genuinely is something else, opt in with generic:

use secure_gate::{dynamic_newtype, RevealSecret};

dynamic_newtype!(pub Counters, generic Vec<u32>);

let c = Counters::new(vec![1u32, 2, 3]);
assert_eq!(c.with_secret(|v| v.len()), 3);

The generic form deliberately provides less: RevealSecret, RevealSecretMut, redacted Debug, Zeroize, ZeroizeOnDrop, and new — no SecretLen and no encoders. The reason is what the macro can see rather than what each method means: the inner type arrives as one opaque token, so the expansion cannot tell a Vec from a struct and withholds the shape-dependent surface uniformly. An encoder genuinely has no meaning for an arbitrary inner type; SecretLen does, and the base wrapper implements it for every Dynamic<Vec<T>> with a correct byte_len, so derive: [IntoWrapper] reaches it through as_wrapper(). What the arm withholds is the newtype’s own len(), not the answer. Writing the marker is how you say you know that.

It also has no new_with, which the Vec<u8> arm does get, and the reason is sharper than it used to be. fixed_newtype!’s generic arm does forward Fixed::new_with, because that constructor is generic over any T: SentinelValue — it hands the closure the inner value itself. Dynamic::new_with hands a sized byte slot, FnOnce(&mut [u8]), which is what makes growth inexpressible and the reallocation hazard impossible. A byte slot is meaningless for an arbitrary inner type, so there is nothing here to forward. new takes its value by value — and moves the allocation rather than copying it, so build it pre-sized and as close to the call as you can.

The String arm has no new_with either, as of 0.9.0-rc.12, and cannot: a String must hold valid UTF-8 and characters have variable byte widths, so a fixed byte window is not somewhere arbitrary text can be written.

The two macros are also asymmetric about SecretLen, deliberately. fixed_newtype! has a third arm for a literal array — generic [i16; 256] — which forwards SecretLen because the element type and the length are both visible as tokens. There is no equivalent here and none is wanted: a Dynamic payload carries its length at runtime rather than in the type, so there is no token for an arm to read, and derive: [IntoWrapper] remains the way to reach the base wrapper’s own len().

§Implementation Notes

The generated type is #[repr(transparent)] over the wrapper and delegates through #[inline] methods, so it costs nothing at runtime.

Unlike Fixed, there is no compile-time zero-size check here, and the reason is about the payload rather than the wrapper. For String and Vec<u8> emptiness is a runtime property — an empty one is a legitimate value to hold before validation — so there is nothing for a compile-time check to decide. That leaves one real gap: a statically zero-sized inner type. Dynamic<Zst> for a zero-sized Zst constructs happily, where Fixed<Zst> is now rejected. Validate expected lengths in your own tests, and do not reach for a zero-sized inner type expecting to be stopped.

Do not add your own Drop impl. None is needed: the wrapped Dynamic still runs its own, so zeroization is unaffected. Adding one makes the inner field unmovable (E0509) and silently costs you into_inner.

§Security

Generated types inherit every Dynamic guarantee: zeroize on drop including spare capacity, Debug that always prints [REDACTED], and access only through RevealSecret / RevealSecretMut. There is no Deref, so a generated newtype is not coercible to its wrapper — which is what keeps the nominal separation total rather than by-value-only.

Nominal separation guards against mistakes, not intent — but nothing is generated that would undo it by accident. There is no From<Wrapper> and no Deref, so a base-typed value cannot flow into a newtype through .into(), and &Newtype never coerces to &Wrapper at a call site. By default the only way material enters or leaves is the 3-tier access API — a with_secret round trip — which is explicit and shows up in the audit sweep. Base-wrapper access is opt-in, per newtype, and split by direction: derive: [FromWrapper] adds from_wrapper (a base value enters this role), derive: [IntoWrapper] adds as_wrapper, as_wrapper_mut, and into_wrapper (material leaves this role toward the base type), and derive: [WrapperAccess] is shorthand for both.

These cross a different wall from into_inner. A newtype has two:

WallGoing inGoing out
Contents (the wrapper’s protection)new, From<&str> / From<&[u8]>with_secret, expose_secret, into_inner
Role (the nominal label)from_wrapperas_wrapper, as_wrapper_mut, into_wrapper

into_inner leaves the protection: it hands back the plain value, so the contents are in the caller’s hands (tier 3 of the access model, audited). into_wrapper is a label drop; into_inner is a protection drop. Confusing those two names is the main way this model gets misread. into_wrapper only removes the label: the result is still a Dynamic, still unreadable without with_secret. The role row exists so that dropping a label never forces opening the contents — without it, reaching base-typed code costs an into_inner plus a rebuild, a reveal the job never needed.

Which direction is safe depends on the pool. In a mixed tree the base type is not raw material: every plain type alias sharing it is that type, so a directional token connects this role to all of them at once. FromWrapper lets anything in the pool become this role — the source never opts in, because the source is just the base type — so a type that guards a boundary must never take it. IntoWrapper lets this role become anything in the pool, which is safe only when the role is no more sensitive than the least-sensitive plain alias sharing its base; on a secret role it is an explicit, greppable downgrade, not a neutral operation. The default, neither token, is sufficient more often than it looks. The exposure is largest during a partial migration — the regime real consumers live in — because while most of the pool stays a plain type alias, the base type is a universal donor. Audit these methods the way you audit expose_secret().

The heap caveats of Dynamic carry over unchanged: see SECURITY.md on realloc residue for Vec/String growth after wrapping.

§See also

  • fixed_newtype! — stack-allocated counterpart, and the reference for the derive: rules
  • a plain type alias — pub type Password = Dynamic<String>; — when a readable name is all that is wanted and interchangeability with the base type is a feature rather than a risk; the crate’s README argues when that is the right reach