use super::Config;
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"api_key",
"apikey",
"token",
"access_token",
"refresh_token",
"secret",
"client_secret",
"password",
"passphrase",
"key",
"credential",
"credentials",
"signature",
"sig",
"x_amz_signature",
"x_goog_signature",
"shared_access_signature",
];
const SENSITIVE_ARG_FLAGS: &[&str] = &[
"-b",
"-u",
"--auth",
"--authorization",
"--cookie",
"--oauth2-bearer",
"--proxy-user",
"--user",
"--token",
"--access-token",
"--refresh-token",
"--api-key",
"--client-secret",
"--password",
"--passphrase",
"--secret",
"--key",
"--credential",
"--credentials",
"--signature",
"--sig",
];
fn percent_decode_for_classification(value: &str) -> String {
fn hex(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
decoded.push((high << 4) | low);
index += 3;
continue;
}
}
decoded.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&decoded).into_owned()
}
fn normalized_credential_key(value: &str) -> String {
let decoded = percent_decode_for_classification(value);
let mut normalized = String::with_capacity(decoded.len());
let mut separator = false;
for ch in decoded.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
separator = false;
} else if !normalized.is_empty() && !separator {
normalized.push('_');
separator = true;
}
}
while normalized.ends_with('_') {
normalized.pop();
}
normalized
}
fn is_sensitive_credential_key(value: &str) -> bool {
let normalized = normalized_credential_key(value);
SENSITIVE_QUERY_KEYS.contains(&normalized.as_str())
|| normalized.ends_with("_token")
|| normalized.ends_with("_secret")
|| normalized.ends_with("_password")
|| normalized.ends_with("_credential")
|| normalized.ends_with("_signature")
}
fn is_sensitive_header_name(value: &str) -> bool {
let normalized = normalized_credential_key(value);
normalized == "authorization"
|| normalized.ends_with("_authorization")
|| is_sensitive_credential_key(&normalized)
|| normalized
.strip_prefix("x_")
.is_some_and(is_sensitive_credential_key)
}
pub(super) fn redact_url_secrets(url: &str) -> String {
let (main, fragment) = match url.split_once('#') {
Some((m, f)) => (m, Some(f)),
None => (url, None),
};
let (authority_and_path, query) = match main.split_once('?') {
Some((b, q)) => (b, Some(q)),
None => (main, None),
};
let mut out = redact_url_userinfo(authority_and_path);
if let Some(q) = query {
out.push('?');
out.push_str(&redact_url_query(q));
}
if let Some(f) = fragment {
out.push('#');
out.push_str(f);
}
out
}
fn redact_url_userinfo(s: &str) -> String {
let Some((scheme, rest)) = s.split_once("://") else {
return s.to_string();
};
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (rest, None),
};
let authority = match authority.rsplit_once('@') {
Some((_userinfo, host)) => format!("{}@{host}", Config::REDACTED),
None => authority.to_string(),
};
let mut out = format!("{scheme}://{authority}");
if let Some(p) = path {
out.push('/');
out.push_str(p);
}
out
}
fn redact_url_query(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((k, _)) if is_sensitive_credential_key(k) => {
format!("{k}={}", Config::REDACTED)
}
_ => param.to_string(),
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_arg_flag(flag: &str) -> bool {
if !flag.starts_with('-') {
return false;
}
let flag = flag.trim_start_matches('-');
let normalized = normalized_credential_key(flag);
SENSITIVE_ARG_FLAGS
.iter()
.any(|candidate| normalized_credential_key(candidate.trim_start_matches('-')) == normalized)
|| is_sensitive_credential_key(&normalized)
}
pub(super) fn redact_arg_secrets(args: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut redact_next = false;
let mut redact_header_next = false;
for arg in args {
if redact_next {
out.push(Config::REDACTED.to_string());
redact_next = false;
continue;
}
if redact_header_next {
if arg
.split_once(':')
.is_some_and(|(name, _)| is_sensitive_header_name(name.trim()))
{
out.push(Config::REDACTED.to_string());
} else {
out.push(arg.clone());
}
redact_header_next = false;
continue;
}
match arg.split_once('=') {
Some((flag, _)) if is_sensitive_arg_flag(flag) => {
out.push(format!("{flag}={}", Config::REDACTED));
}
Some((flag, value)) if matches!(flag, "-H" | "--header") => {
if value
.split_once(':')
.is_some_and(|(name, _)| is_sensitive_header_name(name.trim()))
{
out.push(format!("{flag}={}", Config::REDACTED));
} else {
out.push(arg.clone());
}
}
_ if arg.strip_prefix("-H").is_some_and(|value| {
!value.is_empty()
&& value
.split_once(':')
.is_some_and(|(name, _)| is_sensitive_header_name(name.trim()))
}) =>
{
out.push(format!("-H{}", Config::REDACTED));
}
_ if ["-b", "-u"]
.iter()
.find_map(|flag| arg.strip_prefix(flag).map(|value| (*flag, value)))
.is_some_and(|(_, value)| !value.is_empty()) =>
{
let flag = &arg[..2];
out.push(format!("{flag}{}", Config::REDACTED));
}
_ if is_sensitive_arg_flag(arg) => {
out.push(arg.clone());
redact_next = true;
}
_ if matches!(arg.as_str(), "-H" | "--header") => {
out.push(arg.clone());
redact_header_next = true;
}
_ => out.push(arg.clone()),
}
}
out
}