Skip to main content

acme_proxy_core/
logfields.rs

1//! Typed helpers for structured log fields, so a field has one spelling and
2//! one wire type wherever it is logged.
3
4/// A duration in milliseconds, as a log field.
5///
6/// `Duration::as_millis` returns `u128`, which `tracing` has no primitive
7/// visitor for and so records through `Display` — landing in the JSON output as
8/// a quoted `"42"` rather than the number `42`. That output exists to be
9/// aggregated by machines, and a latency field a collector has to re-parse (or
10/// silently indexes as a string) is a defect in it. Every duration logged
11/// anywhere in this crate goes through here.
12///
13/// The saturation is unreachable — `u64::MAX` milliseconds is some 584 million
14/// years — and is written out only to avoid a silent truncating cast.
15#[must_use]
16pub fn millis(duration: std::time::Duration) -> u64 {
17    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
18}
19
20/// A connection URL with any password replaced, for a log line or an error.
21///
22/// `database.url` is the one configuration value that is both logged verbatim
23/// at startup and rendered into [`reload`]'s refusal message, and a PostgreSQL
24/// DSN carries `user:password@` where a SQLite path carries nothing. The rule
25/// it follows is already written down in `server::reload`: a projection must be
26/// opaque if any field it reaches can hold a credential.
27///
28/// Deliberately string-based rather than parsed. This runs on whatever an
29/// operator put in the configuration, including a value that is not a URL at
30/// all, and a redactor that only works on input it can parse is the wrong shape
31/// — an unparseable DSN must still not have its password logged. Anything
32/// without a `://` or without credentials is returned as it stands.
33///
34/// `net::proxy` has a `Url`-typed twin for the case where the value has already
35/// been parsed, which also keeps the trailing slash `Url` adds.
36///
37/// [`reload`]: https://docs.rs/acme-proxy-server
38#[must_use]
39pub fn redact_url(url: &str) -> std::borrow::Cow<'_, str> {
40    let Some((scheme, rest)) = url.split_once("://") else {
41        return std::borrow::Cow::Borrowed(url);
42    };
43    // Only the authority may carry credentials, and it ends at the first `/`.
44    let authority_end = rest.find('/').unwrap_or(rest.len());
45    let (authority, path) = rest.split_at(authority_end);
46    let Some((credential, host)) = authority.rsplit_once('@') else {
47        return std::borrow::Cow::Borrowed(url);
48    };
49    let user = credential.split_once(':').map_or(credential, |(u, _)| u);
50    std::borrow::Cow::Owned(format!("{scheme}://{user}:***@{host}{path}"))
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn a_password_never_survives() {
59        assert_eq!(
60            redact_url("postgres://acme:hunter2@db.internal:5432/acme"),
61            "postgres://acme:***@db.internal:5432/acme"
62        );
63    }
64
65    /// The default, and every SQLite spelling: nothing to hide, nothing changed.
66    #[test]
67    fn a_url_without_credentials_is_returned_as_it_stands() {
68        for url in [
69            "sqlite://sqlite.db",
70            "sqlite:///var/lib/acme-proxy/acme.db",
71            "postgres://db.internal/acme",
72            "not a url at all",
73            "",
74        ] {
75            assert_eq!(redact_url(url), url, "{url}");
76        }
77    }
78
79    /// A user with no password still has its name kept: an operator reading the
80    /// log needs to know *which* role failed to connect.
81    #[test]
82    fn a_bare_user_is_kept_and_still_marked() {
83        assert_eq!(
84            redact_url("postgres://acme@db.internal/acme"),
85            "postgres://acme:***@db.internal/acme"
86        );
87    }
88
89    /// An `@` in the password must not be mistaken for the authority's own.
90    #[test]
91    fn the_last_at_sign_separates_the_credential() {
92        assert_eq!(
93            redact_url("postgres://acme:p@ss@db.internal/acme"),
94            "postgres://acme:***@db.internal/acme"
95        );
96    }
97
98    /// A `@` after the authority is part of the path and decides nothing.
99    #[test]
100    fn an_at_sign_in_the_path_is_not_a_credential() {
101        assert_eq!(
102            redact_url("postgres://db.internal/acme@weird"),
103            "postgres://db.internal/acme@weird"
104        );
105    }
106}