decdn_common/redact.rs
1//! Credential redaction for URL-ish strings shared across the binaries.
2//!
3//! Two related invariants live here, both keyed on the rule that a URL can
4//! carry a secret no log or operator-facing error may echo:
5//!
6//! - **Userinfo** — relay/origin URLs can carry `user:pass@` userinfo. When
7//! such a string is named in a parse error or log (config-validate time,
8//! [`crate::config`]; node bring-up, the `decdn-node` runtime), the
9//! credentials are scrubbed by [`redact_userinfo`], which preserves the host
10//! and path so the offending entry is still identifiable.
11//! - **Whole URL** — a chain `rpc_url` secret commonly lives in the path or
12//! query (Infura/Alchemy keys), which userinfo redaction would NOT scrub. So
13//! when an `rpc_url` reaches a *transport/chain error*, [`strip_urls`]
14//! removes the URL entirely. The failure class (timeout, connection refused,
15//! DNS, TLS) usually lives in the error's `source()` chain, not in its
16//! top-level Display. [`sanitize_err_chain`] (for an `anyhow::Error`) and
17//! [`sanitize_error_sources`] (for a typed `std::error::Error`) render that
18//! chain, so the class survives the strip. [`sanitize_rpc_display`] renders
19//! only the top-level Display, so it suits a plain `Display` value such as a
20//! `String` reason.
21//!
22//! Routing every gate through this single module keeps each invariant from
23//! drifting between copies.
24
25use std::borrow::Cow;
26
27/// Replace any `user:pass@` userinfo in a URL-ish string with `***@` so a
28/// parse-failure log/error can name the offending entry without leaking
29/// credentials.
30///
31/// The input may be an *already-malformed* entry, so we can't lean on a URL
32/// parser. The authority begins after the first `://` (a scheme separator) or
33/// at the start of the string when there is none — a typo'd entry may drop the
34/// scheme yet still carry credentials. Within the authority (up to the host's
35/// first `/`, `?`, or `#`), userinfo runs to the *last* `@`, so a password
36/// containing a literal `@` is fully redacted, while an `@` in the path/query/
37/// fragment is left intact. Strings with no authority `@` pass through
38/// unchanged (borrowed, no copy), so non-credential values still appear
39/// verbatim in errors.
40pub fn redact_userinfo(raw: &str) -> Cow<'_, str> {
41 let authority_start = raw.find("://").map_or(0, |i| i + 3);
42 let after = raw.get(authority_start..).unwrap_or("");
43 let host_delim = after.find(['/', '?', '#']).unwrap_or(after.len());
44 match after.get(..host_delim).and_then(|a| a.rfind('@')) {
45 Some(at) => {
46 let prefix = raw.get(..authority_start).unwrap_or("");
47 let rest = after.get(at + 1..).unwrap_or("");
48 Cow::Owned(format!("{prefix}***@{rest}"))
49 }
50 None => Cow::Borrowed(raw),
51 }
52}
53
54/// Strip URL-bearing fragments from an error's rendered text so an `rpc_url`
55/// secret never reaches a log or operator-facing error. Any failure class in
56/// the input text (timeout / connection refused / DNS / TLS) is kept; to
57/// include the `source()` chain, call [`sanitize_error_sources`] or
58/// [`sanitize_err_chain`].
59///
60/// Unlike [`redact_userinfo`], which only scrubs `user:pass@` userinfo, this
61/// removes the whole URL: an `rpc_url` secret commonly lives in the path or
62/// query (Infura/Alchemy keys), matching `config validate`'s policy of never
63/// echoing `rpc_url`. Two fragment shapes are scrubbed:
64///
65/// - `reqwest`'s Display tail ` for url (<url>)` (reqwest 0.13.x; the inner
66/// `write!(f, " for url ({url})")`), which `alloy`'s `#[error("{0}")]`-
67/// transparent transport error forwards verbatim — the entire ` for url (...)`
68/// clause is dropped; and
69/// - any remaining bare `<scheme>://` token, replaced with `<redacted-url>` as
70/// a backstop for non-`reqwest` renderings.
71///
72/// Text with no URL scheme passes through borrowed (no copy).
73pub fn strip_urls(raw: &str) -> Cow<'_, str> {
74 match remove_for_url_clauses(raw) {
75 // Nothing dropped: feed the original slice on so the borrow survives.
76 Cow::Borrowed(s) => redact_bare_urls(s),
77 // A clause was dropped. Only re-scan when a bare URL might remain;
78 // otherwise hand back the owned buffer without a redundant copy.
79 Cow::Owned(s) if s.contains("://") => Cow::Owned(redact_bare_urls(&s).into_owned()),
80 Cow::Owned(s) => Cow::Owned(s),
81 }
82}
83
84/// Drop every ` for url (...)` clause (reqwest's Display tail) from `s`.
85fn remove_for_url_clauses(s: &str) -> Cow<'_, str> {
86 const NEEDLE: &str = " for url (";
87 if !s.contains(NEEDLE) {
88 return Cow::Borrowed(s);
89 }
90 let mut out = String::with_capacity(s.len());
91 let mut rest = s;
92 while let Some(pos) = rest.find(NEEDLE) {
93 out.push_str(rest.get(..pos).unwrap_or(""));
94 // reqwest writes ` for url ({url})`; a URL contains no whitespace, so
95 // scan the token to the next whitespace/end rather than the first `)` —
96 // a `)` can appear unescaped in a URL path/query (`url::Url` leaves
97 // sub-delims as-is), and stopping there would strand the secret-bearing
98 // tail. A single trailing `)` (the clause's own close) is then dropped.
99 let after = rest.get(pos + NEEDLE.len()..).unwrap_or("");
100 let url_end = after
101 .find(|c: char| c.is_whitespace())
102 .unwrap_or(after.len());
103 let tail = after.get(url_end..).unwrap_or("");
104 rest = tail.strip_prefix(')').unwrap_or(tail);
105 }
106 out.push_str(rest);
107 Cow::Owned(out)
108}
109
110/// Replace each bare `<scheme>://<token>` in `s` with `<redacted-url>`.
111///
112/// Keys off the `://` separator and walks back over the scheme characters
113/// (RFC 3986: ALPHA / DIGIT / `+` / `-` / `.`), so it matches ANY scheme
114/// regardless of case — `http`, `HTTPS`, `ws`, `git+ssh`, … — rather than a
115/// hard-coded `http`/`https` prefix a future non-`reqwest` renderer (or an
116/// uppercased URL) could slip past.
117fn redact_bare_urls(s: &str) -> Cow<'_, str> {
118 if !s.contains("://") {
119 return Cow::Borrowed(s);
120 }
121 let mut out = String::with_capacity(s.len());
122 let mut rest = s;
123 while let Some(sep) = rest.find("://") {
124 // Walk back over the scheme characters to the URL's start.
125 let before = rest.get(..sep).unwrap_or("");
126 let scheme_len = before
127 .bytes()
128 .rev()
129 .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
130 .count();
131 let url_start = sep - scheme_len;
132 out.push_str(rest.get(..url_start).unwrap_or(""));
133 out.push_str("<redacted-url>");
134 // A URL contains no whitespace; consume the whole token. `)`/`?`/`&`
135 // etc. can be part of the path/query, so only whitespace ends it —
136 // over-consuming adjacent punctuation is safe; leaking a secret is not.
137 let after = rest.get(sep + 3..).unwrap_or("");
138 let end = after
139 .find(|c: char| c.is_whitespace())
140 .unwrap_or(after.len());
141 rest = after.get(end..).unwrap_or("");
142 }
143 out.push_str(rest);
144 Cow::Owned(out)
145}
146
147/// Render a plain `Display` value, with URL fragments stripped, for a single
148/// log/error line. Use for a value with no `source()` chain, such as a
149/// `String` reason.
150///
151/// For an error, prefer a helper that walks the chain: Display renders only
152/// the *outermost* layer, so the failure class beneath it is lost.
153/// [`sanitize_err_chain`] takes an `anyhow::Error`, whose `.with_context(…)`
154/// layers would otherwise replace the underlying reason.
155/// [`sanitize_error_sources`] takes a typed `std::error::Error` (an `alloy`
156/// `ContractError`, `RpcError`, or `PendingTransactionError`), whose
157/// transport class (timeout, connection refused, DNS, TLS) sits in `source()`.
158pub fn sanitize_rpc_display(err: impl std::fmt::Display) -> String {
159 strip_urls(&err.to_string()).into_owned()
160}
161
162/// Render a typed error and each layer of its `source()` chain as
163/// `outer: cause: cause`, with URL fragments stripped.
164///
165/// Use for a typed `std::error::Error`, such as an `alloy` transport or
166/// contract error. Its top-level Display often names only the operation and
167/// the URL, while the failure class (timeout, connection refused, DNS, TLS)
168/// is a deeper `source()`. A layer is skipped when its Display is empty, or
169/// when the previous non-empty layer's raw Display ends with it at a
170/// non-alphanumeric boundary: a wrapper that ends its message with its source
171/// (`"{0}"`, `"transport: {0}"`) would otherwise print that source twice. A
172/// cause that only appears inside an earlier message or URL (`"timeout"` under
173/// `"request timeout exceeded"`) is still printed. Each layer is stripped on
174/// its own before the join, so a URL token cannot swallow the `": "` separator
175/// after it. For an `anyhow::Error`, use [`sanitize_err_chain`].
176pub fn sanitize_error_sources(err: &(dyn std::error::Error + 'static)) -> String {
177 let mut prev = err.to_string();
178 let mut out = strip_urls(&prev).into_owned();
179 let mut next = err.source();
180 while let Some(layer) = next {
181 let text = layer.to_string();
182 if !text.is_empty() {
183 let repeats = prev.strip_suffix(text.as_str()).is_some_and(|head| {
184 head.chars()
185 .next_back()
186 .is_none_or(|c| !c.is_alphanumeric())
187 });
188 if !repeats {
189 out.push_str(": ");
190 out.push_str(&strip_urls(&text));
191 }
192 prev = text;
193 }
194 next = layer.source();
195 }
196 out
197}
198
199/// Render an `anyhow` error's full `context: cause: cause` chain with URL
200/// fragments stripped, through [`sanitize_error_sources`]: the context layers
201/// and the wrapped error's `source()` chain share one walk, so a repeated
202/// layer is printed once and each layer is stripped on its own.
203///
204/// Use for any propagated chain-RPC error — at the `main()` print boundary so
205/// no raw `rpc_url` is echoed, and at watcher `tracing` sites so the reason
206/// survives. The latter is not a style preference: a chain read is wrapped by
207/// `chain_events::timed`, whose "… timed out after 10s" is the *cause*, and
208/// call sites add context above it (`getOrigins(namespace=1)`,
209/// `nodeIdOf(0x…)`). Rendering such an error with plain Display prints only
210/// that context and drops the timeout entirely — the operator sees a bare
211/// restatement of what was attempted, never why it failed.
212pub fn sanitize_err_chain(err: &anyhow::Error) -> String {
213 sanitize_error_sources(err.as_ref())
214}
215
216#[cfg(test)]
217mod tests;