apollo-redaction 0.2.0

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

/// Redact the password component of a URL, preserving all other components.
///
/// If the URL contains a password component, it is replaced with `***`. All other components —
/// scheme, username, host, port, path, query, fragment — are preserved. The output is the URL as
/// normalised by the `url` crate (for example, a trailing `/` may be added to URLs with an
/// authority and no path, and an empty password is dropped entirely along with its `:` separator).
/// If the input cannot be parsed as a URL, the output is `[REDACTED: invalid URL]`.
///
/// ```rust
/// use apollo_redaction::Redacted;
/// use apollo_redaction::strategy::UrlPasswordRedactor;
///
/// fn url(input: &str) -> String {
///     Redacted::<_, UrlPasswordRedactor>::new(input).to_string()
/// }
///
/// assert_eq!(url("http://alice:s3cr3t@proxy.corp.example.com:3128/"), "http://alice:***@proxy.corp.example.com:3128/");
/// assert_eq!(url("postgres://user:hunter2@db.internal/app"), "postgres://user:***@db.internal/app");
/// assert_eq!(url("http://proxy.example.com:3128"), "http://proxy.example.com:3128/");
/// assert_eq!(url("not a url"), "[REDACTED: invalid URL]");
/// ```
#[derive(Default, Clone)]
pub struct UrlPasswordRedactor;

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

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

    if parsed.password().is_some() {
        // set_password only fails for cannot-be-a-base URLs; a URL with a password
        // always has authority, so this will succeed.
        let _ = parsed.set_password(Some("***"));
    }
    write!(f, "{}", parsed)
}

/// 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"), "****#****");
    }

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

    #[test]
    fn password_is_replaced_with_stars() {
        assert_eq!(
            mask("http://alice:s3cr3t@proxy.corp.example.com:3128/"),
            "http://alice:***@proxy.corp.example.com:3128/"
        );
    }

    #[test]
    fn url_without_explicit_path_is_normalised_by_url_crate() {
        // The url crate normalises URLs with authority by adding a trailing "/".
        assert_eq!(
            mask("http://alice:s3cr3t@proxy.corp.example.com:3128"),
            "http://alice:***@proxy.corp.example.com:3128/"
        );
    }

    #[test]
    fn postgres_url_password_is_replaced() {
        assert_eq!(
            mask("postgres://user:hunter2@db.internal/app"),
            "postgres://user:***@db.internal/app"
        );
    }

    #[test]
    fn url_without_password_is_normalised() {
        assert_eq!(
            mask("http://proxy.example.com:3128"),
            "http://proxy.example.com:3128/"
        );
    }

    #[test]
    fn url_with_username_but_no_password_is_normalised() {
        assert_eq!(
            mask("http://alice@proxy.example.com"),
            "http://alice@proxy.example.com/"
        );
    }

    #[test]
    fn empty_password_is_normalised_away() {
        // The url crate treats `user:` with no password as no userinfo password at all and
        // drops the trailing colon during normalisation, so there is nothing to mask.
        assert_eq!(
            mask("postgres://user:@db.internal/app"),
            "postgres://user@db.internal/app"
        );
    }

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

    #[test]
    fn works_with_url_type() {
        let url: Url = "postgres://user:hunter2@db.internal/app".parse().unwrap();
        let redacted = Redacted::<_, UrlPasswordRedactor>::new(url);
        assert_eq!(redacted.to_string(), "postgres://user:***@db.internal/app");
    }
}