Skip to main content

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;