From a9f4844b509108d5c586bb8880599461ce66b776 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Sat, 27 Sep 2025 07:05:50 +0700 Subject: [PATCH] Refactor metadata redaction to avoid lowercase allocations --- src/app_error/metadata.rs | 86 +++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 125f0de..8590ab7 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -198,36 +198,36 @@ impl Field { } fn infer_default_redaction(name: &str) -> FieldRedaction { - let lowered = name.to_ascii_lowercase(); - - if lowered.contains("password") - || lowered.contains("passphrase") - || lowered.contains("secret") - || lowered.contains("authorization") - || lowered.contains("cookie") - || lowered.contains("session") - || lowered.contains("jwt") - || lowered.contains("bearer") - || lowered.contains("otp") - || lowered.contains("pin") + if contains_ascii_case_insensitive(name, "password") + || contains_ascii_case_insensitive(name, "passphrase") + || contains_ascii_case_insensitive(name, "secret") + || contains_ascii_case_insensitive(name, "authorization") + || contains_ascii_case_insensitive(name, "cookie") + || contains_ascii_case_insensitive(name, "session") + || contains_ascii_case_insensitive(name, "jwt") + || contains_ascii_case_insensitive(name, "bearer") + || contains_ascii_case_insensitive(name, "otp") + || contains_ascii_case_insensitive(name, "pin") { return FieldRedaction::Redact; } let mut card_like = false; let mut number_like = false; + let has_token = contains_ascii_case_insensitive(name, "token"); + let has_key = contains_ascii_case_insensitive(name, "key"); - for segment in lowered.split(['.', '_', '-', ':', '/']) { + for segment in name.split(['.', '_', '-', ':', '/']) { if segment.is_empty() { continue; } if segment.eq_ignore_ascii_case("token") || segment.eq_ignore_ascii_case("apikey") - || segment.eq_ignore_ascii_case("api") && lowered.contains("key") - || segment.ends_with("token") + || segment.eq_ignore_ascii_case("api") && has_key + || ends_with_ascii_case_insensitive(segment, "token") || segment.eq_ignore_ascii_case("key") - || segment.eq_ignore_ascii_case("access") && lowered.contains("token") - || segment.eq_ignore_ascii_case("refresh") && lowered.contains("token") + || segment.eq_ignore_ascii_case("access") && has_token + || segment.eq_ignore_ascii_case("refresh") && has_token { return FieldRedaction::Hash; } @@ -256,6 +256,38 @@ fn infer_default_redaction(name: &str) -> FieldRedaction { } } +fn ends_with_ascii_case_insensitive(value: &str, suffix: &str) -> bool { + let value_bytes = value.as_bytes(); + let suffix_bytes = suffix.as_bytes(); + value_bytes.len() >= suffix_bytes.len() + && eq_ascii_case_insensitive_bytes( + &value_bytes[value_bytes.len() - suffix_bytes.len()..], + suffix_bytes + ) +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + + let haystack_bytes = haystack.as_bytes(); + let needle_bytes = needle.as_bytes(); + + haystack_bytes.len() >= needle_bytes.len() + && haystack_bytes + .windows(needle_bytes.len()) + .any(|window| eq_ascii_case_insensitive_bytes(window, needle_bytes)) +} + +fn eq_ascii_case_insensitive_bytes(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .all(|(&lhs, &rhs)| lhs.eq_ignore_ascii_case(&rhs)) +} + /// Structured metadata attached to [`crate::AppError`]. /// /// Internally backed by a deterministic [`BTreeMap`] keyed by `'static` field @@ -558,6 +590,26 @@ mod tests { assert!(matches!(card.redaction(), FieldRedaction::Last4)); } + #[test] + fn default_redaction_remains_case_insensitive() { + let cases = [ + ("Password", FieldRedaction::Redact), + ("SESSION_ID", FieldRedaction::Redact), + ("X-API-Token", FieldRedaction::Hash), + ("RefreshToken", FieldRedaction::Hash), + ("CARD_NUMBER", FieldRedaction::Last4) + ]; + + for (name, expected) in cases { + let field = field::str(name, Cow::Borrowed("value")); + assert!( + matches!(field.redaction(), policy if policy == expected), + "expected {:?} for {name}", + expected + ); + } + } + #[test] fn field_into_parts_returns_components() { let field = field::u64("elapsed_ms", 30);