Skip to main content

sanitization_derive/
lib.rs

1#![deny(unsafe_code)]
2#![deny(unsafe_op_in_unsafe_fn)]
3
4use proc_macro::TokenStream;
5use proc_macro2::{Span, TokenStream as TokenStream2};
6use quote::quote;
7use syn::{
8    parse_macro_input, parse_quote, spanned::Spanned, Attribute, Data, DataStruct, DeriveInput,
9    Error, Fields, Generics, LitStr, Path, Result, WherePredicate,
10};
11
12/// Derive `sanitization::SecureSanitize` for structs.
13///
14/// Every non-skipped field must implement `SecureSanitize`. Skipped fields
15/// require a non-empty audit reason:
16///
17/// ```ignore
18/// #[sanitization(skip, reason = "public algorithm identifier")]
19/// ```
20///
21/// Enums are rejected because generated safe code can only reach the active
22/// variant and therefore cannot clear bytes retained from a previously active,
23/// larger variant. Model secret state with a struct plus an explicit public
24/// discriminator, or implement a reviewed transition that calls
25/// `sanitization::secure_replace` before changing representation.
26///
27/// The derive also implements `sanitization::DropSafeSanitize` because its
28/// sanitizer is generated field-wise and cannot replace or destroy `Self`.
29#[proc_macro_derive(SecureSanitize, attributes(sanitization))]
30pub fn derive_secure_sanitize(input: TokenStream) -> TokenStream {
31    let input = parse_macro_input!(input as DeriveInput);
32    expand_secure_sanitize(&input)
33        .unwrap_or_else(Error::into_compile_error)
34        .into()
35}
36
37/// Derive `Drop` by invoking the complete `SecureSanitize` implementation.
38///
39/// The generated destructor requires `Self: DropSafeSanitize + Unpin`.
40/// `#[derive(SecureSanitize)]` provides `DropSafeSanitize` automatically for
41/// its generated field-wise sanitizer. A reviewed manual aggregate sanitizer
42/// must implement `DropSafeSanitize` explicitly; this preserves external
43/// storage, ordering, and platform cleanup while making recursive
44/// self-destruction an explicit contract violation.
45///
46/// Enums are rejected even when they have a manual `SecureSanitize`
47/// implementation. Calling that implementation at final drop cannot clear
48/// inactive bytes retained by ordinary variant transitions. Use a stable-layout
49/// struct state machine, or a reviewed manual `Drop` implementation together
50/// with `sanitization::secure_replace` before every enum transition.
51///
52/// # Generics
53///
54/// For structs with type parameters that hold sanitizable data, the parameter
55/// must carry `SecureSanitize + Unpin` bounds at the type declaration:
56///
57/// ```ignore
58/// use sanitization::SecureSanitize;
59///
60/// #[derive(SecureSanitize, SecureSanitizeOnDrop)]
61/// struct Wrapper<T: SecureSanitize + Unpin> {
62///     inner: T,
63/// }
64/// ```
65///
66/// This is a Rust `Drop` restriction: the generated `Drop` impl cannot add a
67/// stricter `T: SecureSanitize + Unpin` bounds than the struct declaration
68/// itself.
69#[proc_macro_derive(SecureSanitizeOnDrop, attributes(sanitization))]
70pub fn derive_secure_sanitize_on_drop(input: TokenStream) -> TokenStream {
71    let input = parse_macro_input!(input as DeriveInput);
72    expand_secure_sanitize_on_drop(&input)
73        .unwrap_or_else(Error::into_compile_error)
74        .into()
75}
76
77/// Derive `sanitization::ct::ConstantTimeEq` for structs.
78///
79/// The generated implementation compares each non-skipped field through that
80/// field's own `ConstantTimeEq` implementation and combines the hidden
81/// `sanitization::ct::Choice` bits. It never compares raw struct bytes, so
82/// padding and representation details are not read. Every skipped field
83/// requires `#[sanitization(skip, reason = "...")]`.
84///
85/// Enums and unions are rejected. For enums, inactive variant bytes cannot be
86/// reached safely and comparing only the active variant can hide residual
87/// secret bytes from previous variants.
88#[proc_macro_derive(ConstantTimeEq, attributes(sanitization))]
89pub fn derive_constant_time_eq(input: TokenStream) -> TokenStream {
90    let input = parse_macro_input!(input as DeriveInput);
91    expand_constant_time_eq(&input)
92        .unwrap_or_else(Error::into_compile_error)
93        .into()
94}
95
96/// Derive `sanitization::ct::ConditionallySelectable` for structs.
97///
98/// The generated implementation selects every field through that field's own
99/// `ConditionallySelectable` implementation. `#[sanitization(skip)]` is
100/// intentionally rejected for this derive because the output must be a complete
101/// selection between `left` and `right`.
102///
103/// Enums and unions are rejected. Field-wise struct selection avoids raw
104/// representation reads and does not inspect padding bytes.
105#[proc_macro_derive(ConditionallySelectable, attributes(sanitization))]
106pub fn derive_conditionally_selectable(input: TokenStream) -> TokenStream {
107    let input = parse_macro_input!(input as DeriveInput);
108    expand_conditionally_selectable(&input)
109        .unwrap_or_else(Error::into_compile_error)
110        .into()
111}
112
113#[derive(Default)]
114struct ContainerOptions {
115    crate_path: Option<Path>,
116    bound_override: Option<Vec<WherePredicate>>,
117}
118
119#[derive(Default)]
120struct FieldOptions {
121    skip: bool,
122    skip_span: Option<Span>,
123    reason: Option<LitStr>,
124    bound_override: Option<Vec<WherePredicate>>,
125}
126
127fn expand_secure_sanitize(input: &DeriveInput) -> Result<TokenStream2> {
128    let options = parse_container_options(&input.attrs)?;
129    validate_container_option_scope(input, &options)?;
130    let crate_path = crate_path(&options);
131    let body = match &input.data {
132        Data::Struct(data) => expand_struct_body(data, &crate_path)?,
133        Data::Enum(_) => {
134            return Err(Error::new_spanned(
135                input,
136                "SecureSanitize cannot be derived for enums because inactive variant bytes are unreachable; use a struct-based state machine or a reviewed manual implementation that sanitizes before every transition",
137            ))
138        }
139        Data::Union(_) => {
140            return Err(Error::new_spanned(
141                input,
142                "SecureSanitize cannot be derived for unions; implement it manually using documented unsafe code for the active field",
143            ))
144        }
145    };
146    let generics = add_sanitize_bounds(input.generics.clone(), &input.data, &crate_path, &options)?;
147    let name = &input.ident;
148    let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
149
150    Ok(quote! {
151        impl #impl_generics #crate_path::SecureSanitize for #name #type_generics #where_clause {
152            #[inline]
153            fn secure_sanitize(&mut self) {
154                #body
155            }
156        }
157
158        impl #impl_generics #crate_path::DropSafeSanitize for #name #type_generics #where_clause {}
159    })
160}
161
162fn expand_secure_sanitize_on_drop(input: &DeriveInput) -> Result<TokenStream2> {
163    let options = parse_container_options(&input.attrs)?;
164    validate_container_option_scope(input, &options)?;
165    validate_field_options(&input.data)?;
166    let crate_path = crate_path(&options);
167
168    match &input.data {
169        Data::Struct(_) => {}
170        Data::Enum(_) => {
171            return Err(Error::new_spanned(
172                input,
173                "SecureSanitizeOnDrop cannot be derived for enums because final-drop sanitization cannot clear inactive bytes retained by earlier variant transitions; use a stable-layout struct state machine or a reviewed manual Drop implementation with secure_replace before every transition",
174            ))
175        }
176        Data::Union(_) => {
177            return Err(Error::new_spanned(
178                input,
179                "SecureSanitizeOnDrop cannot be derived for unions",
180            ))
181        }
182    }
183
184    let name = &input.ident;
185    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
186
187    Ok(quote! {
188        impl #impl_generics Drop for #name #type_generics #where_clause {
189            #[inline]
190            fn drop(&mut self) {
191                fn require_drop_contract<T: ?Sized + #crate_path::DropSafeSanitize + ::core::marker::Unpin>() {}
192                require_drop_contract::<Self>();
193                #crate_path::SecureSanitize::secure_sanitize(self);
194            }
195        }
196    })
197}
198
199fn expand_constant_time_eq(input: &DeriveInput) -> Result<TokenStream2> {
200    let options = parse_container_options(&input.attrs)?;
201    validate_container_option_scope(input, &options)?;
202    let crate_path = crate_path(&options);
203    let body = match &input.data {
204        Data::Struct(data) => expand_ct_eq_struct_body(data, &crate_path)?,
205        Data::Enum(_) => {
206            return Err(Error::new_spanned(
207                input,
208                "ConstantTimeEq cannot be derived for enums; compare explicit struct wrappers or implement the active-variant semantics manually",
209            ))
210        }
211        Data::Union(_) => {
212            return Err(Error::new_spanned(
213                input,
214                "ConstantTimeEq cannot be derived for unions; implement it manually using documented unsafe code for the active field",
215            ))
216        }
217    };
218    let trait_path: TokenStream2 = quote!(#crate_path::ct::ConstantTimeEq);
219    let generics = add_trait_bounds(
220        input.generics.clone(),
221        &input.data,
222        &trait_path,
223        &options,
224        SkipPolicy::Allow,
225    )?;
226    let name = &input.ident;
227    let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
228
229    Ok(quote! {
230        impl #impl_generics #crate_path::ct::ConstantTimeEq for #name #type_generics #where_clause {
231            #[inline]
232            fn ct_eq(&self, other: &Self) -> #crate_path::ct::Choice {
233                #body
234            }
235        }
236    })
237}
238
239fn expand_conditionally_selectable(input: &DeriveInput) -> Result<TokenStream2> {
240    let options = parse_container_options(&input.attrs)?;
241    validate_container_option_scope(input, &options)?;
242    let crate_path = crate_path(&options);
243    let body = match &input.data {
244        Data::Struct(data) => expand_ct_select_struct_body(data, &crate_path)?,
245        Data::Enum(_) => {
246            return Err(Error::new_spanned(
247                input,
248                "ConditionallySelectable cannot be derived for enums; select explicit struct wrappers or implement the active-variant semantics manually",
249            ))
250        }
251        Data::Union(_) => {
252            return Err(Error::new_spanned(
253                input,
254                "ConditionallySelectable cannot be derived for unions; implement it manually using documented unsafe code for the active field",
255            ))
256        }
257    };
258    let trait_path: TokenStream2 = quote!(#crate_path::ct::ConditionallySelectable);
259    let generics = add_trait_bounds(
260        input.generics.clone(),
261        &input.data,
262        &trait_path,
263        &options,
264        SkipPolicy::Reject,
265    )?;
266    let name = &input.ident;
267    let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
268
269    Ok(quote! {
270        impl #impl_generics #crate_path::ct::ConditionallySelectable for #name #type_generics #where_clause {
271            #[inline]
272            fn conditional_select(
273                left: &Self,
274                right: &Self,
275                choice: #crate_path::ct::Choice,
276            ) -> Self {
277                #body
278            }
279        }
280    })
281}
282
283fn crate_path(options: &ContainerOptions) -> Path {
284    options
285        .crate_path
286        .clone()
287        .unwrap_or_else(|| parse_quote!(::sanitization))
288}
289
290fn validate_container_option_scope(
291    _input: &DeriveInput,
292    _options: &ContainerOptions,
293) -> Result<()> {
294    Ok(())
295}
296
297fn validate_field_options(data: &Data) -> Result<()> {
298    for field in sanitized_fields(data)? {
299        parse_field_options(&field.attrs)?;
300    }
301    Ok(())
302}
303
304fn add_sanitize_bounds(
305    mut generics: Generics,
306    data: &Data,
307    crate_path: &Path,
308    options: &ContainerOptions,
309) -> Result<Generics> {
310    let where_clause = generics.make_where_clause();
311
312    if let Some(bounds) = &options.bound_override {
313        where_clause.predicates.extend(bounds.iter().cloned());
314        return Ok(generics);
315    }
316
317    for field in sanitized_fields(data)? {
318        let field_options = parse_field_options(&field.attrs)?;
319        if field_options.skip {
320            continue;
321        }
322
323        if let Some(bounds) = field_options.bound_override {
324            where_clause.predicates.extend(bounds);
325        } else {
326            let ty = &field.ty;
327            where_clause
328                .predicates
329                .push(parse_quote!(#ty: #crate_path::SecureSanitize));
330        }
331    }
332
333    Ok(generics)
334}
335
336#[derive(Clone, Copy)]
337enum SkipPolicy {
338    Allow,
339    Reject,
340}
341
342fn add_trait_bounds(
343    mut generics: Generics,
344    data: &Data,
345    trait_path: &TokenStream2,
346    options: &ContainerOptions,
347    skip_policy: SkipPolicy,
348) -> Result<Generics> {
349    let where_clause = generics.make_where_clause();
350
351    if let Some(bounds) = &options.bound_override {
352        where_clause.predicates.extend(bounds.iter().cloned());
353        return Ok(generics);
354    }
355
356    for field in sanitized_fields(data)? {
357        let field_options = parse_field_options(&field.attrs)?;
358        if field_options.skip {
359            if matches!(skip_policy, SkipPolicy::Reject) {
360                return Err(Error::new_spanned(
361                    field,
362                    "#[sanitization(skip)] is not supported for this derive because every output field must be constructed",
363                ));
364            }
365            continue;
366        }
367
368        if let Some(bounds) = field_options.bound_override {
369            where_clause.predicates.extend(bounds);
370        } else {
371            let ty = &field.ty;
372            where_clause.predicates.push(parse_quote!(#ty: #trait_path));
373        }
374    }
375
376    Ok(generics)
377}
378
379fn sanitized_fields(data: &Data) -> Result<Vec<&syn::Field>> {
380    let mut fields = Vec::new();
381    match data {
382        Data::Struct(data) => fields.extend(data.fields.iter()),
383        Data::Enum(data) => {
384            for variant in &data.variants {
385                fields.extend(variant.fields.iter());
386            }
387        }
388        Data::Union(_) => {}
389    }
390    Ok(fields)
391}
392
393fn expand_struct_body(data: &DataStruct, crate_path: &Path) -> Result<TokenStream2> {
394    let calls = field_calls_for_struct(&data.fields, crate_path)?;
395    Ok(quote!(#(#calls)*))
396}
397
398fn expand_ct_eq_struct_body(data: &DataStruct, crate_path: &Path) -> Result<TokenStream2> {
399    let mut calls = Vec::new();
400
401    for (index, field) in data.fields.iter().enumerate() {
402        if parse_field_options(&field.attrs)?.skip {
403            continue;
404        }
405
406        let (left, right) = match &field.ident {
407            Some(ident) => (quote!(&self.#ident), quote!(&other.#ident)),
408            None => {
409                let index = syn::Index::from(index);
410                (quote!(&self.#index), quote!(&other.#index))
411            }
412        };
413        calls.push(quote! {
414            result = result & #crate_path::ct::ConstantTimeEq::ct_eq(#left, #right);
415        });
416    }
417
418    Ok(quote! {
419        let mut result = #crate_path::ct::Choice::TRUE;
420        #(#calls)*
421        result
422    })
423}
424
425fn expand_ct_select_struct_body(data: &DataStruct, crate_path: &Path) -> Result<TokenStream2> {
426    match &data.fields {
427        Fields::Named(fields) => {
428            let mut selected = Vec::new();
429            for field in &fields.named {
430                if parse_field_options(&field.attrs)?.skip {
431                    return Err(Error::new_spanned(
432                        field,
433                        "#[sanitization(skip)] is not supported for ConditionallySelectable derives",
434                    ));
435                }
436                let ident = field.ident.as_ref().expect("named field");
437                selected.push(quote! {
438                    #ident: #crate_path::ct::ConditionallySelectable::conditional_select(
439                        &left.#ident,
440                        &right.#ident,
441                        choice,
442                    )
443                });
444            }
445            Ok(quote!(Self { #(#selected),* }))
446        }
447        Fields::Unnamed(fields) => {
448            let mut selected = Vec::new();
449            for (index, field) in fields.unnamed.iter().enumerate() {
450                if parse_field_options(&field.attrs)?.skip {
451                    return Err(Error::new_spanned(
452                        field,
453                        "#[sanitization(skip)] is not supported for ConditionallySelectable derives",
454                    ));
455                }
456                let index = syn::Index::from(index);
457                selected.push(quote! {
458                    #crate_path::ct::ConditionallySelectable::conditional_select(
459                        &left.#index,
460                        &right.#index,
461                        choice,
462                    )
463                });
464            }
465            Ok(quote!(Self(#(#selected),*)))
466        }
467        Fields::Unit => Ok(quote!(Self)),
468    }
469}
470
471fn field_calls_for_struct(fields: &Fields, crate_path: &Path) -> Result<Vec<TokenStream2>> {
472    let mut calls = Vec::new();
473
474    for (index, field) in fields.iter().enumerate() {
475        if parse_field_options(&field.attrs)?.skip {
476            continue;
477        }
478
479        let access = match &field.ident {
480            Some(ident) => quote!(&mut self.#ident),
481            None => {
482                let index = syn::Index::from(index);
483                quote!(&mut self.#index)
484            }
485        };
486        calls.push(quote!(#crate_path::SecureSanitize::secure_sanitize(#access);));
487    }
488
489    Ok(calls)
490}
491
492fn parse_container_options(attrs: &[Attribute]) -> Result<ContainerOptions> {
493    let mut options = ContainerOptions::default();
494
495    for attr in attrs
496        .iter()
497        .filter(|attr| attr.path().is_ident("sanitization"))
498    {
499        attr.parse_nested_meta(|meta| {
500            if meta.path.is_ident("crate") {
501                if options.crate_path.is_some() {
502                    return Err(meta.error("duplicate sanitization crate option"));
503                }
504                let value = meta.value()?;
505                let literal: LitStr = value.parse()?;
506                options.crate_path = Some(literal.parse()?);
507                Ok(())
508            } else if meta.path.is_ident("bound") {
509                if options.bound_override.is_some() {
510                    return Err(meta.error("duplicate sanitization bound option"));
511                }
512                let value = meta.value()?;
513                let literal: LitStr = value.parse()?;
514                options.bound_override = Some(parse_bounds(&literal)?);
515                Ok(())
516            } else {
517                Err(meta.error("unsupported sanitization container attribute"))
518            }
519        })?;
520    }
521
522    Ok(options)
523}
524
525fn parse_field_options(attrs: &[Attribute]) -> Result<FieldOptions> {
526    let mut options = FieldOptions::default();
527
528    for attr in attrs
529        .iter()
530        .filter(|attr| attr.path().is_ident("sanitization"))
531    {
532        attr.parse_nested_meta(|meta| {
533            if meta.path.is_ident("skip") {
534                if options.skip {
535                    return Err(meta.error("duplicate sanitization skip option"));
536                }
537                options.skip = true;
538                options.skip_span = Some(meta.path.span());
539                Ok(())
540            } else if meta.path.is_ident("reason") {
541                if options.reason.is_some() {
542                    return Err(meta.error("duplicate sanitization reason option"));
543                }
544                let value = meta.value()?;
545                let literal: LitStr = value.parse()?;
546                if literal.value().trim().is_empty() {
547                    return Err(meta.error("sanitization skip reason must not be empty"));
548                }
549                options.reason = Some(literal);
550                Ok(())
551            } else if meta.path.is_ident("bound") {
552                if options.bound_override.is_some() {
553                    return Err(meta.error("duplicate sanitization bound option"));
554                }
555                let value = meta.value()?;
556                let literal: LitStr = value.parse()?;
557                options.bound_override = Some(parse_bounds(&literal)?);
558                Ok(())
559            } else {
560                Err(meta.error("unsupported sanitization field attribute"))
561            }
562        })?;
563    }
564
565    if options.skip && options.reason.is_none() {
566        return Err(Error::new(
567            options.skip_span.unwrap_or_else(Span::call_site),
568            "#[sanitization(skip)] requires a non-empty reason, for example #[sanitization(skip, reason = \"public metadata\")]",
569        ));
570    }
571    if !options.skip {
572        if let Some(reason) = options.reason {
573            return Err(Error::new_spanned(
574                reason,
575                "sanitization reason is only valid together with skip",
576            ));
577        }
578    }
579
580    Ok(options)
581}
582
583fn parse_bounds(literal: &LitStr) -> Result<Vec<WherePredicate>> {
584    let text = literal.value();
585    if text.trim().is_empty() {
586        return Ok(Vec::new());
587    }
588
589    let where_clause: syn::WhereClause = syn::parse_str(&format!("where {text}"))
590        .map_err(|error| Error::new(literal.span(), error))?;
591    Ok(where_clause.predicates.into_iter().collect())
592}