cellos-core 0.7.3

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! Redact secrets from strings intended for **operator logs** (not a crypto primitive).

use url::Url;

/// Remove URL username/password (userinfo) so connection strings can be logged safely.
///
/// Uses [`Url::parse`] when possible (handles IPv6, encoded passwords, etc.). If parsing fails,
/// applies a conservative heuristic: the first `@` after `://` is treated as separating userinfo
/// from host.
pub fn redact_url_credentials_for_logs(input: &str) -> String {
    if let Ok(mut u) = Url::parse(input) {
        let has_user = !u.username().is_empty();
        let has_pass = u.password().is_some();
        if has_user || has_pass {
            let _ = u.set_username("");
            let _ = u.set_password(None);
            return u.to_string();
        }
        // Url::parse succeeded but found no credentials on the first URL.
        // Fall through to the heuristic so credentials in a trailing
        // URL (multi-URL log lines) are still caught.
    }
    heuristic_redact_userinfo(input)
}

/// Replace exact occurrences of `raw_connection_url` in `err_text` with
/// [`redact_url_credentials_for_logs`] output when they differ.
///
/// Some NATS/async clients echo the full connection string (including userinfo) in `Display`
/// errors — use this before logging or wrapping in [`crate::CellosError`].
pub fn redact_url_if_echoed_in_text(err_text: &str, raw_connection_url: &str) -> String {
    let redacted = redact_url_credentials_for_logs(raw_connection_url);
    if raw_connection_url == redacted || !err_text.contains(raw_connection_url) {
        return err_text.to_string();
    }
    err_text.replace(raw_connection_url, &redacted)
}

fn heuristic_redact_userinfo(s: &str) -> String {
    let Some(pos) = s.find("://") else {
        return s.to_string();
    };
    let after_scheme = &s[pos + 3..];
    let Some(at_rel) = after_scheme.find('@') else {
        return s.to_string();
    };
    // Skip the `@` itself so the output is idempotent (no stray `@` for the
    // next pass to re-process as a new userinfo boundary).
    format!("{}<redacted>{}", &s[..pos + 3], &after_scheme[at_rel + 1..])
}

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

    #[test]
    fn leaves_bare_nats_url_unchanged() {
        assert_eq!(
            redact_url_credentials_for_logs("nats://127.0.0.1:4222"),
            "nats://127.0.0.1:4222"
        );
    }

    #[test]
    fn strips_user_password_nats() {
        let out = redact_url_credentials_for_logs("nats://alice:secret@broker.internal:4222");
        assert!(!out.contains("alice"), "{out}");
        assert!(!out.contains("secret"), "{out}");
        assert!(out.contains("broker.internal"), "{out}");
    }

    #[test]
    fn heuristic_when_not_parseable() {
        let out = heuristic_redact_userinfo("nats://x:y@host:1/extra");
        assert_eq!(out, "nats://<redacted>host:1/extra");
    }

    #[test]
    fn redacts_echoed_url_in_error_blob() {
        let raw = "nats://alice:hunter2@10.0.0.5:4222";
        let err = format!("connection refused: {raw} (try again)");
        let out = redact_url_if_echoed_in_text(&err, raw);
        assert!(!out.contains("alice"), "{out}");
        assert!(!out.contains("hunter2"), "{out}");
        assert!(out.contains("10.0.0.5"), "{out}");
    }

    #[test]
    fn leaves_error_unchanged_when_url_not_echoed() {
        let err = "IO error: connection refused";
        assert_eq!(
            redact_url_if_echoed_in_text(err, "nats://u:p@h:1"),
            err.to_string()
        );
    }
}