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}