Skip to main content

deps_core/redact/
wrapper.rs

1//! Pre-redacted text wrappers: a [`super::url::url_for_tracing`]/
2//! [`super::key::redact_declaration_key`] result held as a distinct type ([`RedactedUrl`]/
3//! [`RedactedName`]) so nothing raw is ever retained past construction.
4
5use std::marker::PhantomData;
6
7use super::key::redact_declaration_key;
8use super::url::url_for_tracing;
9
10mod private {
11    pub trait Sealed {}
12}
13
14/// Selects which redaction function a [`RedactedText`] applies at construction.
15///
16/// Sealed (issue #1216): mirrors `crate::net_policy`'s `RegistryUrlKind`/`private::Sealed`
17/// pattern for the same reason — [`UrlRedaction`] and [`NameRedaction`] are the only two
18/// kinds this crate ever needs, and a foreign implementor could otherwise mint a
19/// [`RedactedText`] that claims to be redacted without actually running either redaction
20/// function. Unlike `RegistryUrlKind`'s sealing module (`pub mod private`, deliberately
21/// reachable so sibling ecosystem crates in this workspace can implement it for their own
22/// marker types), this trait's `private` module is not `pub` at all: no crate outside this
23/// one ever needs to implement `RedactionKind`, so the plain same-crate sealed-trait idiom
24/// applies — even a downstream crate's own doctest cannot name `private::Sealed` to implement
25/// it.
26///
27/// # Examples
28///
29/// ```
30/// use deps_core::redact::{RedactionKind, UrlRedaction};
31///
32/// assert_eq!(
33///     UrlRedaction::redact("https://user:hunter2@registry.example/simple"),
34///     "https://***@registry.example/simple"
35/// );
36/// ```
37pub trait RedactionKind: private::Sealed {
38    /// Redacts `raw`, producing the text a [`RedactedText<Self>`] retains.
39    fn redact(raw: &str) -> String;
40}
41
42/// [`RedactedText`] marker selecting [`super::url::url_for_tracing`] — see [`RedactedUrl`].
43pub enum UrlRedaction {}
44
45impl private::Sealed for UrlRedaction {}
46
47impl RedactionKind for UrlRedaction {
48    fn redact(raw: &str) -> String {
49        url_for_tracing(raw)
50    }
51}
52
53/// [`RedactedText`] marker selecting [`super::key::redact_declaration_key`] — see
54/// [`RedactedName`].
55pub enum NameRedaction {}
56
57impl private::Sealed for NameRedaction {}
58
59impl RedactionKind for NameRedaction {
60    fn redact(raw: &str) -> String {
61        redact_declaration_key(raw).into_owned()
62    }
63}
64
65/// A value that has already been redacted (via `K::redact`) for safe inclusion in error or
66/// log output — the shared implementation behind [`RedactedUrl`] and [`RedactedName`] (issue
67/// #1216).
68///
69/// Eagerly redacted at construction: [`Self::new`] applies `K::redact` immediately and
70/// retains only the resulting text, so the raw value passed in is never stored, not even
71/// transiently. The only public read surface — [`Display`](std::fmt::Display),
72/// [`AsRef<str>`], a [`Debug`](std::fmt::Debug) impl that forwards to the redacted text, and
73/// [`Self::into_inner`] (which hands back that same already-redacted text by value) — is
74/// therefore always safe to log: there is no `expose()`-style escape hatch, because nothing raw
75/// remains to expose.
76///
77/// `PartialEq`/`Eq`/`Hash` compare the *redacted* text, not the original input — see
78/// [`RedactedUrl`]'s own doc for why this is intentional and what it means for using either
79/// alias as a cache key.
80///
81/// Carries no bound on `K` itself in its own definition — [`Clone`], [`Debug`](std::fmt::Debug),
82/// [`PartialEq`], [`Eq`], [`Hash`](std::hash::Hash), and [`Display`](std::fmt::Display) are all
83/// hand-written (not derived) so that none of them require `K: Clone`/`K: Hash`/etc., and
84/// `PhantomData<fn() -> K>` (rather than `PhantomData<K>`) keeps [`Send`]/[`Sync`]
85/// unconditional regardless of `K` — mirrors `crate::net_policy::ValidatedRegistryUrl<K>`'s
86/// identical shape for the identical reason.
87///
88/// # Examples
89///
90/// ```
91/// use deps_core::redact::{RedactedName, RedactedUrl};
92///
93/// let redacted = RedactedUrl::new("https://user:hunter2@registry.example/simple");
94/// assert_eq!(redacted.to_string(), "https://***@registry.example/simple");
95///
96/// let redacted = RedactedName::new("com.google.guava:guava");
97/// assert_eq!(redacted.to_string(), "com.google.guava:guava");
98/// ```
99pub struct RedactedText<K: RedactionKind>(String, PhantomData<fn() -> K>);
100
101impl<K: RedactionKind> RedactedText<K> {
102    /// Redacts `raw` immediately via `K::redact`, retaining only the resulting text.
103    #[must_use]
104    pub fn new(raw: &str) -> Self {
105        Self(K::redact(raw), PhantomData)
106    }
107
108    /// Takes ownership of the redacted text without allocating a second copy — unlike
109    /// [`Display`](std::fmt::Display)/[`AsRef<str>`], which only ever hand back a borrow and so
110    /// require the caller to clone if it needs an owned `String`.
111    #[must_use]
112    pub fn into_inner(self) -> String {
113        self.0
114    }
115}
116
117impl<K: RedactionKind> From<&str> for RedactedText<K> {
118    fn from(raw: &str) -> Self {
119        Self::new(raw)
120    }
121}
122
123impl<K: RedactionKind> From<String> for RedactedText<K> {
124    fn from(raw: String) -> Self {
125        Self::new(&raw)
126    }
127}
128
129impl<K: RedactionKind> Clone for RedactedText<K> {
130    fn clone(&self) -> Self {
131        Self(self.0.clone(), PhantomData)
132    }
133}
134
135impl<K: RedactionKind> PartialEq for RedactedText<K> {
136    fn eq(&self, other: &Self) -> bool {
137        self.0 == other.0
138    }
139}
140
141impl<K: RedactionKind> Eq for RedactedText<K> {}
142
143impl<K: RedactionKind> std::hash::Hash for RedactedText<K> {
144    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
145        self.0.hash(state);
146    }
147}
148
149impl<K: RedactionKind> std::fmt::Display for RedactedText<K> {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        f.write_str(&self.0)
152    }
153}
154
155impl<K: RedactionKind> std::fmt::Debug for RedactedText<K> {
156    /// Forwards to the redacted text's own `Debug` (a quoted string), not a struct-wrapper
157    /// rendering — so a `RedactedText` embedded in a hand-written `Debug` impl (see
158    /// `deps_core::error::DepsError`) reads identically to the plain `String` field it
159    /// replaces.
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        std::fmt::Debug::fmt(&self.0, f)
162    }
163}
164
165impl<K: RedactionKind> AsRef<str> for RedactedText<K> {
166    fn as_ref(&self) -> &str {
167        &self.0
168    }
169}
170
171impl<K: RedactionKind> PartialEq<str> for RedactedText<K> {
172    /// # Warning
173    ///
174    /// Compares against the *redacted* text, not `other` as raw input — e.g.
175    /// `RedactedUrl::new(u) == "https://h/p?token=a"` is `false` by construction, since the
176    /// left side has already dropped the query string.
177    fn eq(&self, other: &str) -> bool {
178        self.0 == other
179    }
180}
181
182impl<K: RedactionKind> PartialEq<&str> for RedactedText<K> {
183    /// # Warning
184    ///
185    /// Same redacted-text comparison as `PartialEq<str>` above — `other` is compared against
186    /// the redacted text, not treated as raw input.
187    fn eq(&self, other: &&str) -> bool {
188        self.0 == *other
189    }
190}
191
192/// A URL-bearing value that has already been redacted for safe inclusion in error or log
193/// output — the structural chokepoint for outbound-URL redaction (issue #789).
194///
195/// Applies [`super::url::url_for_tracing`]'s rules at construction (via `Self::new`) and
196/// retains only the resulting text — nothing raw is stored, not even transiently, and there
197/// is no escape hatch that could return it.
198///
199/// # Examples
200///
201/// ```
202/// use deps_core::redact::RedactedUrl;
203///
204/// let redacted = RedactedUrl::new("https://npm.internal/pkg?token=super-secret-value");
205/// assert_eq!(redacted.to_string(), "https://npm.internal/pkg");
206///
207/// let redacted = RedactedUrl::new("https://user:hunter2@registry.example/simple");
208/// assert_eq!(redacted.to_string(), "https://***@registry.example/simple");
209/// ```
210///
211/// `PartialEq`/`Eq`/`Hash` compare the *redacted* text, not the original input: two distinct
212/// URLs differing only by a stripped component (query string, fragment, or userinfo) compare
213/// equal here even though they were different requests — e.g. `?token=a` and `?token=b`
214/// against the same path both redact to the same value. This is intentional for this type's
215/// own purpose (deduplicating/comparing error variants in tests, `deps_core::error`'s own
216/// `assert_eq!` usage), but makes `RedactedUrl` unsuitable as a cache key or any other context
217/// that needs to distinguish the underlying raw URLs — use the raw `String`/`reqwest::Url`
218/// value for that instead.
219///
220/// Also masks a token-shaped host label or path piece with no credential syntax of its own
221/// (`https://ghp_<36>.registry.example/`, `https://npm-proxy.fury.io/AbCdEf1234567890xyz/acme/`)
222/// — see [`super::url::url_for_tracing`]'s own doc for the heuristic and its accepted
223/// false-positive class (#1429).
224pub type RedactedUrl = RedactedText<UrlRedaction>;
225
226/// A package/coordinate name, stored pre-redacted via [`super::key::redact_declaration_key`] so
227/// it is always safe to log or surface in a client-visible message (#1209).
228///
229/// Unlike [`RedactedUrl`] (built for actual URLs, which mangles ordinary Maven/Gradle
230/// coordinates such as `com.google.guava:guava` into `com.google.guava:***`), this alias uses
231/// `redact_declaration_key`'s credential-shape gate: a genuine package/coordinate name is left
232/// untouched, while a credential-shaped value (e.g. `deploy:TOKEN@host` embedded where a
233/// manifest expected a name) is redacted the same way.
234///
235/// # Examples
236///
237/// ```
238/// use deps_core::redact::RedactedName;
239///
240/// let redacted = RedactedName::new("com.google.guava:guava");
241/// assert_eq!(redacted.to_string(), "com.google.guava:guava");
242///
243/// let redacted = RedactedName::new("com.google.guava:deploy:TOKEN@git.internal.corp");
244/// assert_eq!(redacted.to_string(), "***@git.internal.corp");
245/// ```
246pub type RedactedName = RedactedText<NameRedaction>;
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// FR-003: `RedactedUrl::new` must match `url_for_tracing`'s output byte-for-byte for
253    /// every input this module's own regression suite already pins.
254    #[test]
255    fn test_redacted_url_matches_url_for_tracing_byte_for_byte() {
256        let inputs = [
257            "https://npm.internal/pkg?token=super-secret-value",
258            "https://user:hunter2@registry.example/simple?token=x",
259            "https://registry.example/simple",
260            "not-a-url-at-all",
261            "@types/node",
262            "c:/user:hunter2@evil",
263            "c:///user:hunter2@evil",
264            "https://npm-proxy.fury.io/AbCdEf1234567890xyz/acme/",
265        ];
266        for raw in inputs {
267            assert_eq!(RedactedUrl::new(raw).to_string(), url_for_tracing(raw));
268        }
269    }
270
271    /// The `RedactedName` equivalent of the `RedactedUrl` consistency check above (#1216).
272    #[test]
273    fn test_redacted_name_matches_redact_declaration_key_byte_for_byte() {
274        let inputs = [
275            "com.google.guava:guava",
276            "com.google.guava:deploy:TOKEN@git.internal.corp",
277            "source:Blocked",
278            "scope:@myorg",
279            "top-level",
280        ];
281        for raw in inputs {
282            assert_eq!(
283                RedactedName::new(raw).to_string(),
284                redact_declaration_key(raw)
285            );
286        }
287    }
288
289    /// NFR-004: the public read surface (`Display`/`AsRef<str>`/`into_inner`) returns redacted
290    /// text only, for a known-sensitive input — there is no accessor that could return `raw`.
291    /// `into_inner` is checked last since it consumes `self` (#1317 critic S3: this accessor
292    /// was added after this test was written and had gone uncovered by it).
293    #[test]
294    fn test_redacted_url_display_and_as_ref_never_expose_raw_credential() {
295        let raw = "https://user:hunter2@registry.example/simple?token=super-secret-value";
296        let redacted = RedactedUrl::new(raw);
297        assert!(!redacted.to_string().contains("hunter2"));
298        assert!(!redacted.to_string().contains("super-secret-value"));
299        assert!(!redacted.as_ref().contains("hunter2"));
300        assert!(!redacted.as_ref().contains("super-secret-value"));
301        assert_eq!(redacted.to_string(), "https://***@registry.example/simple");
302
303        let owned = redacted.into_inner();
304        assert!(!owned.contains("hunter2"));
305        assert!(!owned.contains("super-secret-value"));
306        assert_eq!(owned, "https://***@registry.example/simple");
307    }
308
309    /// `into_inner`'s equivalent of the `RedactedUrl`/`RedactedName` byte-for-byte checks above
310    /// (#1317 critic S3): it must never diverge from `Display`, since it hands back the exact
311    /// same already-redacted `String` instead of a clone of it.
312    #[test]
313    fn test_into_inner_matches_display_byte_for_byte() {
314        for raw in [
315            "https://npm.internal/pkg?token=super-secret-value",
316            "https://user:hunter2@registry.example/simple?token=x",
317            "https://registry.example/simple",
318            "@types/node",
319        ] {
320            assert_eq!(RedactedUrl::new(raw).into_inner(), url_for_tracing(raw));
321        }
322        for raw in [
323            "com.google.guava:guava",
324            "com.google.guava:deploy:TOKEN@git.internal.corp",
325            "source:Blocked",
326        ] {
327            assert_eq!(
328                RedactedName::new(raw).into_inner(),
329                redact_declaration_key(raw)
330            );
331        }
332    }
333
334    /// `Debug` forwards to the redacted text's own quoted-string rendering, not a
335    /// struct-wrapper form, and must never expose the raw credential either.
336    #[test]
337    fn test_redacted_url_debug_forwards_to_inner_string_and_redacts() {
338        let redacted = RedactedUrl::new("https://user:hunter2@registry.example/simple");
339        assert_eq!(
340            format!("{redacted:?}"),
341            "\"https://***@registry.example/simple\""
342        );
343    }
344
345    /// The scoped-package-name no-op (#767 M1) must hold through `RedactedUrl` too.
346    #[test]
347    fn test_redacted_url_noop_for_scoped_package_name() {
348        assert_eq!(RedactedUrl::new("@types/node").to_string(), "@types/node");
349    }
350
351    /// #1216: `PartialEq<str>`/`PartialEq<&str>` are on the generic `RedactedText<K>`, so both
352    /// aliases have them — compares against the redacted text, not raw input.
353    #[test]
354    fn test_redacted_url_partial_eq_str_compares_redacted_text() {
355        let redacted = RedactedUrl::new("https://user:hunter2@registry.example/simple");
356        let expected: &str = "https://***@registry.example/simple";
357        // `PartialEq<&str>` (RHS is a `&str` value).
358        assert_eq!(redacted, expected);
359        // `PartialEq<str>` (RHS is a dereferenced, unsized `str`).
360        assert_eq!(redacted, *expected);
361        assert_ne!(redacted, "https://user:hunter2@registry.example/simple");
362    }
363
364    /// #1216: same guarantee as the `RedactedUrl` case above, for `RedactedName`.
365    #[test]
366    fn test_redacted_name_partial_eq_str_compares_redacted_text() {
367        let redacted = RedactedName::new("com.google.guava:guava");
368        let expected: &str = "com.google.guava:guava";
369        assert_eq!(redacted, expected);
370        assert_eq!(redacted, *expected);
371    }
372}