1use serde_json::Value;
2use std::collections::HashSet;
3
4#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum RedactionPolicy {
11 #[default]
13 All,
14 TraceOnly,
16 Off,
18}
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum PlainStyle {
24 #[default]
26 Readable,
27 Raw,
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct Redactor {
38 policy: RedactionPolicy,
39 secret_names: Vec<String>,
40}
41
42impl Redactor {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn secret_names<I: IntoIterator<Item = S>, S: Into<String>>(mut self, names: I) -> Self {
54 self.secret_names = names.into_iter().map(|s| s.into()).collect();
55 self
56 }
57
58 pub fn policy(mut self, policy: RedactionPolicy) -> Self {
61 self.policy = policy;
62 self
63 }
64
65 pub fn value(&self, value: &Value) -> Value {
70 let mut v = value.clone();
71 self.redact_in_place(&mut v);
72 v
73 }
74
75 pub fn url(&self, url: &str) -> String {
85 let context = RedactionContext::from_redactor(self);
86 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
87 }
88
89 pub fn redact_in_place(&self, value: &mut Value) {
94 let context = RedactionContext::from_redactor(self);
95 apply_redaction_policy_with_context(value, self.policy, &context);
96 }
97
98 pub fn is_secret_name(&self, name: &str) -> bool {
108 RedactionContext::from_redactor(self).is_secret_key(name)
109 }
110}
111
112impl From<RedactionPolicy> for Redactor {
113 fn from(policy: RedactionPolicy) -> Self {
114 Self {
115 policy,
116 secret_names: Vec::new(),
117 }
118 }
119}
120
121#[derive(Clone, Debug, Default, PartialEq, Eq)]
123pub struct OutputOptions {
124 pub redaction: Redactor,
126 pub style: PlainStyle,
128}
129
130impl From<RedactionPolicy> for OutputOptions {
131 fn from(policy: RedactionPolicy) -> Self {
132 Self {
133 redaction: Redactor::from(policy),
134 style: PlainStyle::default(),
135 }
136 }
137}
138
139pub fn redacted_value(value: &Value) -> Value {
145 Redactor::new().value(value)
146}
147
148pub fn redact_url_secrets(url: &str) -> String {
153 Redactor::new().url(url)
154}
155
156#[derive(Default)]
161pub(crate) struct RedactionContext {
162 secret_names: HashSet<String>,
163}
164
165impl RedactionContext {
166 fn from_redactor(redactor: &Redactor) -> Self {
167 let secret_names = redactor.secret_names.iter().cloned().collect();
168 Self { secret_names }
169 }
170
171 fn is_secret_key(&self, key: &str) -> bool {
172 key_has_secret_suffix(key) || self.secret_names.contains(key)
173 }
174}
175
176fn key_has_secret_suffix(key: &str) -> bool {
177 key.ends_with("_secret") || key.ends_with("_SECRET")
178}
179
180fn key_has_url_suffix(key: &str) -> bool {
181 key.ends_with("_url") || key.ends_with("_URL")
182}
183
184#[cfg(feature = "cli-help")]
185pub(crate) fn is_secret_flag_name(flag_name: &str, context: &RedactionContext) -> bool {
186 let normalized = flag_name.replace('-', "_");
187 context.is_secret_key(&normalized) || context.is_secret_key(flag_name)
188}
189
190const MAX_DEPTH: usize = 256;
191const MAX_DEPTH_MARKER: &str = "<afdata:max-depth>";
192
193fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
194 redact_secrets_with_context_depth(value, context, 0);
195}
196
197fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
198 if depth >= MAX_DEPTH {
199 *value = Value::String(MAX_DEPTH_MARKER.into());
200 return;
201 }
202 match value {
203 Value::Object(map) => {
204 let keys: Vec<String> = map.keys().cloned().collect();
205 for key in keys {
206 if context.is_secret_key(&key) {
207 map.insert(key, Value::String("***".into()));
208 } else if key_has_url_suffix(&key) {
209 if let Some(Value::String(s)) = map.get_mut(&key) {
210 *s = redact_url_field_value(s, context);
211 } else if let Some(v) = map.get_mut(&key) {
212 redact_secrets_with_context_depth(v, context, depth + 1);
213 }
214 } else if let Some(v) = map.get_mut(&key) {
215 redact_secrets_with_context_depth(v, context, depth + 1);
216 }
217 }
218 }
219 Value::Array(arr) => {
220 for v in arr {
221 redact_secrets_with_context_depth(v, context, depth + 1);
222 }
223 }
224 _ => {}
225 }
226}
227
228fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
232 if !s.contains("://") || !is_single_url(s) {
239 return None;
240 }
241 let scheme_sep = s.find("://")?;
242 let scheme = &s[..scheme_sep];
243 let rest = &s[scheme_sep + 3..];
244
245 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
247 let authority = &rest[..auth_end];
248 let remainder = &rest[auth_end..];
249
250 let new_authority = redact_userinfo_password(authority);
251
252 let new_remainder = match remainder.find('?') {
254 Some(q) => {
255 let (path, q_onwards) = remainder.split_at(q);
256 let query_body = &q_onwards[1..];
257 let (query, fragment) = match query_body.find('#') {
258 Some(h) => (&query_body[..h], &query_body[h..]),
259 None => (query_body, ""),
260 };
261 format!("{path}?{}{fragment}", redact_query(query, context))
262 }
263 None => remainder.to_string(),
264 };
265
266 Some(format!("{scheme}://{new_authority}{new_remainder}"))
267}
268
269fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
270 if let Some(redacted) = redact_url_in_str(s, context) {
271 return redacted;
272 }
273 let trimmed = s.trim();
274 if trimmed != s
275 && let Some(redacted) = redact_url_in_str(trimmed, context)
276 {
277 return redacted;
278 }
279 if s.chars().any(char::is_whitespace) || s.contains('@') {
285 return "***".to_string();
286 }
287 s.to_string()
288}
289
290fn redact_userinfo_password(authority: &str) -> String {
293 let Some(at) = authority.rfind('@') else {
294 return authority.to_string();
295 };
296 let userinfo = &authority[..at];
297 match userinfo.find(':') {
298 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
299 None => authority.to_string(),
300 }
301}
302
303fn redact_query(query: &str, context: &RedactionContext) -> String {
306 query
307 .split('&')
308 .map(|segment| {
309 let Some(eq) = segment.find('=') else {
310 return segment.to_string();
311 };
312 let raw_key = &segment[..eq];
313 let name = url::form_urlencoded::parse(segment.as_bytes())
315 .next()
316 .map(|(k, _)| k.into_owned())
317 .unwrap_or_default();
318 if context.is_secret_key(&name) {
319 format!("{raw_key}=***")
320 } else {
321 segment.to_string()
322 }
323 })
324 .collect::<Vec<_>>()
325 .join("&")
326}
327
328fn is_single_url(s: &str) -> bool {
332 if s.bytes().any(|b| b.is_ascii_whitespace()) {
333 return false;
334 }
335 let bytes = s.as_bytes();
336 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
337 return false;
338 }
339 let mut i = 1;
340 while i < bytes.len() {
341 let c = bytes[i];
342 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
343 i += 1;
344 } else {
345 break;
346 }
347 }
348 s[i..].starts_with("://")
349}
350
351fn apply_redaction_policy_with_context(
352 value: &mut Value,
353 redaction_policy: RedactionPolicy,
354 context: &RedactionContext,
355) {
356 match redaction_policy {
357 RedactionPolicy::All => redact_secrets_with_context(value, context),
358 RedactionPolicy::TraceOnly => {
359 if let Value::Object(map) = value
360 && let Some(trace) = map.get_mut("trace")
361 {
362 redact_secrets_with_context(trace, context);
363 }
364 }
365 RedactionPolicy::Off => {}
366 }
367}