Skip to main content

base64_ng_derive/
lib.rs

1#![deny(unsafe_code)]
2#![deny(missing_docs)]
3#![deny(clippy::all)]
4#![deny(clippy::pedantic)]
5
6//! Derive helpers for fixed-size `base64-ng` byte newtypes.
7//!
8//! This proc-macro crate is intentionally narrow and dependency-free. The
9//! [`Base64Secret`] derive supports tuple structs with exactly one `[u8; N]`
10//! field and generates Base64 parsing/encoding helpers around that field.
11
12use proc_macro::{Delimiter, Group, TokenStream, TokenTree};
13
14/// Derives fixed-size Base64 helper methods for a tuple newtype.
15///
16/// Supported input:
17///
18/// ```
19/// use base64_ng_derive::Base64Secret;
20///
21/// #[derive(Base64Secret)]
22/// struct ApiKey([u8; 32]);
23/// ```
24///
25/// Generated behavior:
26///
27/// - `from_base64(&[u8])` and `from_base64_str(&str)` decode with
28///   `base64_ng::ct::STANDARD.decode_slice_staged_clear_tail`.
29/// - `encode_base64::<CAP>()` encodes with `base64_ng::STANDARD`.
30/// - `Debug` is redacted.
31/// - `Drop` clears the wrapped bytes with `base64_ng::clear_bytes`.
32#[proc_macro_derive(Base64Secret)]
33pub fn derive_base64_secret(input: TokenStream) -> TokenStream {
34    match expand_base64_secret(input) {
35        Ok(tokens) => tokens,
36        Err(error) => compile_error(&error),
37    }
38}
39
40fn expand_base64_secret(input: TokenStream) -> Result<TokenStream, String> {
41    let tokens = input.into_iter().collect::<Vec<_>>();
42    let struct_index = find_struct_index(&tokens)?;
43    let name = struct_name(&tokens, struct_index)?;
44    let field_group = tuple_field_group(&tokens, struct_index)?;
45    let length = array_field_length(&field_group)?;
46
47    let expanded = format!(
48        r#"
49impl {name} {{
50    /// Parses strict standard padded Base64 into this fixed-size newtype.
51    ///
52    /// Decoding uses `base64_ng::ct::STANDARD` and stages plaintext privately
53    /// until the full input has been accepted.
54    pub fn from_base64(input: &[u8]) -> Result<Self, ::base64_ng::DecodeError> {{
55        let required = ::base64_ng::ct::STANDARD.decoded_len(input)?;
56        if required != {length} {{
57            return Err(::base64_ng::DecodeError::InvalidLength);
58        }}
59
60        let mut output: [u8; {length}] = [0u8; {length}];
61        let mut staging: [u8; {length}] = [0u8; {length}];
62        let written = match ::base64_ng::ct::STANDARD
63            .decode_slice_staged_clear_tail(input, &mut output, &mut staging)
64        {{
65            Ok(written) => written,
66            Err(error) => {{
67                ::base64_ng::clear_bytes(&mut output);
68                ::base64_ng::clear_bytes(&mut staging);
69                return Err(error);
70            }}
71        }};
72
73        if written != {length} {{
74            ::base64_ng::clear_bytes(&mut output);
75            ::base64_ng::clear_bytes(&mut staging);
76            return Err(::base64_ng::DecodeError::InvalidLength);
77        }}
78
79        ::base64_ng::clear_bytes(&mut staging);
80        Ok(Self(output))
81    }}
82
83    /// Parses strict standard padded Base64 text into this fixed-size newtype.
84    pub fn from_base64_str(input: &str) -> Result<Self, ::base64_ng::DecodeError> {{
85        Self::from_base64(input.as_bytes())
86    }}
87
88    /// Encodes this value as strict standard padded Base64.
89    pub fn encode_base64<const CAP: usize>(
90        &self,
91    ) -> Result<::base64_ng::EncodedBuffer<CAP>, ::base64_ng::EncodeError> {{
92        ::base64_ng::STANDARD.encode_buffer::<CAP>(&self.0)
93    }}
94
95    /// Returns the wrapped bytes.
96    #[must_use]
97    pub fn as_bytes(&self) -> &[u8] {{
98        &self.0
99    }}
100
101    /// Returns the wrapped bytes mutably.
102    ///
103    /// Callers that mutate secret bytes are responsible for preserving any
104    /// application-level invariants attached to this newtype.
105    pub fn as_mut_bytes(&mut self) -> &mut [u8] {{
106        &mut self.0
107    }}
108
109    /// Compares two values with a fixed-width, constant-time-oriented scan.
110    ///
111    /// The final equality result is public. This inherits the caveats of
112    /// `base64_ng::constant_time_eq_fixed_width`.
113    #[must_use]
114    pub fn constant_time_eq(&self, other: &Self) -> bool {{
115        ::base64_ng::constant_time_eq_fixed_width(&self.0, &other.0)
116    }}
117}}
118
119impl ::core::convert::TryFrom<&[u8]> for {name} {{
120    type Error = ::base64_ng::DecodeError;
121
122    fn try_from(input: &[u8]) -> Result<Self, Self::Error> {{
123        Self::from_base64(input)
124    }}
125}}
126
127impl ::core::convert::TryFrom<&str> for {name} {{
128    type Error = ::base64_ng::DecodeError;
129
130    fn try_from(input: &str) -> Result<Self, Self::Error> {{
131        Self::from_base64_str(input)
132    }}
133}}
134
135impl ::core::str::FromStr for {name} {{
136    type Err = ::base64_ng::DecodeError;
137
138    fn from_str(input: &str) -> Result<Self, Self::Err> {{
139        Self::from_base64_str(input)
140    }}
141}}
142
143impl ::core::convert::From<[u8; {length}]> for {name} {{
144    fn from(bytes: [u8; {length}]) -> Self {{
145        Self(bytes)
146    }}
147}}
148
149impl ::core::convert::AsRef<[u8]> for {name} {{
150    fn as_ref(&self) -> &[u8] {{
151        self.as_bytes()
152    }}
153}}
154
155impl ::core::fmt::Debug for {name} {{
156    fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {{
157        formatter
158            .debug_struct(::core::stringify!({name}))
159            .field("bytes", &"<redacted>")
160            .field("len", &{length})
161            .finish()
162    }}
163}}
164
165impl ::core::ops::Drop for {name} {{
166    fn drop(&mut self) {{
167        ::base64_ng::clear_bytes(&mut self.0);
168    }}
169}}
170"#
171    );
172
173    expanded
174        .parse()
175        .map_err(|error| format!("failed to generate Base64Secret impl: {error}"))
176}
177
178fn find_struct_index(tokens: &[TokenTree]) -> Result<usize, String> {
179    tokens
180        .iter()
181        .position(|token| matches!(token, TokenTree::Ident(ident) if ident.to_string() == "struct"))
182        .ok_or_else(|| "Base64Secret can only be derived for tuple structs".to_owned())
183}
184
185fn struct_name(tokens: &[TokenTree], struct_index: usize) -> Result<String, String> {
186    tokens
187        .get(struct_index + 1)
188        .and_then(|token| match token {
189            TokenTree::Ident(ident) => Some(ident.to_string()),
190            _ => None,
191        })
192        .ok_or_else(|| "Base64Secret could not find a struct name".to_owned())
193}
194
195fn tuple_field_group(tokens: &[TokenTree], struct_index: usize) -> Result<Group, String> {
196    tokens
197        .iter()
198        .skip(struct_index + 2)
199        .find_map(|token| match token {
200            TokenTree::Group(group) if group.delimiter() == Delimiter::Parenthesis => {
201                Some(group.clone())
202            }
203            TokenTree::Group(group) if group.delimiter() == Delimiter::Brace => None,
204            _ => None,
205        })
206        .ok_or_else(|| {
207            "Base64Secret supports only tuple structs like `struct Key([u8; 32]);`".to_owned()
208        })
209}
210
211fn array_field_length(group: &Group) -> Result<String, String> {
212    let mut field_tokens = strip_field_visibility(group.stream().into_iter().collect::<Vec<_>>())?;
213    if field_tokens
214        .iter()
215        .any(|token| matches!(token, TokenTree::Punct(punct) if punct.as_char() == ','))
216    {
217        return Err("Base64Secret supports exactly one tuple field".to_owned());
218    }
219
220    if field_tokens.len() != 1 {
221        return Err("Base64Secret field must be exactly `[u8; N]`".to_owned());
222    }
223
224    let Some(TokenTree::Group(array_group)) = field_tokens.pop() else {
225        return Err("Base64Secret field must be a `[u8; N]` byte array".to_owned());
226    };
227    if array_group.delimiter() != Delimiter::Bracket {
228        return Err("Base64Secret field must be a `[u8; N]` byte array".to_owned());
229    }
230
231    let array_tokens = array_group.stream().into_iter().collect::<Vec<_>>();
232    parse_array_length(&array_tokens)
233}
234
235fn strip_field_visibility(mut tokens: Vec<TokenTree>) -> Result<Vec<TokenTree>, String> {
236    if matches!(tokens.first(), Some(TokenTree::Ident(ident)) if ident.to_string() == "pub") {
237        tokens.remove(0);
238        if matches!(tokens.first(), Some(TokenTree::Group(group)) if group.delimiter() == Delimiter::Parenthesis)
239        {
240            tokens.remove(0);
241        }
242    }
243    if tokens
244        .iter()
245        .any(|token| matches!(token, TokenTree::Punct(punct) if punct.as_char() == '#'))
246    {
247        return Err("Base64Secret tuple fields may not have field attributes".to_owned());
248    }
249    Ok(tokens)
250}
251
252fn parse_array_length(tokens: &[TokenTree]) -> Result<String, String> {
253    let semicolon = tokens
254        .iter()
255        .position(|token| matches!(token, TokenTree::Punct(punct) if punct.as_char() == ';'))
256        .ok_or_else(|| "Base64Secret field must be `[u8; N]`".to_owned())?;
257
258    let element = tokens[..semicolon]
259        .iter()
260        .map(TokenTree::to_string)
261        .collect::<String>();
262    if element != "u8" {
263        return Err("Base64Secret field element type must be `u8`".to_owned());
264    }
265
266    let length = tokens[semicolon + 1..]
267        .iter()
268        .map(TokenTree::to_string)
269        .collect::<Vec<_>>()
270        .join(" ");
271    if length.is_empty() {
272        return Err("Base64Secret field length is missing".to_owned());
273    }
274    Ok(length)
275}
276
277fn compile_error(message: &str) -> TokenStream {
278    let source = format!("compile_error!({message:?});");
279    match source.parse() {
280        Ok(tokens) => tokens,
281        Err(_) => TokenStream::new(),
282    }
283}