Skip to main content

deps_core_macros/
lib.rs

1//! Proc-macro implementation backing `deps_core::redact_debug::RedactingDebug` (issue #1238).
2//!
3//! Not a direct dependency surface: consumers depend on `deps-core` and use
4//! `deps_core::redact_debug::RedactingDebug`, never this crate directly — see that module's
5//! doc comment for the derive's contract and an `# Examples` doctest.
6
7use proc_macro::TokenStream;
8use proc_macro2::TokenStream as TokenStream2;
9use quote::quote;
10use syn::{Data, DeriveInput, Fields, parse_macro_input};
11
12#[derive(Clone, Copy)]
13enum FieldRedaction {
14    Url,
15    Key,
16    Raw,
17}
18
19/// Derives a `Debug` impl requiring every named field to declare its redaction treatment.
20///
21/// Every field of the struct it's applied to must carry exactly one of `#[redact(url)]`,
22/// `#[redact(key)]`, or `#[raw]` — an unannotated, ambiguous, or conflicting field fails to
23/// compile instead of silently rendering a credential-shaped value unredacted (#1238).
24///
25/// Only structs with named fields are supported; an enum, tuple struct, or unit struct is a
26/// compile error naming why. See `deps_core::redact_debug`'s re-exported doc for the field
27/// attribute contract and a runnable example.
28#[proc_macro_derive(RedactingDebug, attributes(redact, raw))]
29pub fn derive_redacting_debug(input: TokenStream) -> TokenStream {
30    let input = parse_macro_input!(input as DeriveInput);
31    expand(&input)
32        .unwrap_or_else(syn::Error::into_compile_error)
33        .into()
34}
35
36fn expand(input: &DeriveInput) -> syn::Result<TokenStream2> {
37    let Data::Struct(data) = &input.data else {
38        return Err(syn::Error::new_spanned(
39            input,
40            "RedactingDebug only supports structs with named fields (enums are not supported \
41             — see issue #1238's Out of Scope; they need per-variant redaction logic)",
42        ));
43    };
44    let Fields::Named(fields) = &data.fields else {
45        return Err(syn::Error::new_spanned(
46            &data.fields,
47            "RedactingDebug requires named fields (tuple structs and unit structs are not \
48             supported in this version — see issue #1238's Out of Scope)",
49        ));
50    };
51    if fields.named.is_empty() {
52        return Err(syn::Error::new_spanned(
53            fields,
54            "RedactingDebug requires at least one named field — a zero-field struct has \
55             nothing to redact; use a hand-written `impl Debug` instead",
56        ));
57    }
58
59    let mut errors: Vec<syn::Error> = Vec::new();
60    let mut field_tokens = Vec::new();
61
62    for field in &fields.named {
63        // `Fields::Named` guarantees every field has an identifier.
64        let Some(field_ident) = &field.ident else {
65            continue;
66        };
67        match field_redaction_kind(field) {
68            Ok(kind) => {
69                let name = field_ident.to_string();
70                field_tokens.push(match kind {
71                    FieldRedaction::Url => quote! {
72                        .field(#name, &::deps_core::redact_debug::__redact_url_field(&self.#field_ident))
73                    },
74                    FieldRedaction::Key => quote! {
75                        .field(#name, &::deps_core::redact_debug::__redact_key_field(&self.#field_ident))
76                    },
77                    FieldRedaction::Raw => quote! {
78                        .field(#name, &self.#field_ident)
79                    },
80                });
81            }
82            Err(e) => errors.push(e),
83        }
84    }
85
86    if let Some(combined) = errors.into_iter().reduce(|mut acc, e| {
87        acc.combine(e);
88        acc
89    }) {
90        return Err(combined);
91    }
92
93    let ident = &input.ident;
94    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
95    let name_str = ident.to_string();
96
97    Ok(quote! {
98        #[automatically_derived]
99        impl #impl_generics ::std::fmt::Debug for #ident #ty_generics #where_clause {
100            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
101                f.debug_struct(#name_str)
102                    #(#field_tokens)*
103                    .finish()
104            }
105        }
106    })
107}
108
109/// Determines the field's exactly-one redaction treatment (FR-005): `#[redact(url)]`,
110/// `#[redact(key)]`, or `#[raw]`. Zero or 2+ matching attributes on one field is a compile
111/// error naming the field and the conflict.
112fn field_redaction_kind(field: &syn::Field) -> syn::Result<FieldRedaction> {
113    let mut found: Option<FieldRedaction> = None;
114
115    for attr in &field.attrs {
116        if attr.path().is_ident("raw") {
117            record_redaction(&mut found, FieldRedaction::Raw, attr)?;
118        } else if attr.path().is_ident("redact") {
119            let mut kind = None;
120            attr.parse_nested_meta(|meta| {
121                let this_kind = if meta.path.is_ident("url") {
122                    FieldRedaction::Url
123                } else if meta.path.is_ident("key") {
124                    FieldRedaction::Key
125                } else {
126                    return Err(meta.error("expected `url` or `key` inside `#[redact(...)]`"));
127                };
128                // Catches `#[redact(url, key)]` — `parse_nested_meta` invokes this closure
129                // once per comma-separated argument, so two arguments in one attribute would
130                // otherwise silently let the second overwrite the first instead of erroring.
131                if kind.is_some() {
132                    return Err(
133                        meta.error("`#[redact(...)]` accepts exactly one of `url`/`key`, not both")
134                    );
135                }
136                kind = Some(this_kind);
137                Ok(())
138            })?;
139            let Some(kind) = kind else {
140                return Err(syn::Error::new_spanned(
141                    attr,
142                    "`#[redact(...)]` requires `url` or `key`, e.g. `#[redact(url)]`",
143                ));
144            };
145            record_redaction(&mut found, kind, attr)?;
146        }
147    }
148
149    found.ok_or_else(|| {
150        let field_name = field
151            .ident
152            .as_ref()
153            .map_or_else(|| "<field>".to_string(), ToString::to_string);
154        syn::Error::new_spanned(
155            field,
156            format!(
157                "field `{field_name}` must carry exactly one of `#[redact(url)]`, \
158                 `#[redact(key)]`, or `#[raw]` (RedactingDebug, #1238)"
159            ),
160        )
161    })
162}
163
164/// Records `kind` as `field`'s redaction treatment, failing if one was already recorded —
165/// the shared conflict check for both the `#[raw]` and `#[redact(...)]` branches above.
166fn record_redaction(
167    found: &mut Option<FieldRedaction>,
168    kind: FieldRedaction,
169    attr: &syn::Attribute,
170) -> syn::Result<()> {
171    if found.is_some() {
172        return Err(syn::Error::new_spanned(
173            attr,
174            "field carries more than one of `#[redact(url)]`, `#[redact(key)]`, `#[raw]` — \
175             exactly one is required",
176        ));
177    }
178    *found = Some(kind);
179    Ok(())
180}