use serde::{Serialize, Serializer};
use std::fmt;
pub const REDACTED: &str = "[REDACTED]";
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Sensitive<T> {
value: T,
}
impl<T> Sensitive<T> {
pub const fn new(value: T) -> Self {
Self { value }
}
pub const fn expose(&self) -> &T {
&self.value
}
pub fn into_inner(self) -> T {
self.value
}
pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Sensitive<U> {
Sensitive::new(f(self.value))
}
}
impl<T> From<T> for Sensitive<T> {
fn from(value: T) -> Self {
Self::new(value)
}
}
impl<T> fmt::Display for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
impl<T> fmt::Debug for Sensitive<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Sensitive([REDACTED])")
}
}
impl<T> Serialize for Sensitive<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(REDACTED)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RedactionPolicy {
#[default]
Redact,
Reveal,
}
impl RedactionPolicy {
pub fn format<T>(self, value: &Sensitive<T>) -> String
where
T: fmt::Display,
{
match self {
Self::Redact => REDACTED.to_owned(),
Self::Reveal => value.expose().to_string(),
}
}
}
pub fn is_sensitive_key(name: &str) -> bool {
let normalized: String = name
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
if matches!(
normalized.as_str(),
"password"
| "passwd"
| "token"
| "accesstoken"
| "refreshtoken"
| "secret"
| "clientsecret"
| "authorization"
| "cookie"
| "setcookie"
| "apikey"
) {
return true;
}
name.split(|character: char| !character.is_ascii_alphanumeric())
.filter(|part| !part.is_empty())
.any(|part| {
let part = part.to_ascii_lowercase();
matches!(
part.as_str(),
"password" | "passwd" | "token" | "secret" | "authorization" | "cookie"
)
})
}
pub fn sanitize_path(value: &str) -> String {
std::path::Path::new(value)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| REDACTED.to_owned())
}
pub fn sanitize_url(value: &str) -> String {
let without_fragment = value.split_once('#').map_or(value, |(head, _)| head);
let without_query = without_fragment
.split_once('?')
.map_or(without_fragment, |(head, _)| head);
if let Some(rest) = without_query.strip_prefix("//") {
let (authority, path) = match rest.split_once('/') {
Some((authority, path)) => (authority, Some(path)),
None => (rest, None),
};
let safe_authority = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
return match path {
Some(path) => {
format!("//{safe_authority}/{path}")
}
None => {
format!("//{safe_authority}")
}
};
}
let Some((scheme, rest)) = without_query.split_once("://") else {
return without_query.to_owned();
};
let (authority, path) = match rest.split_once('/') {
Some((authority, path)) => (authority, Some(path)),
None => (rest, None),
};
let safe_authority = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
match path {
Some(path) => {
format!("{scheme}://{safe_authority}/{path}")
}
None => {
format!("{scheme}://{safe_authority}")
}
}
}
#[cfg(test)]
mod sanitizer_tests {
use super::sanitize_url;
#[test]
fn sanitizer_removes_hierarchical_url_userinfo_query_and_fragment() {
assert_eq!(
sanitize_url("https://alice:secret@example.com/docs?token=abc#private"),
"https://example.com/docs"
);
}
#[test]
fn sanitizer_handles_scheme_relative_userinfo() {
assert_eq!(
sanitize_url("//alice:secret@example.com/docs?token=abc#private"),
"//example.com/docs"
);
}
#[test]
fn sanitizer_removes_query_and_fragment_from_relative_links() {
assert_eq!(
sanitize_url("/docs/page?token=secret#private"),
"/docs/page"
);
}
#[test]
fn sanitizer_preserves_safe_hierarchical_urls() {
assert_eq!(
sanitize_url("https://example.com/docs/reference"),
"https://example.com/docs/reference"
);
}
}