martin-core 0.9.0

Basic building blocks of MapLibre's Martin tile server.
Documentation
//! A wrapper around a `PostgreSQL` connection string that redacts its password when displayed.

use std::fmt::{self, Debug, Display};
use std::sync::LazyLock;

use regex::Regex;
use url::Url;

/// Placeholder substituted for any password found in a connection string.
const REDACTED: &str = "REDACTED";

/// A `PostgreSQL` connection string whose password is redacted for display.
///
/// `PostgreSQL` connection strings usually embed a password, either in the userinfo of a URL
/// (`postgres://user:secret@host/db`) or as a `password=` keyword/value pair.
/// Logging such a string verbatim leaks the credential, see <https://github.com/maplibre/martin/issues/2895>.
///
/// This wrapper stores only the already-redacted form, so the original password can never be
/// recovered or accidentally logged through [`Display`] or [`Debug`].
#[derive(Clone, PartialEq, Eq)]
pub struct RedactedConnectionString(String);

impl RedactedConnectionString {
    /// Redacts any password embedded in `conn_str` and wraps the result.
    #[must_use]
    pub fn new(conn_str: &str) -> Self {
        Self(redact(conn_str))
    }
}

impl From<&str> for RedactedConnectionString {
    fn from(conn_str: &str) -> Self {
        Self::new(conn_str)
    }
}

impl Display for RedactedConnectionString {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl Debug for RedactedConnectionString {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Render as a bare quoted string, not `RedactedConnectionString("…")`.
        Debug::fmt(&self.0, f)
    }
}

/// Redacts the password in a `PostgreSQL` connection string, whichever format it is in.
fn redact(conn_str: &str) -> String {
    if let Ok(mut url) = Url::parse(conn_str) {
        if url.password().is_some() {
            let _ = url.set_password(Some(REDACTED));
        }
        // A parseable URL may still carry `?password=` in its query string.
        return redact_keyword_password(url.as_str());
    }
    // Not a parseable URL: a malformed URL or the `keyword=value` format.
    redact_keyword_password(&redact_url_userinfo(conn_str))
}

/// Redacts the `scheme://user:PASSWORD@` userinfo of a URL the `url` crate could not parse.
fn redact_url_userinfo(conn_str: &str) -> String {
    static USERINFO: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"(?i)([a-z][a-z0-9+.\-]*://[^@/?#:\s]*:)[^@/?#\s]*(@)")
            .expect("userinfo redaction regex is valid")
    });
    USERINFO
        .replace_all(conn_str, |caps: &regex::Captures| {
            format!("{}{REDACTED}{}", &caps[1], &caps[2])
        })
        .into_owned()
}

/// Redacts a `password=value` pair, as used by the `keyword=value` format and URL query strings.
fn redact_keyword_password(conn_str: &str) -> String {
    static KEYWORD: LazyLock<Regex> = LazyLock::new(|| {
        // `password = 'quoted value'` or `password=unquoted`, delimited by whitespace or `&`.
        Regex::new(r"(?i)(\bpassword\s*=\s*)('(?:[^'\\]|\\.)*'|[^\s&]*)")
            .expect("password redaction regex is valid")
    });
    KEYWORD
        .replace_all(conn_str, |caps: &regex::Captures| {
            format!("{}{REDACTED}", &caps[1])
        })
        .into_owned()
}

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

    use super::*;

    #[rstest]
    #[case::url_userinfo(
        "postgres://user:secret@localhost:5432/db",
        "postgres://user:REDACTED@localhost:5432/db"
    )]
    #[case::url_userinfo_with_colon(
        "postgres://user:p@ss:word@localhost:5432/db",
        "postgres://user:REDACTED@localhost:5432/db"
    )]
    #[case::url_userinfo_percent_encoded(
        "postgres://user:p%26ss%3Aword@localhost:5432/db",
        "postgres://user:REDACTED@localhost:5432/db"
    )]
    #[case::url_query_password(
        "postgres://host/db?password=secret&sslmode=require",
        "postgres://host/db?password=REDACTED&sslmode=require"
    )]
    #[case::url_query_password_percent_encoded(
        "postgres://host/db?password=p%26w%3Drd&sslmode=require",
        "postgres://host/db?password=REDACTED&sslmode=require"
    )]
    #[case::keyword_password(
        "host=localhost password=secret sslmode=verify-full",
        "host=localhost password=REDACTED sslmode=verify-full"
    )]
    #[case::keyword_password_quoted(
        "host=localhost password='se cret' dbname=db",
        "host=localhost password=REDACTED dbname=db"
    )]
    #[case::url_no_password(
        "postgres://user@localhost:5432/db",
        "postgres://user@localhost:5432/db"
    )]
    #[case::url_no_userinfo("postgres://localhost:5432/db", "postgres://localhost:5432/db")]
    #[case::keyword_password_substring(
        "host=localhost mypassword=keep",
        "host=localhost mypassword=keep"
    )]
    #[case::keyword_no_password(
        "host=localhost dbname=db sslmode=verify-full",
        "host=localhost dbname=db sslmode=verify-full"
    )]
    fn redacts(#[case] conn_str: &str, #[case] expected: &str) {
        assert_eq!(
            RedactedConnectionString::new(conn_str).to_string(),
            expected
        );
    }

    #[rstest]
    #[case::bad_port(
        "postgres://postgres:testpassword@host:notaport/db",
        "postgres://postgres:REDACTED@host:notaport/db"
    )]
    #[case::bad_port_colon_password(
        "postgres://postgres:test:password@host:notaport/db",
        "postgres://postgres:REDACTED@host:notaport/db"
    )]
    fn redacts_unparseable_url(#[case] conn_str: &str, #[case] expected: &str) {
        assert!(
            Url::parse(conn_str).is_err(),
            "test needs the regex fallback path"
        );
        assert_eq!(
            RedactedConnectionString::new(conn_str).to_string(),
            expected
        );
    }
}