Skip to main content

deps_core/redact/
key.rs

1//! Declaration-key and parse-error-message redaction: the credential-shape gate applied to a
2//! value that is not always a URL (an opaque label, a manifest declaration key, or a parser's
3//! error message), unlike [`super::url`]'s URL-specific redaction.
4
5use super::url::{
6    is_authority_bearing_url, redact_userinfo_with_parsed, segment_has_credential_colon,
7    url_for_tracing_with_parsed,
8};
9
10/// Redacts a [`crate::ecosystem::BlockedRegistryOccurrence::declaration_key`] value for safe
11/// inclusion in a client-visible diagnostic message.
12///
13/// `key` is not always a URL: most ecosystems set it to a short opaque label (`"top-level"`,
14/// `"source:Blocked"`, `"scope:@myorg"`), and only some set it to a real registry URL, sometimes
15/// prefixed with an opaque label of the ecosystem's own choosing
16/// (`"source:https://user:pass@host/index"`). Running [`super::url::url_for_tracing`]'s aggressive
17/// text-scan fallbacks unconditionally would mangle a label like `"scope:@myorg"` into
18/// `"***@myorg"` (#981), so redaction only runs when `key` shows real URL structure
19/// (`is_authority_bearing_url`) or a credential shape of its own: at least one `@` whose own
20/// preceding segment (back to the previous `@`, or `key`'s own start) has a credential-shaped
21/// `:` once its own trailing `:` is stripped (`segment_has_credential_colon`, checked
22/// per-segment so a decoy trailing `label:@...` segment can't shadow an earlier real credential
23/// — #993 M5). Stripping the trailing `:` first is what keeps `"scope:@myorg"` — an
24/// empty-username userinfo shape used as an opaque label, not a credential — from matching,
25/// since after stripping it the segment is just `"scope"`, with no `:` left to trip the check.
26/// This is a shape check, not a separator check: it has no opinion on `/` at all, so it catches
27/// a credential regardless of what separates the opaque label from it
28/// (`"source:user:ghp_SECRET@host"`, `"source:feed/user:hunter2@host"`,
29/// `"source:feed//user:hunter2@host"`, even a percent-encoded
30/// `"source:feed%2F%2Fuser:hunter2@h"`, since the scan only looks at `:`/`@`, never `/`), while
31/// never mangling a `/`-containing label that has no `@` at all (`"source:feed//mirror"`,
32/// `"component-host:gitlab.example.com//group"` are left untouched — #993 S2, a regression in
33/// an earlier, separator-based version of this gate). Every other key takes the plain
34/// query/fragment-strip fallback instead.
35///
36/// Two accepted trade-offs, both favoring over-redaction over under-redaction per this module's
37/// own stated design (see [`super::url::redact_userinfo`]'s doc):
38/// - Once triggered, the actual redaction ([`super::url::url_for_tracing`]) can still destroy more of `key`
39///   than just the credential — e.g. `"source:feed//user:hunter2@h"` redacts to `"***@h"`,
40///   losing the `"source:feed//"` label entirely — since the fallback scan this reaches has no
41///   way to tell where the opaque label ends and true userinfo begins once neither
42///   `is_authority_bearing_url` nor a real authority is available to anchor on. This defeats
43///   `declaration_key`'s own disambiguation purpose (see its doc, and
44///   `build_blocked_registry_diagnostic`'s) for that one value.
45/// - A non-credential `label:text@text`-shaped key is redacted too, since the gate cannot tell
46///   it apart from a real credential — e.g. `"source:contoso@internal"` redacts to
47///   `"***@internal"` even though `contoso` is just a source name, not a password. In practice
48///   only a free-text label an ecosystem builds from unvalidated user input (e.g. NuGet's
49///   `format!("source:{}", entry.key)` from a `NuGet.config` `<add key>` attribute) is likely to
50///   contain an `@` at all — most ecosystems' own fixed/opaque labels cannot.
51///
52/// This is a best-effort heuristic gate, not a formally verified one: it closes every
53/// known-realistic and known-adversarial leak shape found so far (#981, #993), but a
54/// sufficiently unusual `key` could in principle still slip past both `is_authority_bearing_url`
55/// and this shape check.
56///
57/// # Examples
58///
59/// ```
60/// use deps_core::redact::redact_declaration_key;
61///
62/// assert_eq!(
63///     redact_declaration_key("https://user:hunter2@10.0.0.1/index"),
64///     "https://***@10.0.0.1/index"
65/// );
66/// assert_eq!(
67///     redact_declaration_key("source:https://user:hunter2@10.0.0.1/v3/index.json"),
68///     "source:https://***@10.0.0.1/v3/index.json"
69/// );
70/// assert_eq!(redact_declaration_key("source:Blocked"), "source:Blocked");
71/// assert_eq!(redact_declaration_key("scope:@myorg"), "scope:@myorg");
72/// // A single `/` (not `//`) still redacts — this is a credential-shape gate, not a
73/// // separator-substring gate.
74/// assert_eq!(
75///     redact_declaration_key("source:feed/user:hunter2@nuget.internal"),
76///     "***@nuget.internal"
77/// );
78/// // No `@` at all: never redacted, even with a `/`-heavy label.
79/// assert_eq!(
80///     redact_declaration_key("source:feed//mirror"),
81///     "source:feed//mirror"
82/// );
83/// // A control character survives the credential-shape gate (no `@` here at all) but is still
84/// // swept, since it could otherwise forge a log line on its own (#1469).
85/// assert_eq!(
86///     redact_declaration_key("lodash\nERROR deps_lsp: FORGED"),
87///     "lodash ERROR deps_lsp: FORGED"
88/// );
89/// ```
90#[must_use]
91#[expect(
92    clippy::string_slice,
93    reason = "idx comes from find of ASCII '?'/'#' bytes on key, so it always lands on a char \
94              boundary"
95)]
96pub fn redact_declaration_key(key: &str) -> std::borrow::Cow<'_, str> {
97    // Parsed once and reused for both the authority-bearing check and the redaction call below
98    // (#1317) — `is_authority_bearing_url` hands back the parsed `Url` instead of a plain `bool`
99    // for exactly this reason.
100    let parsed = is_authority_bearing_url(key);
101    let redacted = if parsed.is_some() || has_credential_shape(key) {
102        // The gate firing doesn't mean the text actually changed (e.g. an authority-bearing,
103        // credential-free URL) — a value-equality check is needed to still borrow in that case,
104        // not just when the gate never fires at all (critic S1, #1317). Note this still costs
105        // `url_for_tracing_with_parsed`'s own internal `String` allocation on that no-op path
106        // (code-review follow-up, #1317) — the `Cow::Borrowed` below only avoids a caller
107        // re-allocating a second time on top of it via `Display`/`.to_string()`.
108        let redacted = url_for_tracing_with_parsed(key, parsed);
109        if redacted == key {
110            std::borrow::Cow::Borrowed(key)
111        } else {
112            std::borrow::Cow::Owned(redacted)
113        }
114    } else {
115        match key.find(['?', '#']) {
116            Some(idx) => std::borrow::Cow::Owned(key[..idx].to_string()),
117            None => std::borrow::Cow::Borrowed(key),
118        }
119    };
120    // #1469: the credential-shape gate above has no opinion on control characters, so a
121    // manifest-controlled `key` with no credential shape at all (e.g. a plain package name
122    // carrying a raw `\n`/`\r`/ESC) would otherwise reach this function's callers — including
123    // `PackageName::for_tracing()` — unsanitized.
124    match sanitize_invisible(&redacted) {
125        std::borrow::Cow::Borrowed(_) => redacted,
126        std::borrow::Cow::Owned(sanitized) => std::borrow::Cow::Owned(sanitized),
127    }
128}
129
130/// Replaces every Unicode `Cc` (Control), `Cf` (Format), `Zl` (Line Separator), and `Zp`
131/// (Paragraph Separator) character in `s` with a single space, borrowing when `s` has none
132/// (#1242, #1246).
133///
134/// `Cc` covers ASCII/C1 control characters, including `\n`/`\r` and raw ANSI escape
135/// (`\x1B`) bytes, that could otherwise splice new lines into a single-line rendering
136/// (table row, JSON string, SARIF message) or forge terminal escape sequences. `Cf`
137/// additionally covers invisible formatting characters with no glyph of their own —
138/// bidirectional overrides (e.g. U+202E RIGHT-TO-LEFT OVERRIDE, the Trojan Source vector),
139/// zero-width joiners/spaces (U+200B, U+200C, U+200D), and the byte-order mark (U+FEFF) —
140/// which can visually reorder or hide text without tripping a `Cc`-only check. `Zl`/`Zp`
141/// (U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR) are neither `Cc` nor `Cf`, but are
142/// still line terminators for JS/`eval` consumers of `--format json` output and are treated
143/// as breaks by some editor renderers (critic follow-up M1, #1246).
144///
145/// This is deliberately wider than [`crate::lsp_helpers::escape_markdown`] and
146/// [`crate::lsp_helpers::markdown_code_span`]'s own narrow, explicit bidi/invisible-character
147/// list (`is_markdown_unsafe`, #1248): those helpers also run on registry-supplied hover
148/// *descriptions*, where legitimate right-to-left text carries real `Cf` marks (e.g. U+200F
149/// RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK) and emoji ZWJ sequences carry U+200D, so
150/// treating the whole `Cf` category as unsafe there would mangle genuine Arabic/Hebrew text
151/// or emoji. A manifest-declared package/coordinate *name*, by contrast, has no legitimate
152/// use for any `Cf`/`Zl`/`Zp` character, so this dedicated helper — for name-shaped values
153/// only — can safely treat the whole categories as unsafe.
154///
155/// # Examples
156///
157/// ```
158/// use deps_core::redact::sanitize_invisible;
159///
160/// assert_eq!(sanitize_invisible("left-pad"), "left-pad");
161/// assert_eq!(sanitize_invisible("a\nb\rc"), "a b c");
162/// assert_eq!(sanitize_invisible("bidi\u{202E}gnp.exe"), "bidi gnp.exe");
163/// assert_eq!(sanitize_invisible("a\u{2028}b\u{2029}c"), "a b c");
164/// ```
165#[must_use]
166pub fn sanitize_invisible(s: &str) -> std::borrow::Cow<'_, str> {
167    if !s.chars().any(is_invisible) {
168        return std::borrow::Cow::Borrowed(s);
169    }
170    std::borrow::Cow::Owned(
171        s.chars()
172            .map(|c| if is_invisible(c) { ' ' } else { c })
173            .collect(),
174    )
175}
176
177/// Whether `c` belongs to the Unicode `Cc`/`Cf`/`Zl`/`Zp` general categories
178/// [`sanitize_invisible`] sweeps.
179///
180/// `pub(crate)` rather than private so `lsp_helpers::mod`'s drift-guard test (#1323) can
181/// scan the full Unicode code space and assert that every character this predicate
182/// flags is either blocked by `is_markdown_unsafe` or in that function's documented,
183/// named exempt set — without duplicating this category logic in the test itself,
184/// which would defeat the point of a drift guard.
185pub(crate) fn is_invisible(c: char) -> bool {
186    use unicode_general_category::{GeneralCategory, get_general_category};
187
188    matches!(
189        get_general_category(c),
190        GeneralCategory::Control
191            | GeneralCategory::Format
192            | GeneralCategory::LineSeparator
193            | GeneralCategory::ParagraphSeparator
194    )
195}
196
197/// Longest prefix (in bytes, not chars — see [`redact_parse_error_for_log`]'s slicing) of a
198/// redacted parse-error message logged, post-redaction, by [`redact_parse_error_for_log`].
199///
200/// `pub`, not `pub(crate)`: `deps_pypi::parser::truncate_for_log` (a thin wrapper delegating to
201/// [`redact_parse_error_for_log`]) mirrors this value for its own test fixtures rather than
202/// hardcoding a second `200` that could silently drift from this one.
203pub const MAX_PARSE_ERROR_LOG_BYTES: usize = 200;
204
205/// Redacts, then bounds, a parse-error's `Display` text before it reaches a log sink.
206///
207/// `toml_span::Error` and `yaml_rust2::ScanError` can both embed a manifest/lockfile's
208/// offending duplicate key or table name verbatim in their `Display` output, and that name can
209/// itself be a credential-bearing URL (#1240). [`redact_declaration_key`] runs first, then the
210/// result is truncated to a bounded prefix — in that order, since truncating first could cut the
211/// string exactly at the boundary the credential-shape scan depends on, leaking a credential
212/// that straddles the cut. Truncation is an independent concern from redaction: the file `raw`
213/// derives from can be as large as the ~10 MB read cap
214/// ([`crate::fs_probe::read_to_string_capped`]'s bound), so an unbounded
215/// `tracing::warn!`/`debug!`/`error!` call would be a synchronous multi-megabyte write to a (by
216/// default, unbuffered, stderr-backed) log sink — this mostly matters on the benign path, since
217/// once redaction actually fires it collapses `raw` to a short fixed-shape string well under the
218/// cap. Falls back to `raw` unchanged (as a `Cow::Borrowed`, so a caller that just needs to read
219/// or forward the result avoids a second clone) when redaction was a no-op and it's already
220/// short enough.
221///
222/// [`redact_declaration_key`] itself returns a `Cow`, so this function's own no-op path never
223/// re-allocates on top of it (#1317) — it just forwards `redact_declaration_key`'s own
224/// `Cow::Borrowed`. This is guaranteed allocation-free only for `redact_declaration_key`'s plain
225/// opaque-key fallback (no `?`/`#`, no URL/credential shape); its authority-bearing,
226/// credential-free case (e.g. a plain registry URL with no query) still allocates internally
227/// before that `Cow::Borrowed` short-circuit discards the allocation (code-review follow-up,
228/// #1317) — the win there is one fewer allocation, not zero.
229///
230/// This is the single shared implementation behind both `deps-core`'s own parse-error sinks and
231/// `deps_pypi::parser::truncate_for_log`'s delegation to it — see CLAUDE.md's cross-ecosystem
232/// rule: the same redact-then-truncate shape was independently needed in ≥2 crates (#1228,
233/// #1239, #1240), so it lives here once rather than being reimplemented per-crate.
234///
235/// # Examples
236///
237/// ```
238/// use deps_core::redact::redact_parse_error_for_log;
239///
240/// assert_eq!(
241///     redact_parse_error_for_log(
242///         "duplicate key: `https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x`"
243///     ),
244///     "duplicate key: `https://***@pkg.internal.corp/x`"
245/// );
246/// // Benign errors are left unchanged — the credential-shape gate does not fire on them.
247/// assert_eq!(redact_parse_error_for_log("duplicate key: `serde`"), "duplicate key: `serde`");
248/// ```
249#[must_use]
250#[expect(
251    clippy::string_slice,
252    reason = "`boundary` comes from `floor_char_boundary`, so the slice always lands on a char \
253              boundary"
254)]
255pub fn redact_parse_error_for_log(raw: &str) -> std::borrow::Cow<'_, str> {
256    let text = redact_declaration_key(raw);
257    if text.len() <= MAX_PARSE_ERROR_LOG_BYTES {
258        return text;
259    }
260    let boundary = text.floor_char_boundary(MAX_PARSE_ERROR_LOG_BYTES);
261    // `raw.len()`, not `text.len()`: the annotation describes the original attacker-controlled
262    // payload's size, which redaction must not misreport just because it happened to shorten
263    // the visible text (#1228 M1).
264    std::borrow::Cow::Owned(format!(
265        "{}... ({} bytes total)",
266        &text[..boundary],
267        raw.len()
268    ))
269}
270
271/// Redacts `e` via [`redact_parse_error_for_log`] and wraps the result as the boxed error
272/// [`crate::error::DepsError::ParseError`]'s `source` field expects.
273///
274/// Centralizes the whole "redact, then wrap into an `io::Error`" step — not just the redaction
275/// itself — as one call, `deps_core::redact::parse_error_source(&e)`, in place of
276/// `Box::new(std::io::Error::other(redact_parse_error_for_log(&e.to_string())))` repeated at
277/// each `toml_span`/`yaml-rust2` `map_err` site. A future ecosystem crate adding a new parse-error
278/// site is forced through the safe path by construction, rather than being able to copy an old,
279/// unfixed `Box::new(std::io::Error::other(e.to_string()))` from elsewhere and reintroduce #1240.
280///
281/// # Examples
282///
283/// ```
284/// use deps_core::redact::parse_error_source;
285///
286/// let source = parse_error_source(&"duplicate key: `serde`");
287/// assert_eq!(source.to_string(), "duplicate key: `serde`");
288/// ```
289#[must_use]
290pub fn parse_error_source(e: &dyn std::fmt::Display) -> Box<dyn std::error::Error + Send + Sync> {
291    Box::new(std::io::Error::other(redact_parse_error_for_log_owned(
292        e.to_string(),
293    )))
294}
295
296/// [`redact_parse_error_for_log`]'s owned-input counterpart: `raw` is already an owned
297/// allocation the caller has no further use for, so the no-op path returns it directly instead
298/// of cloning a borrow of it via `Cow::into_owned` (#1317) — the exact allocation
299/// [`parse_error_source`] used to pay twice for on the benign (no-credential) path.
300#[must_use]
301fn redact_parse_error_for_log_owned(raw: String) -> String {
302    // Relies on `Borrowed` always meaning "the full, untruncated `raw`" — true today, since
303    // `redact_parse_error_for_log`'s truncation branch always returns `Owned` (never a
304    // `Cow::Borrowed(&raw[..boundary])` slice) specifically so this substitution stays correct;
305    // that invariant would need to move here too if the truncation branch ever changed.
306    match redact_parse_error_for_log(&raw) {
307        std::borrow::Cow::Borrowed(_) => raw,
308        std::borrow::Cow::Owned(s) => s,
309    }
310}
311
312/// The segment-bounded `@`/`:` scan shared by [`redact_declaration_key`] (decides whether a
313/// non-URL label needs redacting at all) and [`is_credential_or_query_bearing`] (decides whether an
314/// outbound value must be rejected outright) — extracted so the two callers can't drift apart
315/// on what counts as "looks like a credential" (#1206).
316///
317/// See [`redact_declaration_key`]'s own doc for the exact segment/trim rule this implements.
318#[expect(
319    clippy::string_slice,
320    reason = "`at` comes from `match_indices('@')` on ASCII '@' bytes, so every slice bound \
321              always lands on a char boundary"
322)]
323fn has_credential_shape(key: &str) -> bool {
324    let mut prev = 0;
325    key.match_indices('@').any(|(at, _)| {
326        let segment = &key[prev..at];
327        prev = at + 1;
328        segment_has_credential_colon(segment.trim_end_matches(':'))
329    })
330}
331
332/// Whether `value` carries credential-shaped userinfo of its own, or an untrusted
333/// query/fragment component that could hide one.
334///
335/// Detects, in order:
336/// - a `?`/`#` anywhere in `value` (#1206 S2) — a query string is exactly where a token can
337///   travel (`?token=...`, `?access_token=...`) without ever taking the `user:pass@host`
338///   shape the checks below look for, and [`super::url::url_for_tracing`] already treats *any* query or
339///   fragment as untrusted-by-default (it drops both unconditionally, per #866/#858) — this
340///   function applies that same always-suspect rule to the reject-before-search decision, not
341///   just to what gets logged afterward;
342/// - for a real URL authority (`host()` is `Some`), whether [`super::url::redact_userinfo`] would change
343///   `value` at all — not just a non-empty username/password on the authority itself. A
344///   credential-shaped `user:pass@host` span can also sit *after* the authority, later in the
345///   path (e.g. `https://mirror.example/redirect/user:pass@evil.com`, code-review round 1
346///   #1206 finding 1): checking only `url.username()`/`url.password()` missed this, since that
347///   pair only reflects the authority's own userinfo. Delegating to `redact_userinfo` reuses
348///   its existing authority-then-tail scan instead of re-deriving it, and is deliberately
349///   scoped to the `host().is_some()` case alone — see the over-rejection note below for why
350///   this isn't applied unconditionally to every input;
351/// - for text with no parseable URL structure (e.g. a `.package(url: "...")` literal with its
352///   scheme already stripped before reaching a registry-search query), the same
353///   segment-bounded credential-colon shape [`redact_declaration_key`]'s fallback scan uses
354///   (`has_credential_shape`).
355///
356/// Unlike `is_authority_bearing_url`, this is a genuine credential-detection predicate, not
357/// "does this parse as a URL at all" — an ordinary credential-free, query-free URL
358/// (`https://host/path`) returns `false` here.
359///
360/// Narrower than [`super::url::url_for_tracing`]'s own redaction coverage in one specific way: a
361/// scheme-stripped, colon-less, token-only userinfo (e.g. `ghp_TOKEN@host/path`, with no `:`
362/// anywhere) is not flagged here, even though `url_for_tracing` would still redact it via
363/// `find_token_prefix_at`'s prefix sniffing. Not widened to match: probe-testing found that
364/// treating "the redactor would change this string" as the reject criterion false-positives on
365/// ordinary non-credential input containing a bare `@` with no colon at all — a Maven
366/// coordinate like `com.google.guava:guava` (no `@`, unaffected) is fine, but an SSH-style Git
367/// remote like `git@github.com:apple/swift-nio.git` would trip a token-prefix-agnostic
368/// widening. The `user:pass@`/`label:value@` colon-shaped case this function does catch covers
369/// every shape in the actual #1206 repro family (a full `.package(url:)` literal, with or
370/// without its scheme).
371///
372/// Conversely, delegating to `redact_userinfo` for the `host().is_some()` case inherits that
373/// function's own documented over-redaction: a colon in the path with no `@` at all (e.g.
374/// `https://10.0.0.1/v1/items:search`, a REST-style path segment, no credential involved) is
375/// still flagged here, because `redact_userinfo` still rewrites it. Accepted per this module's
376/// stated over-redact/over-reject-over-leak trade-off (the same one the `?`/`#` check above
377/// already applies) — an occasional false-positive-rejected completion keystroke costs far less
378/// than a missed credential leak, and this narrower delegation (scoped to values that already
379/// parse as a real URL authority) is far more targeted than falling through to
380/// `has_credential_shape` would be, which over-triggers on *any* `scheme://...@...` value purely
381/// from the scheme's own leading colon (verified while fixing this finding: naively falling
382/// through to `has_credential_shape` for every `host().is_some()` value flagged an ordinary
383/// scoped-package-style path like `https://github.com/@babel/core` with no credential anywhere).
384///
385/// Intended as a reject-before-search gate (issue #1206): a value this function flags must
386/// never reach an outbound registry-search query or an unredacted `tracing` field — a redacted
387/// value is not a useful search term, so the caller should drop the request entirely rather
388/// than redact-and-search.
389///
390/// # Examples
391///
392/// ```
393/// use deps_core::redact::is_credential_or_query_bearing;
394///
395/// assert!(is_credential_or_query_bearing(
396///     "https://user:hunter2@registry.example/simple"
397/// ));
398/// // Scheme already stripped by the caller (e.g. Swift's `.package(url:)` completion) — no
399/// // longer parses as a URL, but still shows the same credential shape.
400/// assert!(is_credential_or_query_bearing(
401///     "deploy:AUDITSENTINEL0000@git.internal.corp/team/x.git"
402/// ));
403/// // A query string can carry a token with no userinfo shape at all.
404/// assert!(is_credential_or_query_bearing(
405///     "https://github.com/apple/swift-nio?token=SECRET"
406/// ));
407/// // A credential-shaped span after the authority, later in the path, is caught too.
408/// assert!(is_credential_or_query_bearing(
409///     "https://mirror.example/redirect/user:pass@evil.com"
410/// ));
411/// assert!(!is_credential_or_query_bearing("apple/swift-nio"));
412/// assert!(!is_credential_or_query_bearing("https://registry.example/simple"));
413/// ```
414#[must_use]
415pub fn is_credential_or_query_bearing(value: &str) -> bool {
416    if value.contains(['?', '#']) {
417        return true;
418    }
419    // Parsed once and reused below (#1317) instead of parsing `value` again inside
420    // `redact_userinfo`.
421    let parsed = url::Url::parse(value).ok();
422    if parsed
423        .as_ref()
424        .is_some_and(|url| !url.cannot_be_a_base() && url.host().is_some())
425    {
426        return redact_userinfo_with_parsed(value, parsed) != value;
427    }
428    has_credential_shape(value)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    /// #993 M5 (impl-critic on the first credential-shape fix): the carve-out used to inspect
436    /// only the *last* `@` found by the old `find_credential_at`-based scan, so a trailing
437    /// `label:@`-shaped decoy segment could shadow an earlier, real credential. The per-segment
438    /// scan (`segment_has_credential_colon` on each `@`-delimited segment, independently) fixes
439    /// this: every segment is judged on its own, so a decoy segment after the real credential
440    /// can no longer suppress redaction of the one before it.
441    #[test]
442    fn redact_declaration_key_trailing_decoy_segment_does_not_shadow_earlier_credential() {
443        assert_eq!(
444            redact_declaration_key("source:user:hunter2@nuget.internal:@x"),
445            "***@x"
446        );
447        assert_eq!(
448            redact_declaration_key("source:user:hunter2@host/scope:@myorg"),
449            "***@host/***@myorg"
450        );
451        assert_eq!(redact_declaration_key("named:user:ghp_SECRET@h:@"), "***@");
452        for adversarial in [
453            "source:user:hunter2@nuget.internal:@x",
454            "source:user:hunter2@host/scope:@myorg",
455            "named:user:ghp_SECRET@h:@",
456            "source:user:hunter2@h@scope:@myorg",
457        ] {
458            assert!(
459                !redact_declaration_key(adversarial).contains("hunter2")
460                    && !redact_declaration_key(adversarial).contains("SECRET"),
461                "credential in {adversarial:?} must not survive redaction"
462            );
463        }
464    }
465
466    /// Critic S2 (#1317): pins `url_for_tracing_with_parsed`'s `end != raw.len()` fallback —
467    /// when `key` has a query/fragment to truncate away, the `Url` parsed from the *untruncated*
468    /// `key` by `is_authority_bearing_url` must be discarded and the truncated slice re-parsed,
469    /// or the query string (and any credential inside it) would leak past #866's truncate-first
470    /// guarantee.
471    #[test]
472    fn redact_declaration_key_truncates_query_credential_on_authority_bearing_url() {
473        let redacted =
474            redact_declaration_key("https://user:hunter2@registry.example/index?token=SECRET");
475        assert!(!redacted.contains("hunter2"));
476        assert!(!redacted.contains("SECRET"));
477        assert!(!redacted.contains("token"));
478        assert_eq!(redacted, "https://***@registry.example/index");
479    }
480
481    #[test]
482    fn redact_parse_error_for_log_redacts_credential_and_keeps_host() {
483        let redacted = redact_parse_error_for_log(
484            "duplicate key: `https://svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x`",
485        );
486        assert!(!redacted.contains("ghp_SUPERSECRETTOKEN123"));
487        assert!(!redacted.contains("svcacct"));
488        assert!(redacted.contains("pkg.internal.corp"));
489    }
490
491    #[test]
492    fn redact_parse_error_for_log_leaves_benign_message_unchanged() {
493        assert_eq!(
494            redact_parse_error_for_log("duplicate key: `serde`"),
495            "duplicate key: `serde`"
496        );
497    }
498
499    /// #1240 (impl-critic S3 follow-up on the developer's first attempt): the original fixture
500    /// placed the whole credential past [`MAX_PARSE_ERROR_LOG_BYTES`], so a truncate-first bug
501    /// would have dropped it too — the ordering invariant went untested. This fixture instead
502    /// straddles the cut: the secret token sits before the boundary, and the disambiguating `@`
503    /// that `has_credential_shape` needs lands after it. A truncate-first implementation would
504    /// see only the `@`-less prefix, never detect a credential, and emit the secret almost
505    /// verbatim; redact-then-truncate (the correct order) sees the whole string, including the
506    /// `@`, and redacts it before the cut ever happens.
507    ///
508    /// #1240 round 2 (impl-critic R2-M1): asserting on the *full* `"ghp_SUPERSECRETTOKEN123"` is
509    /// not load-bearing on its own — the boundary cuts mid-token (at byte 200, one byte before
510    /// the token's own end at byte 201), so even a truncate-first bug drops the trailing digits
511    /// and that assertion passes under either ordering. `"SUPERSECRETTOKEN"` (no trailing
512    /// digits) ends before the boundary, so it fully survives a buggy truncate-first prefix —
513    /// only the correct redact-first order removes it, making this assertion actually gate the
514    /// invariant, same as the `"svcacct"` one already did.
515    #[test]
516    fn redact_parse_error_for_log_redacts_before_truncating() {
517        let raw = format!(
518            "{}svcacct:ghp_SUPERSECRETTOKEN123@pkg.internal.corp/x",
519            "x".repeat(170)
520        );
521        // Named `needle_end`/`at_sign_offset`, not `secret_*` — these are byte offsets into the
522        // fixture, not the credential text itself, but CodeQL's cleartext-logging heuristic
523        // flags on variable-name pattern, not on what the value actually holds.
524        let needle_end = raw.find("SUPERSECRETTOKEN").unwrap() + "SUPERSECRETTOKEN".len();
525        let at_sign_offset = raw.find('@').unwrap();
526        assert!(
527            needle_end < MAX_PARSE_ERROR_LOG_BYTES && at_sign_offset > MAX_PARSE_ERROR_LOG_BYTES,
528            "fixture must straddle the truncation boundary: needle ends at {needle_end}, '@' at {at_sign_offset}, boundary {MAX_PARSE_ERROR_LOG_BYTES}"
529        );
530
531        let redacted = redact_parse_error_for_log(&raw);
532        assert!(!redacted.contains("SUPERSECRETTOKEN"));
533        assert!(!redacted.contains("svcacct"));
534    }
535
536    /// #1240 round 2 (impl-critic R2-M2): the truncation branch itself — including the
537    /// original-vs-redacted length annotation rule (round 1 S2) — was only exercised via
538    /// `deps_pypi::parser::truncate_for_log`'s inherited test, not directly in `deps-core`.
539    /// Mirrors that test's fixture here so the rule stays covered even if the pypi wrapper is
540    /// ever removed.
541    #[test]
542    fn redact_parse_error_for_log_truncation_annotation_reports_original_length() {
543        let credential = "user:hunter2very-long-password-padding-to-cross-the-cap@";
544        let host_and_path = "example.com/".repeat(20);
545        let raw = format!("https://{credential}{host_and_path}");
546        assert!(
547            raw.len() > MAX_PARSE_ERROR_LOG_BYTES,
548            "fixture must exceed the cap"
549        );
550
551        let redacted = redact_parse_error_for_log(&raw);
552        assert!(!redacted.contains("hunter2"));
553        assert!(
554            redacted.contains(&format!("({} bytes total)", raw.len())),
555            "byte count must reflect the original input length, not the (shorter, \
556             post-redaction) visible text: {redacted:?}"
557        );
558    }
559
560    /// #993 M6: the credential-shape gate cannot distinguish a real credential from a
561    /// non-credential `label:text@text` key, so a free-text label containing an `@` (only
562    /// realistically reachable via NuGet's unvalidated `NuGet.config` `<add key>` attribute) is
563    /// redacted too, even though nothing in it is a password. Accepted per this module's
564    /// over-redact-over-leak design; pinned here as a documented, intentional trade-off rather
565    /// than an accidental regression.
566    #[test]
567    fn redact_declaration_key_over_redacts_non_credential_label_at_text_shape() {
568        assert_eq!(
569            redact_declaration_key("source:contoso@internal"),
570            "***@internal"
571        );
572        assert_eq!(redact_declaration_key("named:my-index@v1"), "***@v1");
573    }
574
575    // --- is_credential_or_query_bearing (#1206) ---
576
577    #[test]
578    fn is_credential_or_query_bearing_detects_real_url_userinfo() {
579        assert!(is_credential_or_query_bearing(
580            "https://user:hunter2@registry.example/simple"
581        ));
582        assert!(is_credential_or_query_bearing(
583            "https://tokenonly@registry.example"
584        ));
585    }
586
587    /// The exact shape of issue #1206: Swift's completion strips the `https://github.com/`
588    /// scheme off a `.package(url:)` literal before it becomes a search query, so a
589    /// credential-bearing value no longer parses as a URL by the time this gate sees it.
590    #[test]
591    fn is_credential_or_query_bearing_detects_scheme_stripped_credential() {
592        assert!(is_credential_or_query_bearing(
593            "deploy:AUDITSENTINEL0000@git.internal.corp/team/x.git"
594        ));
595    }
596
597    #[test]
598    fn is_credential_or_query_bearing_false_for_ordinary_url() {
599        assert!(!is_credential_or_query_bearing(
600            "https://registry.example/simple"
601        ));
602    }
603
604    #[test]
605    fn is_credential_or_query_bearing_false_for_plain_package_name() {
606        assert!(!is_credential_or_query_bearing("apple/swift-nio"));
607        assert!(!is_credential_or_query_bearing("serde"));
608    }
609
610    /// A scoped npm-style name (`@scope/pkg`) must not false-positive: the segment before its
611    /// only `@` is empty, so no credential-shaped `:` can be found in it.
612    #[test]
613    fn is_credential_or_query_bearing_false_for_scoped_package_name() {
614        assert!(!is_credential_or_query_bearing("@babel/core"));
615    }
616
617    /// A Windows drive-letter colon (`c:/...`) must not be mistaken for credential shape,
618    /// mirroring `redact_userinfo`'s own drive-letter carve-out.
619    #[test]
620    fn is_credential_or_query_bearing_false_for_drive_letter_colon() {
621        assert!(!is_credential_or_query_bearing("c:/user@evil"));
622    }
623
624    /// #1206 S2: a query-string token has no `user:pass@` userinfo shape at all, so it must be
625    /// caught by the `?`/`#` check rather than falling through to the credential-colon scan.
626    #[test]
627    fn is_credential_or_query_bearing_true_for_query_string_token() {
628        assert!(is_credential_or_query_bearing(
629            "https://github.com/apple/swift-nio?token=SECRET"
630        ));
631        assert!(is_credential_or_query_bearing(
632            "apple/swift-nio?token=SECRET"
633        ));
634        assert!(is_credential_or_query_bearing("apple/swift-nio#fragment"));
635    }
636
637    /// #1206 M1: a Maven coordinate and an SSH-style Git remote must not false-positive —
638    /// pinned here since a naive "the redactor would touch this string" widening (considered
639    /// and rejected, see this function's own doc) would flag both.
640    #[test]
641    fn is_credential_or_query_bearing_false_for_maven_coordinate_and_ssh_remote() {
642        assert!(!is_credential_or_query_bearing("com.google.guava:guava"));
643        assert!(!is_credential_or_query_bearing(
644            "git@github.com:apple/swift-nio.git"
645        ));
646    }
647
648    /// Code-review round 1 finding 1: a credential-shaped span after a real URL authority —
649    /// not in the authority's own userinfo — must still be caught, matching what
650    /// `url_for_tracing` already redacts for the identical input.
651    #[test]
652    fn is_credential_or_query_bearing_true_for_credential_after_authority_in_path() {
653        assert!(is_credential_or_query_bearing(
654            "https://mirror.example/redirect/user:pass@evil.com"
655        ));
656    }
657
658    /// A scoped-package-style path segment (`@babel/core`-shaped) appended to a clean URL
659    /// authority must not false-positive purely from the scheme's own leading colon — the
660    /// specific over-trigger a naive fallback to `has_credential_shape` would have introduced
661    /// (see this function's own doc; caught while fixing the finding above).
662    #[test]
663    fn is_credential_or_query_bearing_false_for_scoped_package_path_after_authority() {
664        assert!(!is_credential_or_query_bearing(
665            "https://github.com/@babel/core"
666        ));
667    }
668
669    /// Documented, accepted over-rejection (see this function's own doc): a REST-style path
670    /// colon with no credential at all still gets rejected, because `redact_userinfo` itself
671    /// still rewrites it. Pinned so a future change to this trade-off is a deliberate doc +
672    /// test update, not a silent behavior drift.
673    #[test]
674    fn is_credential_or_query_bearing_true_for_rest_style_path_colon_no_credential() {
675        assert!(is_credential_or_query_bearing(
676            "https://10.0.0.1/v1/items:search"
677        ));
678    }
679
680    /// #1209: `redact_declaration_key` is the primitive [`crate::PackageName::for_tracing`]
681    /// and [`RedactedName`] build on to redact package/coordinate names before they reach a
682    /// log or client-visible message. One genuine, legitimate name per ecosystem (from the
683    /// security audit's live probe against all 14 ecosystems) must survive unredacted —
684    /// otherwise every hover/completion/diagnostic for that ecosystem would render a mangled
685    /// name.
686    #[test]
687    fn redact_declaration_key_leaves_every_ecosystems_legitimate_names_untouched() {
688        for name in [
689            "serde",                                            // Cargo
690            "com.google.guava:guava",                           // Maven/Gradle
691            "org.springframework.boot:spring-boot-starter-web", // Maven/Gradle
692            "@types/node",                                      // npm
693            "@babel/core",                                      // npm
694            "jsr:@std/path",                                    // Deno
695            "npm:@scope/pkg",                                   // Deno
696            "requests",                                         // PyPI
697            "github.com/gin-gonic/gin",                         // Go
698            "gopkg.in/yaml.v3",                                 // Go
699            "rails",                                            // Bundler
700            "path",                                             // Dart
701            "git@github.com:apple/swift-nio.git",               // Swift
702            "monolog/monolog",                                  // Composer
703            "Newtonsoft.Json",                                  // NuGet
704            "actions/checkout",                                 // GitHub Actions
705            "gitlab.com/components/sast",                       // GitLab CI
706            "ghcr.io/owner/image@sha256:abcdef",                // GitLab CI (component ref)
707            "com.example:${project.version}",                   // Maven (unresolved property)
708            "alternate registry (not registered)",              // shared fallback label
709        ] {
710            assert_eq!(
711                redact_declaration_key(name),
712                name,
713                "legitimate name {name:?} must survive redaction unchanged"
714            );
715        }
716    }
717
718    /// #1209 M2 (impl-critic follow-up): `redact_declaration_key` cannot distinguish a real
719    /// `label:value@suffix`-shaped name from a genuine credential — the same structural
720    /// ambiguity [`redact_declaration_key`]'s own doc already calls out. No current ecosystem
721    /// emits this shape in a `PackageName` (Deno keeps the version out of the name —
722    /// `deps_deno::specifier::ParsedSpecifier`'s `name` field never carries a trailing
723    /// `@version`), so this is not a live bug — but since [`RedactedName`] now feeds
724    /// [`crate::DepsError::PackageNotFound`]'s `Display`, which reaches a `window/showMessage`
725    /// toast, a future ecosystem emitting this shape would silently over-redact a legitimate
726    /// name in a user-visible message. This test documents the known limitation explicitly
727    /// (asserting the *actual*, over-redacting behavior) rather than leaving it as a silent
728    /// gap — a change to any of these outcomes should be a deliberate, reviewed one.
729    #[test]
730    fn redact_declaration_key_over_redacts_documented_label_value_at_suffix_shapes() {
731        assert_eq!(redact_declaration_key("npm:express@4.18.2"), "***@4.18.2");
732        assert_eq!(
733            redact_declaration_key("com.example:artifact@1.0"),
734            "***@1.0"
735        );
736        assert_eq!(
737            redact_declaration_key("alpine:3.18@sha256:abc"),
738            "***@sha256:***"
739        );
740        assert_eq!(
741            redact_declaration_key("ghcr.io/owner/image:1.2.3@sha256:abcdef"),
742            "ghcr.io/owner/image:***"
743        );
744    }
745
746    /// Critic follow-up M1 (#1242, #1246): `sanitize_invisible` covers `Cc`/`Cf`, but U+2028
747    /// LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are neither category — both are line
748    /// terminators for JS/`eval` consumers of `--format json` output and are treated as
749    /// breaks by some editor renderers, so they must be sanitized too.
750    #[test]
751    fn sanitize_invisible_replaces_line_and_paragraph_separators() {
752        assert_eq!(
753            sanitize_invisible("a\u{2028}b\u{2029}c"),
754            "a b c",
755            "U+2028/U+2029 must not survive sanitize_invisible"
756        );
757    }
758
759    #[test]
760    fn sanitize_invisible_borrows_when_nothing_needs_sanitizing() {
761        assert!(matches!(
762            sanitize_invisible("com.google.guava:guava"),
763            std::borrow::Cow::Borrowed(_)
764        ));
765    }
766}