apollo-redaction 0.1.0

Redaction utility for sensitive data in error messages, debug logs, etc.
Documentation
use crate::RedactionStrategy;
use std::fmt;
use url::Url;

/// Redact URLs, removing the scheme, hostname, port, auth, query values, and fragments.
///
/// ```rust
/// use apollo_redaction::Redacted;
/// use apollo_redaction::strategy::UrlHostRedactor;
///
/// fn url(input: &str) -> String {
///     Redacted::<_, UrlHostRedactor>::new(input).to_string()
/// }
///
/// assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
/// assert_eq!(url("https://example.com"), "****");
/// assert_eq!(url("postgres://user:pass@host/db"), "****/db");
/// assert_eq!(url("https://example.com/api?key=secret"), "****/api?key=****");
/// assert_eq!(url("https://example.com/page#section"), "****/page#****");
/// ```
#[derive(Default, Clone)]
pub struct UrlHostRedactor;

impl<T: AsRef<str>> RedactionStrategy<T> for UrlHostRedactor {
    fn display(&self, value: &T, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        redact_url(value.as_ref(), f)
    }
}

fn redact_url(input: &str, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    let Ok(parsed) = Url::parse(input) else {
        return write!(f, "[REDACTED: invalid URL]");
    };

    write!(f, "****")?;

    // Write path (the url crate returns "/" for URLs with authority even when no path)
    let path = parsed.path();
    if path != "/" {
        write!(f, "{}", path)?;
    }

    // Write redacted query parameters
    if parsed.query().is_some() {
        write!(f, "?")?;
        let mut first = true;
        for (key, _value) in parsed.query_pairs() {
            if !first {
                write!(f, "&")?;
            }
            first = false;
            write!(f, "{}=****", key)?;
        }
    }

    // Write redacted fragment
    if parsed.fragment().is_some() {
        write!(f, "#****")?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Redacted;

    fn url(input: &str) -> String {
        Redacted::<_, UrlHostRedactor>::new(input).to_string()
    }

    #[test]
    fn test_https_with_path() {
        assert_eq!(url("https://example.com/api/secret"), "****/api/secret");
    }

    #[test]
    fn test_no_path() {
        assert_eq!(url("https://example.com"), "****");
    }

    #[test]
    fn test_with_credentials() {
        assert_eq!(url("postgres://user:pass@host/db"), "****/db");
    }

    #[test]
    fn test_invalid_url() {
        assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
    }

    #[test]
    fn test_with_query_string() {
        assert_eq!(
            url("https://example.com/api?key=secret"),
            "****/api?key=****"
        );
    }

    #[test]
    fn test_with_multiple_query_params() {
        assert_eq!(
            url("https://example.com/api?key=secret&token=abc123"),
            "****/api?key=****&token=****"
        );
    }

    #[test]
    fn test_with_query_flag_no_value() {
        assert_eq!(
            url("https://example.com/api?debug&key=secret"),
            "****/api?debug=****&key=****"
        );
    }

    #[test]
    fn test_with_fragment() {
        assert_eq!(url("https://example.com/page#section"), "****/page#****");
    }

    #[test]
    fn test_with_query_and_fragment() {
        assert_eq!(
            url("https://example.com/api?key=secret#section"),
            "****/api?key=****#****"
        );
    }

    #[test]
    fn test_query_without_path() {
        assert_eq!(url("https://example.com?key=secret"), "****?key=****");
    }

    #[test]
    fn test_fragment_without_path() {
        assert_eq!(url("https://example.com#section"), "****#****");
    }
}