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