Skip to main content

redaction_derive/
lib.rs

1//! Derive macros for `redaction`.
2//!
3//! This crate generates traversal code behind `#[derive(Sensitive)]` and
4//! `#[derive(SensitiveError)]`. It:
5//! - reads `#[sensitive(...)]` field attributes
6//! - emits a `SensitiveType` implementation that calls into a mapper
7//!
8//! It does **not** define classifications or policies. Those live in the main
9//! `redaction` crate and are applied at runtime.
10
11// <https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html>
12#![warn(
13    anonymous_parameters,
14    bare_trait_objects,
15    elided_lifetimes_in_paths,
16    missing_copy_implementations,
17    rust_2018_idioms,
18    trivial_casts,
19    trivial_numeric_casts,
20    unreachable_pub,
21    unsafe_code,
22    unused_extern_crates,
23    unused_import_braces
24)]
25// <https://rust-lang.github.io/rust-clippy/stable>
26#![warn(
27    clippy::all,
28    clippy::cargo,
29    clippy::dbg_macro,
30    clippy::float_cmp_const,
31    clippy::get_unwrap,
32    clippy::mem_forget,
33    clippy::nursery,
34    clippy::pedantic,
35    clippy::todo,
36    clippy::unwrap_used,
37    clippy::uninlined_format_args
38)]
39// Allow some clippy lints
40#![allow(
41    clippy::default_trait_access,
42    clippy::doc_markdown,
43    clippy::if_not_else,
44    clippy::module_name_repetitions,
45    clippy::multiple_crate_versions,
46    clippy::must_use_candidate,
47    clippy::needless_pass_by_value,
48    clippy::needless_ifs,
49    clippy::use_self,
50    clippy::cargo_common_metadata,
51    clippy::missing_errors_doc,
52    clippy::enum_glob_use,
53    clippy::struct_excessive_bools,
54    clippy::missing_const_for_fn,
55    clippy::redundant_pub_crate,
56    clippy::result_large_err,
57    clippy::future_not_send,
58    clippy::option_if_let_else,
59    clippy::from_over_into,
60    clippy::manual_inspect
61)]
62// Allow some lints while testing
63#![cfg_attr(test, allow(clippy::non_ascii_literal, clippy::unwrap_used))]
64
65#[allow(unused_extern_crates)]
66extern crate proc_macro;
67
68#[cfg(feature = "slog")]
69use proc_macro2::Span;
70use proc_macro2::{Ident, TokenStream};
71use proc_macro_crate::{crate_name, FoundCrate};
72use quote::{format_ident, quote};
73#[cfg(feature = "slog")]
74use syn::parse_quote;
75use syn::{parse_macro_input, spanned::Spanned, Data, DeriveInput, Result};
76
77mod container;
78mod derive_enum;
79mod derive_struct;
80mod generics;
81mod redacted_display;
82mod strategy;
83mod transform;
84mod types;
85use container::{parse_container_options, ContainerOptions};
86use derive_enum::derive_enum;
87use derive_struct::derive_struct;
88use generics::{
89    add_classified_value_bounds, add_clone_bounds, add_container_bounds, add_debug_bounds,
90    add_display_bounds, add_redacted_display_bounds,
91};
92use redacted_display::derive_redacted_display;
93
94/// Derives `redaction::SensitiveType` (and related impls) for structs and enums.
95///
96/// # Container Attributes
97///
98/// These attributes are placed on the struct/enum itself:
99///
100/// - `#[sensitive(skip_debug)]` - Opt out of `Debug` impl generation. Use this when you need a
101///   custom `Debug` implementation or the type already derives `Debug` elsewhere.
102///
103/// # Field Attributes
104///
105/// - **No annotation**: The field passes through unchanged. Use this for fields that don't contain
106///   sensitive data, including external types like `chrono::DateTime` or `rust_decimal::Decimal`.
107///
108/// - `#[sensitive]`: For scalar types (i32, bool, char, etc.), redacts to default values (0, false,
109///   'X'). For struct/enum types that derive `Sensitive`, walks into them using `SensitiveType`.
110///
111/// - `#[sensitive(Classification)]`: Treats the field as a sensitive string-like value and applies
112///   the classification's policy. Works for `String`, `Option<String>`, `Vec<String>`, `Box<String>`.
113///   The type must implement `SensitiveValue`.
114///
115/// - `#[sensitive]` on `Box<dyn Trait>`: The derive detects the specific syntax
116///   `Box<dyn Trait>` and calls `redaction::redact_boxed`. This only matches the
117///   unqualified form (not `std::boxed::Box<dyn Trait>` or aliases). The trait
118///   object must implement `RedactableBoxed`.
119///
120/// Unions are rejected at compile time.
121///
122/// # Additional Generated Impls
123///
124/// - `Debug`: when *not* building with `cfg(any(test, feature = "testing"))`, sensitive fields are
125///   formatted as the string `"[REDACTED]"` rather than their values. Use `#[sensitive(skip_debug)]`
126///   on the container to opt out.
127/// - `slog::Value` (behind `cfg(feature = "slog")`): implemented by cloning the value and routing
128///   it through `redaction::slog::IntoRedactedJson`. **Note:** this impl requires `Clone` and
129///   `serde::Serialize` because it emits structured JSON. The derive first looks for a top-level
130///   `slog` crate; if not found, it checks the `REDACTION_SLOG_CRATE` env var for an alternate path
131///   (e.g., `my_log::slog`). If neither is available, compilation fails with a clear error.
132#[proc_macro_derive(Sensitive, attributes(sensitive))]
133pub fn derive_sensitive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
134    let input = parse_macro_input!(input as DeriveInput);
135    match expand(input, SlogMode::RedactedJson) {
136        Ok(tokens) => tokens.into(),
137        Err(err) => err.into_compile_error().into(),
138    }
139}
140
141/// Derives `redaction::SensitiveType` for types that should log without `Serialize`.
142///
143/// This emits the same traversal and redacted `Debug` impls as `Sensitive`, but uses
144/// a `slog::Value` implementation that logs a redacted string derived from a
145/// display template.
146///
147/// The display template is taken from `#[error("...")]` (thiserror-style) or from
148/// doc comments (displaydoc-style). If neither is present, the derive fails with a
149/// compile error to avoid accidental exposure of sensitive fields.
150///
151/// Classified fields referenced in the template are redacted by applying the
152/// policy to an owned copy of the field value, so those field types must
153/// implement `Clone`.
154#[proc_macro_derive(SensitiveError, attributes(sensitive, error))]
155pub fn derive_sensitive_error(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
156    let input = parse_macro_input!(input as DeriveInput);
157    match expand(input, SlogMode::RedactedDisplayString) {
158        Ok(tokens) => tokens.into(),
159        Err(err) => err.into_compile_error().into(),
160    }
161}
162
163/// Returns the token stream to reference the redaction crate root.
164///
165/// Handles crate renaming (e.g., `my_redact = { package = "redaction", ... }`)
166/// and internal usage (when derive is used inside the redaction crate itself).
167fn crate_root() -> proc_macro2::TokenStream {
168    match crate_name("redaction") {
169        Ok(FoundCrate::Itself) => quote! { crate },
170        Ok(FoundCrate::Name(name)) => {
171            let ident = format_ident!("{}", name);
172            quote! { ::#ident }
173        }
174        Err(_) => quote! { ::redaction },
175    }
176}
177
178/// Returns the token stream to reference the slog crate root.
179///
180/// Handles crate renaming (e.g., `my_slog = { package = "slog", ... }`).
181/// If the top-level `slog` crate is not available, falls back to the
182/// `REDACTION_SLOG_CRATE` env var, which should be a path like `my_log::slog`.
183#[cfg(feature = "slog")]
184fn slog_crate() -> Result<proc_macro2::TokenStream> {
185    match crate_name("slog") {
186        Ok(FoundCrate::Itself) => Ok(quote! { crate }),
187        Ok(FoundCrate::Name(name)) => {
188            let ident = format_ident!("{}", name);
189            Ok(quote! { ::#ident })
190        }
191        Err(_) => {
192            let env_value = std::env::var("REDACTION_SLOG_CRATE").map_err(|_| {
193                syn::Error::new(
194                    Span::call_site(),
195                    "slog support is enabled, but no top-level `slog` crate was found. \
196Set the REDACTION_SLOG_CRATE env var to a path (e.g., `my_log::slog`) or add \
197`slog` as a direct dependency.",
198                )
199            })?;
200            let path = syn::parse_str::<syn::Path>(&env_value).map_err(|_| {
201                syn::Error::new(
202                    Span::call_site(),
203                    format!("REDACTION_SLOG_CRATE must be a valid Rust path (got `{env_value}`)"),
204                )
205            })?;
206            Ok(quote! { #path })
207        }
208    }
209}
210
211fn crate_path(item: &str) -> proc_macro2::TokenStream {
212    let root = crate_root();
213    let item_ident = syn::parse_str::<syn::Path>(item).expect("redaction crate path should parse");
214    quote! { #root::#item_ident }
215}
216
217struct DeriveOutput {
218    redaction_body: TokenStream,
219    used_generics: Vec<Ident>,
220    classified_generics: Vec<Ident>,
221    debug_redacted_body: TokenStream,
222    debug_redacted_generics: Vec<Ident>,
223    debug_unredacted_body: TokenStream,
224    debug_unredacted_generics: Vec<Ident>,
225    redacted_display_body: Option<TokenStream>,
226    redacted_display_generics: Vec<Ident>,
227    redacted_display_debug_generics: Vec<Ident>,
228    redacted_display_clone_generics: Vec<Ident>,
229    redacted_display_nested_generics: Vec<Ident>,
230}
231
232enum SlogMode {
233    RedactedJson,
234    RedactedDisplayString,
235}
236
237#[allow(clippy::too_many_lines)]
238fn expand(input: DeriveInput, slog_mode: SlogMode) -> Result<TokenStream> {
239    let DeriveInput {
240        ident,
241        generics,
242        data,
243        attrs,
244        ..
245    } = input;
246
247    let ContainerOptions { skip_debug } = parse_container_options(&attrs)?;
248
249    let crate_root = crate_root();
250
251    let redacted_display_output = if matches!(slog_mode, SlogMode::RedactedDisplayString) {
252        Some(derive_redacted_display(&ident, &data, &attrs, &generics)?)
253    } else {
254        None
255    };
256
257    let derive_output = match &data {
258        Data::Struct(data) => {
259            let output = derive_struct(&ident, data.clone(), &generics)?;
260            DeriveOutput {
261                redaction_body: output.redaction_body,
262                used_generics: output.used_generics,
263                classified_generics: output.classified_generics,
264                debug_redacted_body: output.debug_redacted_body,
265                debug_redacted_generics: output.debug_redacted_generics,
266                debug_unredacted_body: output.debug_unredacted_body,
267                debug_unredacted_generics: output.debug_unredacted_generics,
268                redacted_display_body: redacted_display_output
269                    .as_ref()
270                    .map(|output| output.body.clone()),
271                redacted_display_generics: redacted_display_output
272                    .as_ref()
273                    .map(|output| output.display_generics.clone())
274                    .unwrap_or_default(),
275                redacted_display_debug_generics: redacted_display_output
276                    .as_ref()
277                    .map(|output| output.debug_generics.clone())
278                    .unwrap_or_default(),
279                redacted_display_clone_generics: redacted_display_output
280                    .as_ref()
281                    .map(|output| output.clone_generics.clone())
282                    .unwrap_or_default(),
283                redacted_display_nested_generics: redacted_display_output
284                    .as_ref()
285                    .map(|output| output.nested_generics.clone())
286                    .unwrap_or_default(),
287            }
288        }
289        Data::Enum(data) => {
290            let output = derive_enum(&ident, data.clone(), &generics)?;
291            DeriveOutput {
292                redaction_body: output.redaction_body,
293                used_generics: output.used_generics,
294                classified_generics: output.classified_generics,
295                debug_redacted_body: output.debug_redacted_body,
296                debug_redacted_generics: output.debug_redacted_generics,
297                debug_unredacted_body: output.debug_unredacted_body,
298                debug_unredacted_generics: output.debug_unredacted_generics,
299                redacted_display_body: redacted_display_output
300                    .as_ref()
301                    .map(|output| output.body.clone()),
302                redacted_display_generics: redacted_display_output
303                    .as_ref()
304                    .map(|output| output.display_generics.clone())
305                    .unwrap_or_default(),
306                redacted_display_debug_generics: redacted_display_output
307                    .as_ref()
308                    .map(|output| output.debug_generics.clone())
309                    .unwrap_or_default(),
310                redacted_display_clone_generics: redacted_display_output
311                    .as_ref()
312                    .map(|output| output.clone_generics.clone())
313                    .unwrap_or_default(),
314                redacted_display_nested_generics: redacted_display_output
315                    .as_ref()
316                    .map(|output| output.nested_generics.clone())
317                    .unwrap_or_default(),
318            }
319        }
320        Data::Union(u) => {
321            return Err(syn::Error::new(
322                u.union_token.span(),
323                "`Sensitive` cannot be derived for unions",
324            ));
325        }
326    };
327
328    let classify_generics = add_container_bounds(generics.clone(), &derive_output.used_generics);
329    let classify_generics =
330        add_classified_value_bounds(classify_generics, &derive_output.classified_generics);
331    let (impl_generics, ty_generics, where_clause) = classify_generics.split_for_impl();
332    let debug_redacted_generics =
333        add_debug_bounds(generics.clone(), &derive_output.debug_redacted_generics);
334    let (debug_redacted_impl_generics, debug_redacted_ty_generics, debug_redacted_where_clause) =
335        debug_redacted_generics.split_for_impl();
336    let debug_unredacted_generics =
337        add_debug_bounds(generics.clone(), &derive_output.debug_unredacted_generics);
338    let (
339        debug_unredacted_impl_generics,
340        debug_unredacted_ty_generics,
341        debug_unredacted_where_clause,
342    ) = debug_unredacted_generics.split_for_impl();
343    let redaction_body = &derive_output.redaction_body;
344    let debug_redacted_body = &derive_output.debug_redacted_body;
345    let debug_unredacted_body = &derive_output.debug_unredacted_body;
346    let debug_impl = if skip_debug {
347        quote! {}
348    } else {
349        quote! {
350            #[cfg(any(test, feature = "testing"))]
351            impl #debug_unredacted_impl_generics ::core::fmt::Debug for #ident #debug_unredacted_ty_generics #debug_unredacted_where_clause {
352                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
353                    #debug_unredacted_body
354                }
355            }
356
357            #[cfg(not(any(test, feature = "testing")))]
358            #[allow(unused_variables)]
359            impl #debug_redacted_impl_generics ::core::fmt::Debug for #ident #debug_redacted_ty_generics #debug_redacted_where_clause {
360                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
361                    #debug_redacted_body
362                }
363            }
364        }
365    };
366
367    let redacted_display_body = derive_output.redacted_display_body.as_ref();
368    let redacted_display_impl = if matches!(slog_mode, SlogMode::RedactedDisplayString) {
369        let redacted_display_generics =
370            add_display_bounds(generics.clone(), &derive_output.redacted_display_generics);
371        let redacted_display_generics = add_debug_bounds(
372            redacted_display_generics,
373            &derive_output.redacted_display_debug_generics,
374        );
375        let redacted_display_generics = add_clone_bounds(
376            redacted_display_generics,
377            &derive_output.redacted_display_clone_generics,
378        );
379        let redacted_display_generics = add_redacted_display_bounds(
380            redacted_display_generics,
381            &derive_output.redacted_display_nested_generics,
382        );
383        let (display_impl_generics, display_ty_generics, display_where_clause) =
384            redacted_display_generics.split_for_impl();
385        let redacted_display_body = redacted_display_body
386            .cloned()
387            .unwrap_or_else(TokenStream::new);
388        quote! {
389            impl #display_impl_generics #crate_root::slog::RedactedDisplay for #ident #display_ty_generics #display_where_clause {
390                fn fmt_redacted(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
391                    #redacted_display_body
392                }
393            }
394        }
395    } else {
396        quote! {}
397    };
398
399    // Only generate slog impl when the slog feature is enabled on redaction-derive.
400    // If slog is not available, emit a clear error with instructions.
401    #[cfg(feature = "slog")]
402    let slog_impl = {
403        let slog_crate = slog_crate()?;
404        let mut slog_generics = generics;
405        let slog_where_clause = slog_generics.make_where_clause();
406        let self_ty: syn::Type = parse_quote!(#ident #ty_generics);
407        match slog_mode {
408            SlogMode::RedactedJson => {
409                slog_where_clause
410                    .predicates
411                    .push(parse_quote!(#self_ty: ::core::clone::Clone));
412                // IntoRedactedJson requires Self: Serialize, so we add this bound to enable
413                // generic types to work with slog when their type parameters implement Serialize.
414                slog_where_clause
415                    .predicates
416                    .push(parse_quote!(#self_ty: ::serde::Serialize));
417                slog_where_clause
418                    .predicates
419                    .push(parse_quote!(#self_ty: #crate_root::slog::IntoRedactedJson));
420                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
421                    slog_generics.split_for_impl();
422                quote! {
423                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
424                        fn serialize(
425                            &self,
426                            _record: &#slog_crate::Record<'_>,
427                            key: #slog_crate::Key,
428                            serializer: &mut dyn #slog_crate::Serializer,
429                        ) -> #slog_crate::Result {
430                            let redacted = #crate_root::slog::IntoRedactedJson::into_redacted_json(self.clone());
431                            #slog_crate::Value::serialize(&redacted, _record, key, serializer)
432                        }
433                    }
434                }
435            }
436            SlogMode::RedactedDisplayString => {
437                slog_where_clause
438                    .predicates
439                    .push(parse_quote!(#self_ty: #crate_root::slog::RedactedDisplay));
440                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
441                    slog_generics.split_for_impl();
442                quote! {
443                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
444                        fn serialize(
445                            &self,
446                            _record: &#slog_crate::Record<'_>,
447                            key: #slog_crate::Key,
448                            serializer: &mut dyn #slog_crate::Serializer,
449                        ) -> #slog_crate::Result {
450                            let redacted = #crate_root::slog::RedactedDisplay::redacted_display(self);
451                            serializer.emit_arguments(key, &format_args!("{}", redacted))
452                        }
453                    }
454                }
455            }
456        }
457    };
458
459    #[cfg(not(feature = "slog"))]
460    let slog_impl = quote! {};
461
462    let trait_impl = quote! {
463        impl #impl_generics #crate_root::SensitiveType for #ident #ty_generics #where_clause {
464            fn redact_with<M: #crate_root::RedactionMapper>(self, mapper: &M) -> Self {
465                use #crate_root::SensitiveType as _;
466                #redaction_body
467            }
468        }
469
470        #debug_impl
471
472        #redacted_display_impl
473
474        #slog_impl
475
476        // `slog` already provides `impl<V: Value> Value for &V`, so a reference
477        // impl here would conflict with the blanket impl.
478    };
479    Ok(trait_impl)
480}