#[cfg(test)]
use arcature_observe::redact::ErrorCategory;
const REDACTED: &str = "[redacted]";
pub(crate) fn scrub_value(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let bytes = value.as_bytes();
let mut i = 0;
while i < bytes.len() {
if let Some(at) = url_credential_segment(bytes, i) {
out.push_str("://");
out.push_str(REDACTED);
out.push('@');
i = at + 1;
continue;
}
if let Some(eq) = secret_assignment(bytes, i) {
out.push_str(std::str::from_utf8(&bytes[i..=eq]).ok().unwrap_or(""));
out.push_str(REDACTED);
let mut j = eq + 1;
while j < bytes.len() && !is_value_delim(bytes[j]) {
j += 1;
}
i = j;
continue;
}
out.push(bytes[i] as char);
i += 1;
}
out
}
pub(crate) fn scrub_result(value: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match value {
Value::String(s) => Value::String(scrub_value(&s)),
Value::Array(items) => Value::Array(items.into_iter().map(scrub_result).collect()),
Value::Object(map) => {
let scrubbed = map.into_iter().map(|(k, v)| (k, scrub_result(v)));
Value::Object(scrubbed.collect())
}
other => other,
}
}
fn url_credential_segment(bytes: &[u8], i: usize) -> Option<usize> {
let rest = &bytes[i..];
if rest.len() < 5 || &rest[..3] != b"://" {
return None;
}
let max = rest.len().min(512);
let at = rest[..max].iter().position(|&b| b == b'@')?;
let after_scheme = &rest[3..at];
if after_scheme.contains(&b':') && !after_scheme.is_empty() {
Some(i + at)
} else {
None
}
}
fn secret_assignment(bytes: &[u8], i: usize) -> Option<usize> {
let rest = &bytes[i..];
let keys: [&[u8]; 5] = [b"password", b"passwd", b"secret", b"token", b"api_key"];
for key in keys {
if rest.len() > key.len() && eq_ascii_ci(&rest[..key.len()], key) && rest[key.len()] == b'='
{
if i == 0 || is_value_delim(bytes[i - 1]) {
return Some(i + key.len());
}
}
}
None
}
fn eq_ascii_ci(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.eq_ignore_ascii_case(y))
}
fn is_value_delim(b: u8) -> bool {
matches!(
b,
b'&' | b';' | b' ' | b'\t' | b'\n' | b'\r' | b'"' | b'\'' | b','
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_uag_data_is_unaffected() {
let value = "LinksController::index";
assert_eq!(scrub_value(value), value);
assert_eq!(scrub_value("/links/{link}"), "/links/{link}");
assert_eq!(scrub_value("links.index"), "links.index");
}
#[test]
fn scrubs_url_credential() {
let value = "postgres://app:s3cr3t@db.example.com:5432/app";
let scrubbed = scrub_value(value);
assert!(scrubbed.contains(REDACTED), "{scrubbed}");
assert!(!scrubbed.contains("s3cr3t"), "{scrubbed}");
assert!(scrubbed.contains("@db.example.com"), "{scrubbed}");
}
#[test]
fn scrups_url_without_password_is_unaffected() {
let value = "postgres://db.example.com:5432/app";
assert_eq!(scrub_value(value), value);
}
#[test]
fn scrubs_password_query_param() {
let value = "host=db user=app password=hunter2 sslmode=require";
let scrubbed = scrub_value(value);
assert!(scrubbed.contains("password=[redacted]"), "{scrubbed}");
assert!(!scrubbed.contains("hunter2"), "{scrubbed}");
assert!(scrubbed.contains("user=app"), "{scrubbed}");
}
#[test]
fn scrubs_token_and_secret_and_api_key() {
for key in ["token", "secret", "api_key", "passwd"] {
let value = format!("{key}=abc123&other=keep");
let scrubbed = scrub_value(&value);
assert!(
scrubbed.contains(&format!("{key}=[redacted]")),
"{scrubbed}"
);
assert!(!scrubbed.contains("abc123"), "{scrubbed}");
assert!(scrubbed.contains("other=keep"), "{scrubbed}");
}
}
#[test]
fn does_not_match_secret_key_as_substring() {
let value = "xpassword=keep";
assert_eq!(scrub_value(value), value);
}
#[test]
fn empty_string_is_unaffected() {
assert_eq!(scrub_value(""), "");
}
#[test]
fn error_category_is_reused_not_reinvented() {
let _ = ErrorCategory::Auth;
assert_eq!(ErrorCategory::Auth.to_string(), "auth");
}
#[test]
fn scrub_result_walks_nested_json_strings() {
let value = serde_json::json!({
"url": "postgres://app:s3cr3t@db.example.com:5432/app",
"nested": {
"dsn": "host=db password=hunter2",
"safe": "LinksController::index",
"count": 3
},
"list": ["token=abc123", "/links/{link}"]
});
let scrubbed = scrub_result(value);
let s = serde_json::to_string(&scrubbed).expect("serialize");
assert!(!s.contains("s3cr3t"), "{s}");
assert!(!s.contains("hunter2"), "{s}");
assert!(!s.contains("abc123"), "{s}");
assert!(s.contains("[redacted]"), "{s}");
assert!(s.contains("LinksController::index"), "{s}");
assert!(s.contains("/links/{link}"), "{s}");
assert!(s.contains("3"), "{s}");
}
#[test]
fn scrub_result_passes_through_non_strings() {
let value = serde_json::json!({"n": 42, "b": true, "nil": null});
let scrubbed = scrub_result(value);
assert_eq!(scrubbed["n"], 42);
assert_eq!(scrubbed["b"], true);
assert_eq!(scrubbed["nil"], serde_json::Value::Null);
}
}