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}