1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
// =============================================================================
// Copyright (c) 2025 - 2026 Haixing Hu.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0.
// =============================================================================
//! Whitelisted serde field attributes for redacted serialization.
use syn::{
Field,
Ident,
LitStr,
Meta,
Path,
Token,
};
use crate::{
field_mode::FieldMode,
internal::parse_serialize_name,
};
/// Serde controls that preserve the generated redacted structure.
#[must_use]
pub(crate) struct SerdeAttributes {
/// Explicit serialized field name.
rename: Option<String>,
/// Whether any serialization or deserialization rename was declared.
rename_seen: bool,
/// Whether the field is always omitted.
skip: bool,
/// Predicate deciding whether the raw field is omitted.
skip_serializing_if: Option<Path>,
/// Function used to serialize an explicitly plain field.
serialize_with: Option<Path>,
}
impl SerdeAttributes {
/// Parses supported serde field attributes when integration is enabled.
///
/// # Parameters
///
/// * `field` - Field whose helper attributes are read.
/// * `type_name` - Derived type used in diagnostics.
/// * `field_name` - Field identifier used in diagnostics.
/// * `enabled` - Whether the container declared `#[redact(serde)]`.
///
/// # Returns
///
/// Parsed rename, skip, and serialization-adapter controls, or empty
/// controls when disabled.
///
/// # Errors
///
/// Returns an error for malformed, repeated, or unsupported serde controls.
///
/// # Panics
///
/// Panics only if `syn` supplies a nested metadata path without any
/// segments, which violates the `ParseNestedMeta` path invariant.
pub(crate) fn parse(
field: &Field,
type_name: &Ident,
field_name: &str,
enabled: bool,
) -> syn::Result<Self> {
let mut parsed = Self {
rename: None,
rename_seen: false,
skip: false,
skip_serializing_if: None,
serialize_with: None,
};
if !enabled {
return Ok(parsed);
}
for attribute in &field.attrs {
if !attribute.path().is_ident("serde") {
continue;
}
let Meta::List(_) = &attribute.meta else {
return Err(syn::Error::new_spanned(
attribute,
format!(
"Redact serde for `{type_name}` field `{field_name}` expects \
`#[serde(...)]`",
),
));
};
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("rename") {
if parsed.rename_seen {
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` repeats `rename`",
)));
}
parsed.rename = parse_serialize_name(&meta, "rename")?
.map(|literal| literal.value());
parsed.rename_seen = true;
} else if meta.path.is_ident("skip") || meta.path.is_ident("skip_serializing") {
if !meta.input.is_empty() {
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` requires a bare \
skip attribute",
)));
}
if parsed.skip {
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` repeats a skip \
attribute",
)));
}
parsed.skip = true;
} else if meta.path.is_ident("skip_serializing_if") {
if parsed.skip_serializing_if.is_some() {
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` repeats \
`skip_serializing_if`",
)));
}
let literal: LitStr = meta.value()?.parse()?;
parsed.skip_serializing_if = Some(literal.parse()?);
} else if meta.path.is_ident("with")
|| meta.path.is_ident("serialize_with")
{
if parsed.serialize_with.is_some() {
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` repeats a serialization adapter",
)));
}
if !meta.input.peek(Token![=]) {
return Err(meta.error(
"Redact serde expects a string path for a serialization adapter",
));
}
let literal: LitStr = meta.value()?.parse()?;
let path: Path = literal.parse()?;
parsed.serialize_with = if meta.path.is_ident("with") {
Some(syn::parse_quote!(#path::serialize))
} else {
Some(path)
};
} else if is_deserialize_only_control(&meta) {
parse_deserialize_only_control(&meta)?;
} else {
let key = meta
.path
.segments
.last()
.expect("syn nested meta paths always contain a segment")
.ident
.to_string();
return Err(meta.error(format!(
"Redact serde for `{type_name}` field `{field_name}` does not support \
`{key}` because it can change structure or bypass redaction; use only \
`rename`, `skip`, `skip_serializing`, `skip_serializing_if`, \
`with`, `serialize_with`, or \
deserialization-only controls such as `default`, `alias`, and \
`skip_deserializing`",
)));
}
Ok(())
})?;
}
Ok(parsed)
}
/// Rejects raw-value omission predicates on redacted fields.
///
/// # Parameters
///
/// * `field` - Field carrying the relevant Serde attributes.
/// * `type_name` - Derived type used in the diagnostic.
/// * `field_name` - Field identifier used in the diagnostic.
/// * `mode` - Redaction mode selected for the field.
///
/// # Errors
///
/// Returns an error when a sensitive mode would expose raw field state to
/// `skip_serializing_if`. Plain fields intentionally retain their raw
/// representation and skipped fields emit no value, so both remain allowed.
pub(crate) fn validate_redaction_mode(
&self,
field: &Field,
type_name: &Ident,
field_name: &str,
mode: &FieldMode,
) -> syn::Result<()> {
if self.skip_serializing_if.is_some()
&& !matches!(mode, FieldMode::Plain | FieldMode::Skip)
{
return Err(syn::Error::new_spanned(
field,
format!(
"Redact serde for `{type_name}` field `{field_name}` cannot use `skip_serializing_if` with a redaction mode that observes raw field state; use it only with `plain` or `skip`",
),
));
}
if self.serialize_with.is_some()
&& !matches!(mode, FieldMode::Plain | FieldMode::Skip)
{
return Err(syn::Error::new_spanned(
field,
format!(
"Redact serde for `{type_name}` field `{field_name}` cannot use a serialization adapter with a redaction mode that observes raw field state; use it only with `plain` or `skip`",
),
));
}
Ok(())
}
/// Returns the explicit serialized name, when present.
///
/// # Returns
///
/// `Some(name)` for an explicit field rename, or `None` to use the
/// applicable container or variant rename rule.
#[inline(always)]
pub(crate) fn rename(&self) -> Option<&str> {
self.rename.as_deref()
}
/// Returns whether the field is always omitted by serde.
///
/// # Returns
///
/// `true` when either `skip` or `skip_serializing` was present.
#[must_use]
#[inline(always)]
pub(crate) const fn skip(&self) -> bool {
self.skip
}
/// Returns the optional raw-value skip predicate.
///
/// # Returns
///
/// `Some(path)` for `skip_serializing_if`, or `None` when serialization is
/// unconditional.
#[inline(always)]
pub(crate) const fn skip_serializing_if(&self) -> Option<&Path> {
self.skip_serializing_if.as_ref()
}
/// Returns the optional plain-field serialization adapter.
///
/// # Returns
///
/// `Some(path)` for `with` or `serialize_with`, or `None` when the field
/// uses ordinary Serde serialization.
#[inline(always)]
pub(crate) const fn serialize_with(&self) -> Option<&Path> {
self.serialize_with.as_ref()
}
}
/// Returns whether one field control affects deserialization only.
fn is_deserialize_only_control(meta: &syn::meta::ParseNestedMeta<'_>) -> bool {
meta.path.is_ident("default")
|| meta.path.is_ident("alias")
|| meta.path.is_ident("skip_deserializing")
|| meta.path.is_ident("deserialize_with")
|| meta.path.is_ident("deserialize_in_place")
|| meta.path.is_ident("borrow")
}
/// Consumes one supported deserialization-only field control.
fn parse_deserialize_only_control(
meta: &syn::meta::ParseNestedMeta<'_>,
) -> syn::Result<()> {
if meta.path.is_ident("skip_deserializing")
|| meta.path.is_ident("deserialize_in_place")
{
if meta.input.peek(Token![=]) || meta.input.peek(syn::token::Paren) {
return Err(
meta.error("Redact serde expects a bare deserialization-only field control")
);
}
return Ok(());
}
if meta.path.is_ident("default") && !meta.input.peek(Token![=]) {
return Ok(());
}
if meta.path.is_ident("borrow") && !meta.input.peek(Token![=]) {
return Ok(());
}
if !meta.input.peek(Token![=]) {
return Err(meta.error(
"Redact serde expects a string value for this deserialization-only field control",
));
}
let _: LitStr = meta.value()?.parse()?;
Ok(())
}