Skip to main content

mcpls_core/
redaction.rs

1//! Redaction of secrets from server output that reaches logs and MCP clients.
2//!
3//! A language server's stderr is shown to the MCP client when startup fails.
4//! A server that dumps its environment or echoes a rejected argument could
5//! otherwise hand over credentials, so values that look secret are replaced
6//! with `[redacted:NAME]` first. Matching is by exact value plus its JSON and
7//! `Debug` escaped spellings, so a secret is also found inside a logged wire
8//! frame or a `{:?}` rendering. Other encodings (URL, base64) are not found,
9//! and neither is a JSON spelling written by a foreign encoder that escapes
10//! non-ASCII as `\uXXXX` or `/` as `\/`: such a secret can slip past trace
11//! redaction.
12
13use std::borrow::Cow;
14
15use crate::config::LspServerConfig;
16use crate::error::Error;
17
18/// Shortest value redacted; shorter ones would match unrelated text.
19const MIN_SECRET_BYTES: usize = 8;
20
21/// Shortest fragment of a secret hidden when an elision cut splits it.
22const MIN_FRAGMENT_BYTES: usize = 4;
23
24/// Longest label kept in a replacement marker.
25const MAX_LABEL_CHARS: usize = 64;
26
27/// Upper-case substrings that make an environment variable, flag or JSON key
28/// name denote a secret.
29const SECRET_NAME_PATTERNS: [&str; 6] = ["TOKEN", "KEY", "SECRET", "PASSW", "CRED", "AUTH"];
30
31/// Whole name segments (upper-case) that contain a secret pattern but name
32/// something benign, such as the `AUTH` of `GIT_AUTHOR_NAME`.
33const BENIGN_SEGMENTS: [&str; 7] = [
34    "AUTHOR",
35    "AUTHORS",
36    "AUTHORITY",
37    "XAUTHORITY",
38    "KEYBOARD",
39    "KEYMAP",
40    "TOKENIZERS",
41];
42
43/// Whole names (upper-case) that are benign although their segments joined
44/// would match: `SSH_AUTH_SOCK` holds a socket path.
45const BENIGN_NAMES: [&str; 1] = ["SSH_AUTH_SOCK"];
46
47/// Splits `name` into segments at `_`, `-` and `.`, and at camel-case
48/// boundaries (`proxyAuth`, and the end of an acronym in `XMLHttp`).
49fn name_segments(name: &str) -> impl Iterator<Item = &str> {
50    name.split(['_', '-', '.']).flat_map(|piece| {
51        let mut starts = vec![0];
52        let chars: Vec<(usize, char)> = piece.char_indices().collect();
53        for (index, [(_, before), (start, after)]) in chars.array_windows().enumerate() {
54            let next_is_lower = chars
55                .get(index.saturating_add(2))
56                .is_some_and(|(_, next)| next.is_lowercase());
57            let lower_to_upper =
58                (before.is_lowercase() || before.is_ascii_digit()) && after.is_uppercase();
59            let acronym_end = before.is_uppercase() && after.is_uppercase() && next_is_lower;
60            if lower_to_upper || acronym_end {
61                starts.push(*start);
62            }
63        }
64        starts.push(piece.len());
65        starts
66            .array_windows()
67            .filter_map(|[from, to]| piece.get(*from..*to))
68            .collect::<Vec<_>>()
69    })
70}
71
72/// Whether `name` (an environment variable, a flag without its dashes, or a
73/// JSON key) denotes a secret, ignoring case.
74///
75/// Fails closed: the segments that are exactly a benign word are dropped, the
76/// rest are joined and matched by substring, so `passWord` and `AUTHORIZATION`
77/// stay secret while `GIT_AUTHOR_NAME` and `XAUTHORITY` do not.
78pub fn is_secret_name(name: &str) -> bool {
79    if BENIGN_NAMES.contains(&name.to_ascii_uppercase().as_str()) {
80        return false;
81    }
82    let kept: String = name_segments(name)
83        .map(str::to_ascii_uppercase)
84        .filter(|segment| !BENIGN_SEGMENTS.contains(&segment.as_str()))
85        .collect();
86    SECRET_NAME_PATTERNS
87        .iter()
88        .any(|pattern| kept.contains(pattern))
89}
90
91/// Text known to carry no configured secret: it was redacted when built or is
92/// a fixed string.
93///
94/// It has no public constructor, so a value of this type held by
95/// [`Error::LspProtocolError`] cannot be an unredacted `String`; the crate
96/// builds it only through the redaction set of the server it came from.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RedactedText(String);
99
100impl RedactedText {
101    /// Wraps a literal, which cannot hold a secret.
102    pub(crate) fn fixed(text: &'static str) -> Self {
103        Self(text.to_owned())
104    }
105
106    /// The text.
107    #[must_use]
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113impl std::fmt::Display for RedactedText {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.write_str(&self.0)
116    }
117}
118
119/// A tool result whose server-supplied display prose can be scrubbed of
120/// configured secrets before it reaches the MCP client.
121///
122/// Implementors destructure `Self` exhaustively so a new field must be
123/// classified: prose goes through [`Redactions::redact_in_place`], payload
124/// the client reuses verbatim (edits, locations, identifiers, round-trip
125/// items, command arguments) through [`Redactions::note_payload`], nested
126/// results through their own `redact_server_text`, and non-text fields are
127/// skipped. A string already redacted upstream (cached diagnostics, logs and
128/// messages) is not redacted again.
129pub trait ServerText {
130    /// Redacts the prose fields of `self` in place.
131    fn redact_server_text(&mut self, redactions: &Redactions);
132}
133
134impl<T: ServerText> ServerText for Vec<T> {
135    fn redact_server_text(&mut self, redactions: &Redactions) {
136        for item in self {
137            item.redact_server_text(redactions);
138        }
139    }
140}
141
142impl<T: ServerText> ServerText for Option<T> {
143    fn redact_server_text(&mut self, redactions: &Redactions) {
144        if let Some(inner) = self {
145            inner.redact_server_text(redactions);
146        }
147    }
148}
149
150#[derive(Clone, PartialEq, Eq)]
151struct Secret {
152    label: String,
153    value: String,
154    /// JSON- and `Debug`-escaped spellings of `value` that differ from it.
155    escaped: Vec<String>,
156}
157
158/// Prints the label only: a derived `Debug` would print the secret itself
159/// wherever a struct holding a [`Redactions`] is formatted.
160impl std::fmt::Debug for Secret {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.debug_struct("Secret")
163            .field("label", &self.label)
164            .finish_non_exhaustive()
165    }
166}
167
168impl Secret {
169    fn new(label: String, value: String) -> Self {
170        let json = serde_json::Value::String(value.clone()).to_string();
171        let debug = format!("{value:?}");
172        let mut escaped: Vec<String> = [json, debug]
173            .into_iter()
174            .filter_map(|quoted| quoted.strip_circumfix('"', '"').map(str::to_owned))
175            .filter(|spelling| *spelling != value)
176            .collect();
177        escaped.sort();
178        escaped.dedup();
179        Self {
180            label,
181            value,
182            escaped,
183        }
184    }
185
186    fn marker(&self) -> String {
187        format!("[redacted:{}]", self.label)
188    }
189
190    fn spellings(&self) -> impl Iterator<Item = &str> {
191        std::iter::once(self.value.as_str()).chain(self.escaped.iter().map(String::as_str))
192    }
193}
194
195/// The secret values to hide from a server's output, longest first so a value
196/// that contains another is replaced whole.
197#[derive(Clone, Default, PartialEq, Eq)]
198pub struct Redactions(Vec<Secret>);
199
200/// Prints the number of secrets only, never a value.
201impl std::fmt::Debug for Redactions {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.debug_struct("Redactions")
204            .field("secrets", &self.0.len())
205            .finish()
206    }
207}
208
209impl Redactions {
210    /// Builds the set from `(label, value)` candidates, dropping values under
211    /// [`MIN_SECRET_BYTES`] and duplicates. Labels are reduced to
212    /// `[A-Za-z0-9_.-]`.
213    pub(crate) fn new(candidates: impl IntoIterator<Item = (String, String)>) -> Self {
214        let mut secrets: Vec<Secret> = candidates
215            .into_iter()
216            .filter(|(_, value)| value.len() >= MIN_SECRET_BYTES)
217            .map(|(label, value)| Secret::new(sanitize_label(&label), value))
218            .collect();
219        secrets.sort_by(|a, b| {
220            b.value
221                .len()
222                .cmp(&a.value.len())
223                .then_with(|| a.value.cmp(&b.value))
224                .then_with(|| a.label.cmp(&b.label))
225        });
226        secrets.dedup_by(|a, b| a.value == b.value);
227        let markers: Vec<String> = secrets.iter().map(Secret::marker).collect();
228        secrets.retain(|secret| {
229            let inside_marker = markers.iter().any(|marker| marker.contains(&secret.value));
230            if inside_marker {
231                tracing::warn!(
232                    "secret {} is not redacted: its value occurs inside a redaction marker",
233                    secret.marker()
234                );
235            }
236            !inside_marker
237        });
238        Self(secrets)
239    }
240
241    /// The set hiding every secret of `sets`, rebuilt through [`Self::new`]
242    /// so its marker-substring filter runs over the merged labels.
243    pub(crate) fn union<'a>(sets: impl IntoIterator<Item = &'a Self>) -> Self {
244        let mut sets = sets.into_iter().filter(|set| !set.is_empty());
245        let Some(first) = sets.next() else {
246            return Self::default();
247        };
248        let Some(second) = sets.next() else {
249            return first.clone();
250        };
251        Self::new(
252            [first, second]
253                .into_iter()
254                .chain(sets)
255                .flat_map(|set| set.0.iter())
256                .map(|secret| (secret.label.clone(), secret.value.clone())),
257        )
258    }
259
260    /// Records, at debug level and by label only, that `text` -- a payload the
261    /// client applies verbatim and so is left unmodified -- holds a secret.
262    pub(crate) fn note_payload(&self, text: &str) {
263        if self.is_empty() || !tracing::enabled!(tracing::Level::DEBUG) {
264            return;
265        }
266        for secret in &self.0 {
267            if secret.spellings().any(|spelling| text.contains(spelling)) {
268                tracing::debug!(
269                    "tool result payload holds configured secret {}; left unmodified",
270                    secret.marker()
271                );
272            }
273        }
274    }
275
276    /// [`Self::note_payload`] for a JSON payload.
277    pub(crate) fn note_payload_json(&self, value: &serde_json::Value) {
278        if self.is_empty() || !tracing::enabled!(tracing::Level::DEBUG) {
279            return;
280        }
281        self.note_payload(&value.to_string());
282    }
283
284    /// The secrets a launched server can leak: values of `config.env` and of
285    /// the `inherited` environment whose names look secret, the value of a
286    /// secret-named `--flag=value` or `--flag value` argument, and string
287    /// leaves of `initialization_options` and `settings` under a secret-named
288    /// key.
289    pub(crate) fn for_server(
290        config: &LspServerConfig,
291        inherited: impl IntoIterator<Item = (String, String)>,
292    ) -> Self {
293        Self::for_servers(std::iter::once(config), inherited)
294    }
295
296    /// The secrets any of `configs` can leak, over one `inherited`
297    /// environment: [`Self::for_server`] for every server, whether or not
298    /// its project markers matched, so a server that echoes another's secret
299    /// still has it hidden. Fails closed: a configured secret is hidden
300    /// everywhere, not only in the output of the server that owns it.
301    pub(crate) fn for_servers<'a>(
302        configs: impl IntoIterator<Item = &'a LspServerConfig>,
303        inherited: impl IntoIterator<Item = (String, String)>,
304    ) -> Self {
305        let inherited: Vec<(String, String)> = inherited
306            .into_iter()
307            .filter(|(name, _)| is_secret_name(name))
308            .collect();
309        let mut candidates = inherited;
310        for config in configs {
311            candidates.extend(
312                config
313                    .env
314                    .iter()
315                    .filter(|(name, _)| is_secret_name(name.as_str()))
316                    .map(|(name, value)| (name.to_string(), value.clone())),
317            );
318            collect_secret_args(&config.args, &mut candidates);
319            if let Some(options) = &config.initialization_options {
320                collect_secret_json(options, None, &mut candidates);
321            }
322            if let Some(settings) = &config.settings {
323                collect_secret_json(&settings.to_value(), None, &mut candidates);
324            }
325        }
326        Self::new(candidates)
327    }
328
329    /// Replaces every secret, in raw, JSON-escaped and `Debug`-escaped
330    /// spelling, in `text` with its marker; borrows `text` when none occurs.
331    pub(crate) fn apply<'a>(&self, text: &'a str) -> Cow<'a, str> {
332        self.0.iter().fold(Cow::Borrowed(text), |acc, secret| {
333            secret.spellings().fold(acc, |acc, spelling| {
334                if acc.contains(spelling) {
335                    Cow::Owned(acc.replace(spelling, &secret.marker()))
336                } else {
337                    acc
338                }
339            })
340        })
341    }
342
343    /// Whether there is nothing to hide.
344    pub(crate) const fn is_empty(&self) -> bool {
345        self.0.is_empty()
346    }
347
348    /// [`Self::apply`] in place; `text` is written only when a secret occurs.
349    pub(crate) fn redact_in_place(&self, text: &mut String) {
350        if self.is_empty() {
351            return;
352        }
353        if let Cow::Owned(redacted) = self.apply(text) {
354            *text = redacted;
355        }
356    }
357
358    /// Redacts every string leaf of `value`; object keys are left alone.
359    pub(crate) fn redact_json(&self, value: &mut serde_json::Value) {
360        match value {
361            serde_json::Value::String(text) => self.redact_in_place(text),
362            serde_json::Value::Array(items) => {
363                for item in items {
364                    self.redact_json(item);
365                }
366            }
367            serde_json::Value::Object(map) => {
368                for item in map.values_mut() {
369                    self.redact_json(item);
370                }
371            }
372            _ => {}
373        }
374    }
375
376    /// Redacts the text a server controls in `diagnostic`: message, source,
377    /// string code, related-information messages and `data`.
378    ///
379    /// URIs (`relatedInformation[].location.uri`, `codeDescription.href`) are
380    /// kept: they are cache keys and links, and a secret-valued path would
381    /// make them unusable.
382    pub(crate) fn redact_diagnostic(&self, diagnostic: &mut lsp_types::Diagnostic) {
383        match &mut diagnostic.message {
384            lsp_types::Message::String(text) => self.redact_in_place(text),
385            lsp_types::Message::MarkupContent(content) => self.redact_in_place(&mut content.value),
386        }
387        if let Some(source) = &mut diagnostic.source {
388            self.redact_in_place(source);
389        }
390        if let Some(lsp_types::Code::String(code)) = &mut diagnostic.code {
391            self.redact_in_place(code);
392        }
393        for related in diagnostic.related_information.iter_mut().flatten() {
394            self.redact_in_place(&mut related.message);
395        }
396        if let Some(data) = &mut diagnostic.data {
397            self.redact_json(data);
398        }
399    }
400
401    /// Redacts the `title` and `message` of a `$/progress` payload.
402    pub(crate) fn redact_progress(&self, value: &mut serde_json::Value) {
403        for key in ["title", "message"] {
404            if let Some(serde_json::Value::String(text)) = value.get_mut(key) {
405                self.redact_in_place(text);
406            }
407        }
408    }
409
410    /// [`Self::apply`], wrapped as [`RedactedText`].
411    fn redact(&self, text: &str) -> RedactedText {
412        RedactedText(self.apply(text).into_owned())
413    }
414
415    /// An [`Error::LspProtocolError`] whose text is `message` with the secrets
416    /// replaced: the only way to build one from formatted text.
417    pub(crate) fn protocol_error(&self, message: std::fmt::Arguments<'_>) -> Error {
418        Error::LspProtocolError(self.redact(&message.to_string()))
419    }
420
421    /// `error` unchanged when its text holds no secret, otherwise an
422    /// [`Error::LspProtocolError`] carrying the redacted text, so an error
423    /// built from server-controlled text cannot hand a secret to the client.
424    pub(crate) fn sanitize_error(&self, error: Error) -> Error {
425        if self.is_empty() {
426            return error;
427        }
428        match self.apply(&error.to_string()) {
429            Cow::Borrowed(_) => error,
430            Cow::Owned(redacted) => Error::LspProtocolError(RedactedText(redacted)),
431        }
432    }
433
434    /// Hides a secret cut by an elision boundary at the end of `head`: the
435    /// longest trailing fragment of at least [`MIN_FRAGMENT_BYTES`] bytes that
436    /// is a prefix of a secret is replaced by its marker.
437    pub(crate) fn mask_cut_head(&self, head: &str) -> String {
438        for secret in &self.0 {
439            for len in (MIN_FRAGMENT_BYTES..secret.value.len()).rev() {
440                let Some(fragment) = secret.value.get(..len) else {
441                    continue;
442                };
443                if let Some(kept) = head.strip_suffix(fragment) {
444                    return format!("{kept}{}", secret.marker());
445                }
446            }
447        }
448        head.to_owned()
449    }
450
451    /// As [`Self::mask_cut_head`], for a secret cut at the start of `tail`.
452    pub(crate) fn mask_cut_tail(&self, tail: &str) -> String {
453        for secret in &self.0 {
454            let total = secret.value.len();
455            for len in (MIN_FRAGMENT_BYTES..total).rev() {
456                let Some(fragment) = secret.value.get(total.saturating_sub(len)..) else {
457                    continue;
458                };
459                if let Some(rest) = tail.strip_prefix(fragment) {
460                    return format!("{}{rest}", secret.marker());
461                }
462            }
463        }
464        tail.to_owned()
465    }
466}
467
468fn sanitize_label(label: &str) -> String {
469    label
470        .chars()
471        .take(MAX_LABEL_CHARS)
472        .map(|c| {
473            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
474                c
475            } else {
476                '_'
477            }
478        })
479        .collect()
480}
481
482fn collect_secret_args(args: &[String], out: &mut Vec<(String, String)>) {
483    let mut iter = args.iter().peekable();
484    while let Some(arg) = iter.next() {
485        let Some(flag) = arg.strip_prefix('-') else {
486            continue;
487        };
488        let flag = flag.trim_start_matches('-');
489        if let Some((name, value)) = flag.split_once('=') {
490            if is_secret_name(name) {
491                out.push((name.to_owned(), value.to_owned()));
492            }
493        } else if is_secret_name(flag)
494            && let Some(value) = iter.next_if(|next| !next.starts_with('-'))
495        {
496            out.push((flag.to_owned(), value.clone()));
497        }
498    }
499}
500
501fn collect_secret_json(
502    value: &serde_json::Value,
503    secret_key: Option<&str>,
504    out: &mut Vec<(String, String)>,
505) {
506    match value {
507        serde_json::Value::String(text) => {
508            if let Some(key) = secret_key {
509                out.push((key.to_owned(), text.clone()));
510            }
511        }
512        serde_json::Value::Object(map) => {
513            for (key, child) in map {
514                let key_for_child = if is_secret_name(key) {
515                    Some(key.as_str())
516                } else {
517                    secret_key
518                };
519                collect_secret_json(child, key_for_child, out);
520            }
521        }
522        serde_json::Value::Array(items) => {
523            for item in items {
524                collect_secret_json(item, secret_key, out);
525            }
526        }
527        _ => {}
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use std::assert_matches;
534
535    use super::*;
536
537    fn redactions(pairs: &[(&str, &str)]) -> Redactions {
538        Redactions::new(
539            pairs
540                .iter()
541                .map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
542        )
543    }
544
545    fn server_config() -> LspServerConfig {
546        let mut config = LspServerConfig::rust_analyzer();
547        config.env = crate::config::ServerEnv::default();
548        config.args = Vec::new();
549        config.initialization_options = None;
550        config
551    }
552
553    #[test]
554    fn test_for_servers_covers_every_configured_server_over_one_environment() {
555        let mut first = server_config();
556        first.env.insert(
557            crate::config::EnvKey::from_static("FIRST_TOKEN"),
558            "first-secret-value".into(),
559        );
560        let mut second = server_config();
561        second.args = vec!["--api-key=second-secret-value".into()];
562
563        let set = Redactions::for_servers(
564            [&first, &second],
565            [(
566                "GITHUB_TOKEN".to_owned(),
567                "inherited-secret-value".to_owned(),
568            )],
569        );
570
571        for text in [
572            "first-secret-value",
573            "second-secret-value",
574            "inherited-secret-value",
575        ] {
576            assert!(set.apply(text).contains("[redacted:"), "{text}");
577        }
578    }
579
580    #[test]
581    fn test_debug_never_prints_a_secret_value() {
582        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
583        let other_value = redactions(&[("API_TOKEN", "zzzz_ijklmnop")]);
584
585        let printed = format!("{set:?} {:?}", set.0[0]);
586        let printed_other = format!("{other_value:?} {:?}", other_value.0[0]);
587
588        assert!(printed.eq(&printed_other));
589        assert!(printed.contains("secrets: 1"));
590    }
591
592    #[test]
593    fn test_new_drops_value_contained_in_a_marker() {
594        let set = redactions(&[("API_TOKEN", "redacted:API_TOKEN"), ("OTHER_KEY", SECRET)]);
595        assert_eq!(set.apply("redacted:API_TOKEN"), "redacted:API_TOKEN");
596        assert_eq!(set.apply(SECRET), "[redacted:OTHER_KEY]");
597    }
598
599    #[test]
600    fn test_union_merges_sets_and_reruns_the_marker_filter() {
601        let a = redactions(&[("A_TOKEN", "alpha-secret-111")]);
602        let b = redactions(&[("B_TOKEN", "A_TOKEN]-beta-2")]);
603        let merged = Redactions::union([&a, &b, &Redactions::default()]);
604        assert_eq!(merged.apply("alpha-secret-111"), "[redacted:A_TOKEN]");
605        assert_eq!(merged.apply("A_TOKEN]-beta-2"), "[redacted:B_TOKEN]");
606        assert!(Redactions::union([&Redactions::default()]).is_empty());
607        assert_eq!(Redactions::union([&a]), a);
608    }
609
610    fn captured(level: tracing_subscriber::filter::LevelFilter, run: impl FnOnce()) -> Vec<String> {
611        use tracing_subscriber::prelude::*;
612
613        let logs = crate::test_lsp::CapturedLogs::default();
614        let subscriber = tracing_subscriber::registry()
615            .with(level)
616            .with(logs.clone());
617        tracing::subscriber::with_default(subscriber, run);
618        logs.messages()
619    }
620
621    #[test]
622    fn test_note_payload_logs_label_only_at_debug_and_is_silent_at_info() {
623        use tracing_subscriber::filter::LevelFilter;
624
625        let set = redactions(&[("API_TOKEN", SECRET)]);
626
627        let debug = captured(LevelFilter::DEBUG, || {
628            set.note_payload(&format!("let k = {SECRET};"));
629            set.note_payload("clean text");
630        });
631        assert_eq!(debug.len(), 1, "{debug:?}");
632        assert!(debug[0].contains("[redacted:API_TOKEN]"), "{debug:?}");
633        assert!(!debug[0].contains(SECRET), "{debug:?}");
634
635        let info = captured(LevelFilter::INFO, || {
636            set.note_payload(&format!("let k = {SECRET};"));
637        });
638        assert!(info.is_empty(), "{info:?}");
639    }
640
641    #[test]
642    fn test_new_warns_with_label_only_when_a_value_is_dropped() {
643        use tracing_subscriber::filter::LevelFilter;
644
645        let warnings = captured(LevelFilter::WARN, || {
646            let _ = redactions(&[("API_TOKEN", "redacted:API_TOKEN")]);
647        });
648        assert_eq!(warnings.len(), 1, "{warnings:?}");
649        assert!(warnings[0].contains("[redacted:API_TOKEN]"), "{warnings:?}");
650    }
651
652    #[test]
653    fn test_for_server_collects_secret_named_settings_leaves() {
654        let mut config = server_config();
655        config.settings = Some(
656            serde_json::from_value(serde_json::json!({
657                "tool.apiToken": "settings-secret-123",
658                "tool.theme": "plain-visible-value"
659            }))
660            .unwrap(),
661        );
662
663        let set = Redactions::for_server(&config, []);
664
665        assert_eq!(
666            set.apply("settings-secret-123 plain-visible-value"),
667            "[redacted:apiToken] plain-visible-value"
668        );
669    }
670
671    #[test]
672    fn test_apply_finds_json_and_debug_escaped_spellings() {
673        let set = redactions(&[("API_TOKEN", "pa\"ss\\word-12345")]);
674        let frame = serde_json::json!({"token": "pa\"ss\\word-12345"}).to_string();
675        let debug = format!("{:?}", ["pa\"ss\\word-12345"]);
676
677        for text in [frame, debug, "raw pa\"ss\\word-12345".to_owned()] {
678            let cleaned = set.apply(&text);
679            assert!(cleaned.contains("[redacted:API_TOKEN]"), "{cleaned}");
680            assert!(!cleaned.contains("word-12345"), "{cleaned}");
681        }
682    }
683
684    const SECRET: &str = "SuperSecretValue123";
685
686    fn secret_diagnostic() -> lsp_types::Diagnostic {
687        serde_json::from_value(serde_json::json!({
688            "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
689            "code": "E-SuperSecretValue123",
690            "codeDescription": {"href": "https://example.com/SuperSecretValue123"},
691            "source": "lint-SuperSecretValue123",
692            "message": {"kind": "markdown", "value": "see `SuperSecretValue123`"},
693            "relatedInformation": [{
694                "location": {
695                    "uri": "file:///SuperSecretValue123/a.rs",
696                    "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}
697                },
698                "message": "defined with SuperSecretValue123"
699            }],
700            "data": {"SuperSecretValue123": ["x", {"nested": "has SuperSecretValue123"}], "n": 3}
701        }))
702        .unwrap()
703    }
704
705    #[test]
706    fn test_redact_diagnostic_covers_text_fields_and_data_values() {
707        let set = redactions(&[("API_TOKEN", SECRET)]);
708        let mut diagnostic = secret_diagnostic();
709
710        set.redact_diagnostic(&mut diagnostic);
711
712        let lsp_types::Message::MarkupContent(content) = &diagnostic.message else {
713            panic!("expected markup content");
714        };
715        assert_eq!(content.value, "see `[redacted:API_TOKEN]`");
716        assert_eq!(
717            diagnostic.source.as_deref(),
718            Some("lint-[redacted:API_TOKEN]")
719        );
720        assert_eq!(
721            diagnostic.code,
722            Some(lsp_types::Code::String("E-[redacted:API_TOKEN]".to_owned()))
723        );
724        let related = diagnostic.related_information.as_ref().unwrap();
725        assert_eq!(related[0].message, "defined with [redacted:API_TOKEN]");
726        let data = diagnostic.data.as_ref().unwrap();
727        assert_eq!(data[SECRET][1]["nested"], "has [redacted:API_TOKEN]");
728        assert_eq!(data["n"], 3);
729    }
730
731    #[test]
732    fn test_redact_diagnostic_keeps_uris() {
733        let set = redactions(&[("API_TOKEN", SECRET)]);
734        let mut diagnostic = secret_diagnostic();
735
736        set.redact_diagnostic(&mut diagnostic);
737
738        let related = diagnostic.related_information.as_ref().unwrap();
739        assert_eq!(
740            AsRef::<str>::as_ref(&related[0].location.uri),
741            "file:///SuperSecretValue123/a.rs"
742        );
743        let href = &diagnostic.code_description.as_ref().unwrap().href;
744        assert_eq!(
745            AsRef::<str>::as_ref(href),
746            "https://example.com/SuperSecretValue123"
747        );
748    }
749
750    #[test]
751    fn test_redact_progress_covers_title_and_message() {
752        let set = redactions(&[("API_TOKEN", SECRET)]);
753        let mut value = serde_json::json!({
754            "kind": "begin", "title": "index SuperSecretValue123", "message": "at SuperSecretValue123"
755        });
756
757        set.redact_progress(&mut value);
758
759        assert_eq!(value["title"], "index [redacted:API_TOKEN]");
760        assert_eq!(value["message"], "at [redacted:API_TOKEN]");
761    }
762
763    #[test]
764    fn test_redact_in_place_leaves_clean_text_untouched() {
765        let set = redactions(&[("API_TOKEN", SECRET)]);
766        let mut clean = String::from("nothing here");
767        let mut dirty = format!("x {SECRET}");
768
769        set.redact_in_place(&mut clean);
770        set.redact_in_place(&mut dirty);
771
772        assert_eq!(clean, "nothing here");
773        assert_eq!(dirty, "x [redacted:API_TOKEN]");
774        assert!(!set.is_empty());
775        assert!(Redactions::default().is_empty());
776    }
777
778    #[test]
779    fn test_protocol_error_masks_secrets_and_keeps_other_text() {
780        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
781
782        let error = set.protocol_error(format_args!("bad value ghp_abcdefgh in {}", "frame"));
783
784        assert_eq!(
785            error.to_string(),
786            "LSP protocol error: bad value [redacted:API_TOKEN] in frame"
787        );
788    }
789
790    #[test]
791    fn test_sanitize_error_redacts_a_secret_and_keeps_a_clean_error() {
792        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
793        let leaking = Error::LspServerError {
794            code: -32603,
795            message: "bad token ghp_abcdefgh".into(),
796            data: None,
797        };
798
799        let sanitized = set.sanitize_error(leaking);
800
801        assert!(
802            !sanitized.to_string().contains("ghp_abcdefgh"),
803            "{sanitized}"
804        );
805        assert!(sanitized.to_string().contains("[redacted:API_TOKEN]"));
806        assert_matches!(
807            set.sanitize_error(Error::ServerTerminated),
808            Error::ServerTerminated
809        );
810    }
811
812    #[test]
813    fn test_apply_borrows_text_without_secrets() {
814        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
815
816        assert_matches!(set.apply("nothing here"), Cow::Borrowed(_));
817    }
818
819    #[test]
820    fn test_secret_name_matches_case_insensitively() {
821        for name in [
822            "GITHUB_TOKEN",
823            "api_key",
824            "MySecret",
825            "DB_PASSWORD",
826            "AWS_CREDENTIALS",
827            "Authorization",
828        ] {
829            assert!(is_secret_name(name), "{name}");
830        }
831        for name in ["RUSTUP_TOOLCHAIN", "JAVA_HOME", "GOFLAGS", "PATH"] {
832            assert!(!is_secret_name(name), "{name}");
833        }
834    }
835
836    #[test]
837    fn test_secret_name_stays_fail_closed_across_segment_boundaries() {
838        for name in [
839            "AUTHORIZATION",
840            "HTTP_PROXY_AUTHORIZATION",
841            "http.proxyAuthorization",
842            "GIT_AUTHOR_TOKEN",
843            "OPENAI_API_KEY",
844            "apiKey",
845            "APIKEY",
846            "AUTHTOKEN",
847            "db_passwd",
848            "passWord",
849            "passWd",
850            "dbPassWord",
851            "myToKen",
852            "XMLHttpKey",
853            "SSH_AUTH_SOCK_TOKEN",
854            "AUTHORS_KEY",
855            "tokenizers_apikey",
856            "ключ_TOKEN",
857        ] {
858            assert!(is_secret_name(name), "{name}");
859        }
860    }
861
862    #[test]
863    fn test_secret_name_skips_well_known_benign_names() {
864        for name in [
865            "GIT_AUTHOR_NAME",
866            "GIT_AUTHOR_EMAIL",
867            "SSH_AUTH_SOCK",
868            "ssh_auth_sock",
869            "XAUTHORITY",
870            "XAuthority",
871            "TOKENIZERS_PARALLELISM",
872            "KEYBOARD_LAYOUT",
873            "keymap",
874        ] {
875            assert!(!is_secret_name(name), "{name}");
876        }
877    }
878
879    #[test]
880    fn test_name_segments_split_on_separators_and_camel_case() {
881        let segments = |name| name_segments(name).collect::<Vec<_>>();
882        assert_eq!(
883            segments("GIT_AUTHOR-NAME.x"),
884            ["GIT", "AUTHOR", "NAME", "x"]
885        );
886        assert_eq!(segments("proxyAuthorization"), ["proxy", "Authorization"]);
887        assert_eq!(segments("XMLHttpKey"), ["XML", "Http", "Key"]);
888        assert_eq!(segments("v2Key"), ["v2", "Key"]);
889        assert_eq!(segments(""), [""]);
890    }
891
892    #[test]
893    fn test_apply_labels_the_replacement_and_skips_short_values() {
894        let set = redactions(&[("GITHUB_TOKEN", "ghp_abcdefgh"), ("SHORT_KEY", "abc")]);
895
896        assert_eq!(
897            set.apply("t=ghp_abcdefgh k=abc"),
898            "t=[redacted:GITHUB_TOKEN] k=abc"
899        );
900    }
901
902    #[test]
903    fn test_apply_replaces_the_longer_overlapping_secret_whole() {
904        let set = redactions(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]);
905
906        let text = set.apply("v=abcdefghXYZ12345");
907
908        assert_eq!(text, "v=[redacted:B_KEY]");
909    }
910
911    #[test]
912    fn test_new_deduplicates_equal_values() {
913        let set = redactions(&[("A_TOKEN", "samesecret"), ("B_TOKEN", "samesecret")]);
914
915        assert_eq!(set.0.len(), 1);
916    }
917
918    #[test]
919    fn test_label_is_reduced_to_safe_characters() {
920        let set = redactions(&[("bad]\n[label", "secretvalue")]);
921
922        assert_eq!(set.apply("secretvalue"), "[redacted:bad___label]");
923    }
924
925    #[test]
926    fn test_for_server_takes_secret_named_env_only() {
927        let mut config = server_config();
928        config.env = crate::config::ServerEnv::from_entries(
929            [
930                (
931                    crate::config::EnvKey::from_static("API_TOKEN"),
932                    "tok-1234567890".to_string(),
933                ),
934                (
935                    crate::config::EnvKey::from_static("RUSTUP_TOOLCHAIN"),
936                    "nightly-2024-01-01".to_string(),
937                ),
938            ],
939            crate::lsp::HostOs::CURRENT,
940        )
941        .unwrap();
942
943        let set = Redactions::for_server(&config, []);
944
945        let text = set.apply("tok-1234567890 nightly-2024-01-01");
946        assert_eq!(text, "[redacted:API_TOKEN] nightly-2024-01-01");
947    }
948
949    #[test]
950    fn test_for_server_takes_inherited_secret_env() {
951        let set = Redactions::for_server(
952            &server_config(),
953            [
954                ("GITHUB_TOKEN".to_string(), "ghp_inherited123".to_string()),
955                ("HOME".to_string(), "/home/someone-long".to_string()),
956            ],
957        );
958
959        assert_eq!(
960            set.apply("ghp_inherited123 /home/someone-long"),
961            "[redacted:GITHUB_TOKEN] /home/someone-long"
962        );
963    }
964
965    #[test]
966    fn test_for_server_takes_secret_flag_values_in_both_forms() {
967        let mut config = server_config();
968        config.args = [
969            "--stdio",
970            "--api-key=key-value-1",
971            "--auth-token",
972            "tok-value-2",
973            "--verbose",
974            "--log-level=trace-level",
975        ]
976        .map(String::from)
977        .to_vec();
978
979        let set = Redactions::for_server(&config, []);
980
981        assert_eq!(
982            set.apply("key-value-1 tok-value-2 trace-level"),
983            "[redacted:api-key] [redacted:auth-token] trace-level"
984        );
985    }
986
987    #[test]
988    fn test_for_server_takes_string_leaves_under_secret_json_keys() {
989        let mut config = server_config();
990        config.initialization_options = Some(serde_json::json!({
991            "server": {"name": "long-server-name", "apiToken": "init-token-value"},
992            "credentials": ["first-credential", {"inner": "second-credential"}],
993            "retries": 3
994        }));
995
996        let set = Redactions::for_server(&config, []);
997
998        let text =
999            set.apply("long-server-name init-token-value first-credential second-credential");
1000        assert_eq!(
1001            text,
1002            "long-server-name [redacted:apiToken] [redacted:credentials] [redacted:credentials]"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_mask_cut_head_hides_a_secret_prefix_at_the_boundary() {
1008        let set = redactions(&[("A_TOKEN", "supersecretvalue")]);
1009
1010        assert_eq!(
1011            set.mask_cut_head("token=supersec"),
1012            "token=[redacted:A_TOKEN]"
1013        );
1014        assert_eq!(set.mask_cut_head("token=sup"), "token=sup");
1015        assert_eq!(set.mask_cut_head("nothing here"), "nothing here");
1016    }
1017
1018    #[test]
1019    fn test_mask_cut_tail_hides_a_secret_suffix_at_the_boundary() {
1020        let set = redactions(&[("A_TOKEN", "supersecretvalue")]);
1021
1022        assert_eq!(
1023            set.mask_cut_tail("retvalue and more"),
1024            "[redacted:A_TOKEN] and more"
1025        );
1026        assert_eq!(set.mask_cut_tail("lue and more"), "lue and more");
1027    }
1028}