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/// The generated `Display` implementation suppresses
152/// `unused_variables`/`unused_assignments` warnings in its match arm bindings,
153/// since omission from the template is often intentional.
154///
155/// Classified fields referenced in the template are redacted by applying the
156/// policy to an owned copy of the field value, so those field types must
157/// implement `Clone`.
158#[proc_macro_derive(SensitiveError, attributes(sensitive, error))]
159pub fn derive_sensitive_error(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
160    let input = parse_macro_input!(input as DeriveInput);
161    match expand(input, SlogMode::RedactedDisplayString) {
162        Ok(tokens) => tokens.into(),
163        Err(err) => err.into_compile_error().into(),
164    }
165}
166
167/// Returns the token stream to reference the redaction crate root.
168///
169/// Handles crate renaming (e.g., `my_redact = { package = "redaction", ... }`)
170/// and internal usage (when derive is used inside the redaction crate itself).
171fn crate_root() -> proc_macro2::TokenStream {
172    match crate_name("redaction") {
173        Ok(FoundCrate::Itself) => quote! { crate },
174        Ok(FoundCrate::Name(name)) => {
175            let ident = format_ident!("{}", name);
176            quote! { ::#ident }
177        }
178        Err(_) => quote! { ::redaction },
179    }
180}
181
182/// Returns the token stream to reference the slog crate root.
183///
184/// Handles crate renaming (e.g., `my_slog = { package = "slog", ... }`).
185/// If the top-level `slog` crate is not available, falls back to the
186/// `REDACTION_SLOG_CRATE` env var, which should be a path like `my_log::slog`.
187#[cfg(feature = "slog")]
188fn slog_crate() -> Result<proc_macro2::TokenStream> {
189    match crate_name("slog") {
190        Ok(FoundCrate::Itself) => Ok(quote! { crate }),
191        Ok(FoundCrate::Name(name)) => {
192            let ident = format_ident!("{}", name);
193            Ok(quote! { ::#ident })
194        }
195        Err(_) => {
196            let env_value = std::env::var("REDACTION_SLOG_CRATE").map_err(|_| {
197                syn::Error::new(
198                    Span::call_site(),
199                    "slog support is enabled, but no top-level `slog` crate was found. \
200Set the REDACTION_SLOG_CRATE env var to a path (e.g., `my_log::slog`) or add \
201`slog` as a direct dependency.",
202                )
203            })?;
204            let path = syn::parse_str::<syn::Path>(&env_value).map_err(|_| {
205                syn::Error::new(
206                    Span::call_site(),
207                    format!("REDACTION_SLOG_CRATE must be a valid Rust path (got `{env_value}`)"),
208                )
209            })?;
210            Ok(quote! { #path })
211        }
212    }
213}
214
215fn crate_path(item: &str) -> proc_macro2::TokenStream {
216    let root = crate_root();
217    let item_ident = syn::parse_str::<syn::Path>(item).expect("redaction crate path should parse");
218    quote! { #root::#item_ident }
219}
220
221struct DeriveOutput {
222    redaction_body: TokenStream,
223    used_generics: Vec<Ident>,
224    classified_generics: Vec<Ident>,
225    debug_redacted_body: TokenStream,
226    debug_redacted_generics: Vec<Ident>,
227    debug_unredacted_body: TokenStream,
228    debug_unredacted_generics: Vec<Ident>,
229    redacted_display_body: Option<TokenStream>,
230    redacted_display_generics: Vec<Ident>,
231    redacted_display_debug_generics: Vec<Ident>,
232    redacted_display_clone_generics: Vec<Ident>,
233    redacted_display_nested_generics: Vec<Ident>,
234}
235
236enum SlogMode {
237    RedactedJson,
238    RedactedDisplayString,
239}
240
241#[allow(clippy::too_many_lines)]
242fn expand(input: DeriveInput, slog_mode: SlogMode) -> Result<TokenStream> {
243    let DeriveInput {
244        ident,
245        generics,
246        data,
247        attrs,
248        ..
249    } = input;
250
251    let ContainerOptions { skip_debug } = parse_container_options(&attrs)?;
252
253    let crate_root = crate_root();
254
255    let redacted_display_output = if matches!(slog_mode, SlogMode::RedactedDisplayString) {
256        Some(derive_redacted_display(&ident, &data, &attrs, &generics)?)
257    } else {
258        None
259    };
260
261    let derive_output = match &data {
262        Data::Struct(data) => {
263            let output = derive_struct(&ident, data.clone(), &generics)?;
264            DeriveOutput {
265                redaction_body: output.redaction_body,
266                used_generics: output.used_generics,
267                classified_generics: output.classified_generics,
268                debug_redacted_body: output.debug_redacted_body,
269                debug_redacted_generics: output.debug_redacted_generics,
270                debug_unredacted_body: output.debug_unredacted_body,
271                debug_unredacted_generics: output.debug_unredacted_generics,
272                redacted_display_body: redacted_display_output
273                    .as_ref()
274                    .map(|output| output.body.clone()),
275                redacted_display_generics: redacted_display_output
276                    .as_ref()
277                    .map(|output| output.display_generics.clone())
278                    .unwrap_or_default(),
279                redacted_display_debug_generics: redacted_display_output
280                    .as_ref()
281                    .map(|output| output.debug_generics.clone())
282                    .unwrap_or_default(),
283                redacted_display_clone_generics: redacted_display_output
284                    .as_ref()
285                    .map(|output| output.clone_generics.clone())
286                    .unwrap_or_default(),
287                redacted_display_nested_generics: redacted_display_output
288                    .as_ref()
289                    .map(|output| output.nested_generics.clone())
290                    .unwrap_or_default(),
291            }
292        }
293        Data::Enum(data) => {
294            let output = derive_enum(&ident, data.clone(), &generics)?;
295            DeriveOutput {
296                redaction_body: output.redaction_body,
297                used_generics: output.used_generics,
298                classified_generics: output.classified_generics,
299                debug_redacted_body: output.debug_redacted_body,
300                debug_redacted_generics: output.debug_redacted_generics,
301                debug_unredacted_body: output.debug_unredacted_body,
302                debug_unredacted_generics: output.debug_unredacted_generics,
303                redacted_display_body: redacted_display_output
304                    .as_ref()
305                    .map(|output| output.body.clone()),
306                redacted_display_generics: redacted_display_output
307                    .as_ref()
308                    .map(|output| output.display_generics.clone())
309                    .unwrap_or_default(),
310                redacted_display_debug_generics: redacted_display_output
311                    .as_ref()
312                    .map(|output| output.debug_generics.clone())
313                    .unwrap_or_default(),
314                redacted_display_clone_generics: redacted_display_output
315                    .as_ref()
316                    .map(|output| output.clone_generics.clone())
317                    .unwrap_or_default(),
318                redacted_display_nested_generics: redacted_display_output
319                    .as_ref()
320                    .map(|output| output.nested_generics.clone())
321                    .unwrap_or_default(),
322            }
323        }
324        Data::Union(u) => {
325            return Err(syn::Error::new(
326                u.union_token.span(),
327                "`Sensitive` cannot be derived for unions",
328            ));
329        }
330    };
331
332    let classify_generics = add_container_bounds(generics.clone(), &derive_output.used_generics);
333    let classify_generics =
334        add_classified_value_bounds(classify_generics, &derive_output.classified_generics);
335    let (impl_generics, ty_generics, where_clause) = classify_generics.split_for_impl();
336    let debug_redacted_generics =
337        add_debug_bounds(generics.clone(), &derive_output.debug_redacted_generics);
338    let (debug_redacted_impl_generics, debug_redacted_ty_generics, debug_redacted_where_clause) =
339        debug_redacted_generics.split_for_impl();
340    let debug_unredacted_generics =
341        add_debug_bounds(generics.clone(), &derive_output.debug_unredacted_generics);
342    let (
343        debug_unredacted_impl_generics,
344        debug_unredacted_ty_generics,
345        debug_unredacted_where_clause,
346    ) = debug_unredacted_generics.split_for_impl();
347    let redaction_body = &derive_output.redaction_body;
348    let debug_redacted_body = &derive_output.debug_redacted_body;
349    let debug_unredacted_body = &derive_output.debug_unredacted_body;
350    let debug_impl = if skip_debug {
351        quote! {}
352    } else {
353        quote! {
354            #[cfg(any(test, feature = "testing"))]
355            impl #debug_unredacted_impl_generics ::core::fmt::Debug for #ident #debug_unredacted_ty_generics #debug_unredacted_where_clause {
356                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
357                    #debug_unredacted_body
358                }
359            }
360
361            #[cfg(not(any(test, feature = "testing")))]
362            #[allow(unused_variables, unused_assignments)]
363            impl #debug_redacted_impl_generics ::core::fmt::Debug for #ident #debug_redacted_ty_generics #debug_redacted_where_clause {
364                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
365                    #debug_redacted_body
366                }
367            }
368        }
369    };
370
371    let redacted_display_body = derive_output.redacted_display_body.as_ref();
372    let redacted_display_impl = if matches!(slog_mode, SlogMode::RedactedDisplayString) {
373        let redacted_display_generics =
374            add_display_bounds(generics.clone(), &derive_output.redacted_display_generics);
375        let redacted_display_generics = add_debug_bounds(
376            redacted_display_generics,
377            &derive_output.redacted_display_debug_generics,
378        );
379        let redacted_display_generics = add_clone_bounds(
380            redacted_display_generics,
381            &derive_output.redacted_display_clone_generics,
382        );
383        let redacted_display_generics = add_redacted_display_bounds(
384            redacted_display_generics,
385            &derive_output.redacted_display_nested_generics,
386        );
387        let (display_impl_generics, display_ty_generics, display_where_clause) =
388            redacted_display_generics.split_for_impl();
389        let redacted_display_body = redacted_display_body
390            .cloned()
391            .unwrap_or_else(TokenStream::new);
392        quote! {
393            impl #display_impl_generics #crate_root::slog::RedactedDisplay for #ident #display_ty_generics #display_where_clause {
394                fn fmt_redacted(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
395                    #redacted_display_body
396                }
397            }
398        }
399    } else {
400        quote! {}
401    };
402
403    // Only generate slog impl when the slog feature is enabled on redaction-derive.
404    // If slog is not available, emit a clear error with instructions.
405    #[cfg(feature = "slog")]
406    let slog_impl = {
407        let slog_crate = slog_crate()?;
408        let mut slog_generics = generics;
409        let slog_where_clause = slog_generics.make_where_clause();
410        let self_ty: syn::Type = parse_quote!(#ident #ty_generics);
411        match slog_mode {
412            SlogMode::RedactedJson => {
413                slog_where_clause
414                    .predicates
415                    .push(parse_quote!(#self_ty: ::core::clone::Clone));
416                // IntoRedactedJson requires Self: Serialize, so we add this bound to enable
417                // generic types to work with slog when their type parameters implement Serialize.
418                slog_where_clause
419                    .predicates
420                    .push(parse_quote!(#self_ty: ::serde::Serialize));
421                slog_where_clause
422                    .predicates
423                    .push(parse_quote!(#self_ty: #crate_root::slog::IntoRedactedJson));
424                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
425                    slog_generics.split_for_impl();
426                quote! {
427                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
428                        fn serialize(
429                            &self,
430                            _record: &#slog_crate::Record<'_>,
431                            key: #slog_crate::Key,
432                            serializer: &mut dyn #slog_crate::Serializer,
433                        ) -> #slog_crate::Result {
434                            let redacted = #crate_root::slog::IntoRedactedJson::into_redacted_json(self.clone());
435                            #slog_crate::Value::serialize(&redacted, _record, key, serializer)
436                        }
437                    }
438                }
439            }
440            SlogMode::RedactedDisplayString => {
441                slog_where_clause
442                    .predicates
443                    .push(parse_quote!(#self_ty: #crate_root::slog::RedactedDisplay));
444                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
445                    slog_generics.split_for_impl();
446                quote! {
447                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
448                        fn serialize(
449                            &self,
450                            _record: &#slog_crate::Record<'_>,
451                            key: #slog_crate::Key,
452                            serializer: &mut dyn #slog_crate::Serializer,
453                        ) -> #slog_crate::Result {
454                            let redacted = #crate_root::slog::RedactedDisplay::redacted_display(self);
455                            serializer.emit_arguments(key, &format_args!("{}", redacted))
456                        }
457                    }
458                }
459            }
460        }
461    };
462
463    #[cfg(not(feature = "slog"))]
464    let slog_impl = quote! {};
465
466    let trait_impl = quote! {
467        #[allow(unused_assignments)]
468        impl #impl_generics #crate_root::SensitiveType for #ident #ty_generics #where_clause {
469            fn redact_with<M: #crate_root::RedactionMapper>(self, mapper: &M) -> Self {
470                use #crate_root::SensitiveType as _;
471                #redaction_body
472            }
473        }
474
475        #debug_impl
476
477        #redacted_display_impl
478
479        #slog_impl
480
481        // `slog` already provides `impl<V: Value> Value for &V`, so a reference
482        // impl here would conflict with the blanket impl.
483    };
484    Ok(trait_impl)
485}