Skip to main content

redactable_derive/
lib.rs

1//! Derive macros for `redactable`.
2//!
3//! This crate generates traversal code behind `#[derive(Sensitive)]` and
4//! `#[derive(SensitiveError)]`. It:
5//! - reads `#[sensitive(...)]` field attributes
6//! - emits a `RedactableContainer` implementation that calls into a mapper
7//!
8//! It does **not** define policy markers or text policies. Those live in the main
9//! `redactable` 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
68use proc_macro_crate::{FoundCrate, crate_name};
69#[cfg(feature = "slog")]
70use proc_macro2::Span;
71use proc_macro2::{Ident, TokenStream};
72use quote::{format_ident, quote};
73#[cfg(feature = "slog")]
74use syn::parse_quote;
75use syn::{Data, DeriveInput, Result, parse_macro_input, spanned::Spanned};
76
77mod container;
78mod derive_enum;
79mod derive_struct;
80mod generics;
81mod redacted_display;
82mod strategy;
83mod transform;
84mod types;
85use container::{ContainerOptions, parse_container_options};
86use derive_enum::derive_enum;
87use derive_struct::derive_struct;
88use generics::{
89    add_clone_bounds, add_container_bounds, add_debug_bounds, add_display_bounds,
90    add_policy_applicable_bounds, add_redacted_display_bounds,
91};
92use redacted_display::derive_redacted_display;
93
94/// Derives `redactable::RedactableContainer` (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 is traversed by default. Scalars pass through unchanged; nested
106///   structs/enums are walked using `RedactableContainer` (so external types must implement it).
107///
108/// - `#[sensitive(Default)]`: For scalar types (i32, bool, char, etc.), redacts to default values
109///   (0, false, '*'). For string-like types, applies full redaction to `"[REDACTED]"`.
110///
111/// - `#[sensitive(Policy)]`: Applies the policy's redaction rules to string-like
112///   values. Works for `String`, `Option<String>`, `Vec<String>`, `Box<String>`. Scalars can only
113///   use `#[sensitive(Default)]`.
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 `redactable::slog::SlogRedactedExt`. **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 `REDACTABLE_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_container(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#[proc_macro_derive(SensitiveData, attributes(sensitive))]
137pub fn derive_sensitive_data(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
138    derive_sensitive_container(input)
139}
140
141/// Derives a no-op `redactable::RedactableContainer` implementation.
142///
143/// This is useful for types that are known to be non-sensitive but still need to
144/// satisfy `RedactableContainer` / `Redactable` bounds.
145#[proc_macro_derive(NotSensitive)]
146pub fn derive_not_sensitive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
147    let input = parse_macro_input!(input as DeriveInput);
148    let ident = input.ident;
149    let generics = input.generics;
150    let attrs = input.attrs;
151    let data = input.data;
152
153    let mut sensitive_attr_spans = Vec::new();
154    if let Some(attr) = attrs.iter().find(|attr| attr.path().is_ident("sensitive")) {
155        sensitive_attr_spans.push(attr.span());
156    }
157
158    match &data {
159        Data::Struct(data) => {
160            for field in &data.fields {
161                if field
162                    .attrs
163                    .iter()
164                    .any(|attr| attr.path().is_ident("sensitive"))
165                {
166                    sensitive_attr_spans.push(field.span());
167                }
168            }
169        }
170        Data::Enum(data) => {
171            for variant in &data.variants {
172                for field in &variant.fields {
173                    if field
174                        .attrs
175                        .iter()
176                        .any(|attr| attr.path().is_ident("sensitive"))
177                    {
178                        sensitive_attr_spans.push(field.span());
179                    }
180                }
181            }
182        }
183        Data::Union(data) => {
184            return syn::Error::new(
185                data.union_token.span(),
186                "`NotSensitive` cannot be derived for unions",
187            )
188            .into_compile_error()
189            .into();
190        }
191    }
192
193    if let Some(span) = sensitive_attr_spans.first() {
194        return syn::Error::new(
195            *span,
196            "`#[sensitive]` attributes are not allowed on `NotSensitive` types",
197        )
198        .into_compile_error()
199        .into();
200    }
201    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
202    let crate_root = crate_root();
203
204    let tokens = quote! {
205        impl #impl_generics #crate_root::RedactableContainer for #ident #ty_generics #where_clause {
206            fn redact_with<M: #crate_root::RedactableMapper>(self, _mapper: &M) -> Self {
207                self
208            }
209        }
210    };
211    tokens.into()
212}
213
214/// Derives `redactable::RedactableContainer` for types that should log without `Serialize`.
215///
216/// This emits the same traversal and redacted `Debug` impls as `Sensitive`, but uses
217/// a `slog::Value` implementation that logs a redacted string derived from a
218/// display template.
219///
220/// The display template is taken from `#[error("...")]` (thiserror-style) or from
221/// doc comments (displaydoc-style). If neither is present, the derive fails with a
222/// compile error to avoid accidental exposure of sensitive fields.
223///
224/// Classified fields referenced in the template are redacted by applying the
225/// policy to an owned copy of the field value, so those field types must
226/// implement `Clone`.
227#[proc_macro_derive(SensitiveError, attributes(sensitive, error))]
228pub fn derive_sensitive_error(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
229    let input = parse_macro_input!(input as DeriveInput);
230    match expand(input, SlogMode::RedactableErrorString) {
231        Ok(tokens) => tokens.into(),
232        Err(err) => err.into_compile_error().into(),
233    }
234}
235
236/// Returns the token stream to reference the redactable crate root.
237///
238/// Handles crate renaming (e.g., `my_redact = { package = "redactable", ... }`)
239/// and internal usage (when derive is used inside the redactable crate itself).
240fn crate_root() -> proc_macro2::TokenStream {
241    match crate_name("redactable") {
242        Ok(FoundCrate::Itself) => quote! { crate },
243        Ok(FoundCrate::Name(name)) => {
244            let ident = format_ident!("{}", name);
245            quote! { ::#ident }
246        }
247        Err(_) => quote! { ::redactable },
248    }
249}
250
251/// Returns the token stream to reference the slog crate root.
252///
253/// Handles crate renaming (e.g., `my_slog = { package = "slog", ... }`).
254/// If the top-level `slog` crate is not available, falls back to the
255/// `REDACTABLE_SLOG_CRATE` env var, which should be a path like `my_log::slog`.
256#[cfg(feature = "slog")]
257fn slog_crate() -> Result<proc_macro2::TokenStream> {
258    match crate_name("slog") {
259        Ok(FoundCrate::Itself) => Ok(quote! { crate }),
260        Ok(FoundCrate::Name(name)) => {
261            let ident = format_ident!("{}", name);
262            Ok(quote! { ::#ident })
263        }
264        Err(_) => {
265            let env_value = std::env::var("REDACTABLE_SLOG_CRATE").map_err(|_| {
266                syn::Error::new(
267                    Span::call_site(),
268                    "slog support is enabled, but no top-level `slog` crate was found. \
269Set the REDACTABLE_SLOG_CRATE env var to a path (e.g., `my_log::slog`) or add \
270`slog` as a direct dependency.",
271                )
272            })?;
273            let path = syn::parse_str::<syn::Path>(&env_value).map_err(|_| {
274                syn::Error::new(
275                    Span::call_site(),
276                    format!("REDACTABLE_SLOG_CRATE must be a valid Rust path (got `{env_value}`)"),
277                )
278            })?;
279            Ok(quote! { #path })
280        }
281    }
282}
283
284fn crate_path(item: &str) -> proc_macro2::TokenStream {
285    let root = crate_root();
286    let item_ident = syn::parse_str::<syn::Path>(item).expect("redactable crate path should parse");
287    quote! { #root::#item_ident }
288}
289
290struct DeriveOutput {
291    redaction_body: TokenStream,
292    used_generics: Vec<Ident>,
293    policy_applicable_generics: Vec<Ident>,
294    debug_redacted_body: TokenStream,
295    debug_redacted_generics: Vec<Ident>,
296    debug_unredacted_body: TokenStream,
297    debug_unredacted_generics: Vec<Ident>,
298    redacted_display_body: Option<TokenStream>,
299    redacted_display_generics: Vec<Ident>,
300    redacted_display_debug_generics: Vec<Ident>,
301    redacted_display_clone_generics: Vec<Ident>,
302    redacted_display_nested_generics: Vec<Ident>,
303}
304
305enum SlogMode {
306    RedactedJson,
307    RedactableErrorString,
308}
309
310#[allow(clippy::too_many_lines)]
311fn expand(input: DeriveInput, slog_mode: SlogMode) -> Result<TokenStream> {
312    let DeriveInput {
313        ident,
314        generics,
315        data,
316        attrs,
317        ..
318    } = input;
319
320    let ContainerOptions { skip_debug } = parse_container_options(&attrs)?;
321
322    let crate_root = crate_root();
323
324    let redacted_display_output = if matches!(slog_mode, SlogMode::RedactableErrorString) {
325        Some(derive_redacted_display(&ident, &data, &attrs, &generics)?)
326    } else {
327        None
328    };
329
330    let derive_output = match &data {
331        Data::Struct(data) => {
332            let output = derive_struct(&ident, data.clone(), &generics)?;
333            DeriveOutput {
334                redaction_body: output.redaction_body,
335                used_generics: output.used_generics,
336                policy_applicable_generics: output.policy_applicable_generics,
337                debug_redacted_body: output.debug_redacted_body,
338                debug_redacted_generics: output.debug_redacted_generics,
339                debug_unredacted_body: output.debug_unredacted_body,
340                debug_unredacted_generics: output.debug_unredacted_generics,
341                redacted_display_body: redacted_display_output
342                    .as_ref()
343                    .map(|output| output.body.clone()),
344                redacted_display_generics: redacted_display_output
345                    .as_ref()
346                    .map(|output| output.display_generics.clone())
347                    .unwrap_or_default(),
348                redacted_display_debug_generics: redacted_display_output
349                    .as_ref()
350                    .map(|output| output.debug_generics.clone())
351                    .unwrap_or_default(),
352                redacted_display_clone_generics: redacted_display_output
353                    .as_ref()
354                    .map(|output| output.clone_generics.clone())
355                    .unwrap_or_default(),
356                redacted_display_nested_generics: redacted_display_output
357                    .as_ref()
358                    .map(|output| output.nested_generics.clone())
359                    .unwrap_or_default(),
360            }
361        }
362        Data::Enum(data) => {
363            let output = derive_enum(&ident, data.clone(), &generics)?;
364            DeriveOutput {
365                redaction_body: output.redaction_body,
366                used_generics: output.used_generics,
367                policy_applicable_generics: output.policy_applicable_generics,
368                debug_redacted_body: output.debug_redacted_body,
369                debug_redacted_generics: output.debug_redacted_generics,
370                debug_unredacted_body: output.debug_unredacted_body,
371                debug_unredacted_generics: output.debug_unredacted_generics,
372                redacted_display_body: redacted_display_output
373                    .as_ref()
374                    .map(|output| output.body.clone()),
375                redacted_display_generics: redacted_display_output
376                    .as_ref()
377                    .map(|output| output.display_generics.clone())
378                    .unwrap_or_default(),
379                redacted_display_debug_generics: redacted_display_output
380                    .as_ref()
381                    .map(|output| output.debug_generics.clone())
382                    .unwrap_or_default(),
383                redacted_display_clone_generics: redacted_display_output
384                    .as_ref()
385                    .map(|output| output.clone_generics.clone())
386                    .unwrap_or_default(),
387                redacted_display_nested_generics: redacted_display_output
388                    .as_ref()
389                    .map(|output| output.nested_generics.clone())
390                    .unwrap_or_default(),
391            }
392        }
393        Data::Union(u) => {
394            return Err(syn::Error::new(
395                u.union_token.span(),
396                "`Sensitive` cannot be derived for unions",
397            ));
398        }
399    };
400
401    let policy_generics = add_container_bounds(generics.clone(), &derive_output.used_generics);
402    let policy_generics =
403        add_policy_applicable_bounds(policy_generics, &derive_output.policy_applicable_generics);
404    let (impl_generics, ty_generics, where_clause) = policy_generics.split_for_impl();
405    let debug_redacted_generics =
406        add_debug_bounds(generics.clone(), &derive_output.debug_redacted_generics);
407    let (debug_redacted_impl_generics, debug_redacted_ty_generics, debug_redacted_where_clause) =
408        debug_redacted_generics.split_for_impl();
409    let debug_unredacted_generics =
410        add_debug_bounds(generics.clone(), &derive_output.debug_unredacted_generics);
411    let (
412        debug_unredacted_impl_generics,
413        debug_unredacted_ty_generics,
414        debug_unredacted_where_clause,
415    ) = debug_unredacted_generics.split_for_impl();
416    let redaction_body = &derive_output.redaction_body;
417    let debug_redacted_body = &derive_output.debug_redacted_body;
418    let debug_unredacted_body = &derive_output.debug_unredacted_body;
419    let debug_impl = if skip_debug {
420        quote! {}
421    } else {
422        quote! {
423            #[cfg(any(test, feature = "testing"))]
424            impl #debug_unredacted_impl_generics ::core::fmt::Debug for #ident #debug_unredacted_ty_generics #debug_unredacted_where_clause {
425                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
426                    #debug_unredacted_body
427                }
428            }
429
430            #[cfg(not(any(test, feature = "testing")))]
431            #[allow(unused_variables)]
432            impl #debug_redacted_impl_generics ::core::fmt::Debug for #ident #debug_redacted_ty_generics #debug_redacted_where_clause {
433                fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
434                    #debug_redacted_body
435                }
436            }
437        }
438    };
439
440    let redacted_display_body = derive_output.redacted_display_body.as_ref();
441    let redacted_display_impl = if matches!(slog_mode, SlogMode::RedactableErrorString) {
442        let redacted_display_generics =
443            add_display_bounds(generics.clone(), &derive_output.redacted_display_generics);
444        let redacted_display_generics = add_debug_bounds(
445            redacted_display_generics,
446            &derive_output.redacted_display_debug_generics,
447        );
448        let redacted_display_generics = add_clone_bounds(
449            redacted_display_generics,
450            &derive_output.redacted_display_clone_generics,
451        );
452        let redacted_display_generics = add_redacted_display_bounds(
453            redacted_display_generics,
454            &derive_output.redacted_display_nested_generics,
455        );
456        let (display_impl_generics, display_ty_generics, display_where_clause) =
457            redacted_display_generics.split_for_impl();
458        let redacted_display_body = redacted_display_body
459            .cloned()
460            .unwrap_or_else(TokenStream::new);
461        quote! {
462            impl #display_impl_generics #crate_root::RedactableError for #ident #display_ty_generics #display_where_clause {
463                fn fmt_redacted(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
464                    #redacted_display_body
465                }
466            }
467        }
468    } else {
469        quote! {}
470    };
471
472    // Only generate slog impl when the slog feature is enabled on redactable-derive.
473    // If slog is not available, emit a clear error with instructions.
474    #[cfg(feature = "slog")]
475    let slog_impl = {
476        let slog_crate = slog_crate()?;
477        let mut slog_generics = generics;
478        let slog_where_clause = slog_generics.make_where_clause();
479        let self_ty: syn::Type = parse_quote!(#ident #ty_generics);
480        match slog_mode {
481            SlogMode::RedactedJson => {
482                slog_where_clause
483                    .predicates
484                    .push(parse_quote!(#self_ty: ::core::clone::Clone));
485                // SlogRedactedExt requires Self: Serialize, so we add this bound to enable
486                // generic types to work with slog when their type parameters implement Serialize.
487                slog_where_clause
488                    .predicates
489                    .push(parse_quote!(#self_ty: ::serde::Serialize));
490                slog_where_clause
491                    .predicates
492                    .push(parse_quote!(#self_ty: #crate_root::slog::SlogRedactedExt));
493                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
494                    slog_generics.split_for_impl();
495                quote! {
496                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
497                        fn serialize(
498                            &self,
499                            _record: &#slog_crate::Record<'_>,
500                            key: #slog_crate::Key,
501                            serializer: &mut dyn #slog_crate::Serializer,
502                        ) -> #slog_crate::Result {
503                            let redacted = #crate_root::slog::SlogRedactedExt::slog_redacted_json(self.clone());
504                            #slog_crate::Value::serialize(&redacted, _record, key, serializer)
505                        }
506                    }
507                }
508            }
509            SlogMode::RedactableErrorString => {
510                slog_where_clause
511                    .predicates
512                    .push(parse_quote!(#self_ty: #crate_root::RedactableError));
513                let (slog_impl_generics, slog_ty_generics, slog_where_clause) =
514                    slog_generics.split_for_impl();
515                quote! {
516                    impl #slog_impl_generics #slog_crate::Value for #ident #slog_ty_generics #slog_where_clause {
517                        fn serialize(
518                            &self,
519                            _record: &#slog_crate::Record<'_>,
520                            key: #slog_crate::Key,
521                            serializer: &mut dyn #slog_crate::Serializer,
522                        ) -> #slog_crate::Result {
523                            let redacted = #crate_root::RedactableError::redacted_error(self);
524                            serializer.emit_arguments(key, &format_args!("{}", redacted))
525                        }
526                    }
527                }
528            }
529        }
530    };
531
532    #[cfg(not(feature = "slog"))]
533    let slog_impl = quote! {};
534
535    let trait_impl = quote! {
536        impl #impl_generics #crate_root::RedactableContainer for #ident #ty_generics #where_clause {
537            fn redact_with<M: #crate_root::RedactableMapper>(self, mapper: &M) -> Self {
538                use #crate_root::RedactableContainer as _;
539                #redaction_body
540            }
541        }
542
543        #debug_impl
544
545        #redacted_display_impl
546
547        #slog_impl
548
549        // `slog` already provides `impl<V: Value> Value for &V`, so a reference
550        // impl here would conflict with the blanket impl.
551    };
552    Ok(trait_impl)
553}