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)+) => { ... };
}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:
| Wall | Going in | Going out |
|---|---|---|
| Contents (the wrapper’s protection) | new, From<&str> / From<&[u8]> | with_secret, expose_secret, into_inner |
| Role (the nominal label) | from_wrapper | as_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 thederive:rules- a plain
typealias —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