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, ErrorChain};
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.to_string())),
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(&ErrorChain(&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    use crate::lsp::JsonRpcError;
537
538    fn redactions(pairs: &[(&str, &str)]) -> Redactions {
539        Redactions::new(
540            pairs
541                .iter()
542                .map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
543        )
544    }
545
546    fn server_config() -> LspServerConfig {
547        let mut config = LspServerConfig::rust_analyzer();
548        config.env = crate::config::ServerEnv::default();
549        config.args = crate::config::ServerArgs::default();
550        config.initialization_options = None;
551        config
552    }
553
554    #[test]
555    fn test_for_servers_covers_every_configured_server_over_one_environment() {
556        let mut first = server_config();
557        first
558            .env
559            .insert(
560                crate::config::EnvKey::from_static("FIRST_TOKEN"),
561                crate::config::EnvValue::from_static("first-secret-value"),
562            )
563            .unwrap();
564        let mut second = server_config();
565        second.args = crate::config::ServerArgs::new(["--api-key=second-secret-value"]).unwrap();
566
567        let set = Redactions::for_servers(
568            [&first, &second],
569            [(
570                "GITHUB_TOKEN".to_owned(),
571                "inherited-secret-value".to_owned(),
572            )],
573        );
574
575        for text in [
576            "first-secret-value",
577            "second-secret-value",
578            "inherited-secret-value",
579        ] {
580            assert!(set.apply(text).contains("[redacted:"), "{text}");
581        }
582    }
583
584    #[test]
585    fn test_debug_never_prints_a_secret_value() {
586        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
587        let other_value = redactions(&[("API_TOKEN", "zzzz_ijklmnop")]);
588
589        let printed = format!("{set:?} {:?}", set.0[0]);
590        let printed_other = format!("{other_value:?} {:?}", other_value.0[0]);
591
592        assert!(printed.eq(&printed_other));
593        assert!(printed.contains("secrets: 1"));
594    }
595
596    #[test]
597    fn test_new_drops_value_contained_in_a_marker() {
598        let set = redactions(&[("API_TOKEN", "redacted:API_TOKEN"), ("OTHER_KEY", SECRET)]);
599        assert_eq!(set.apply("redacted:API_TOKEN"), "redacted:API_TOKEN");
600        assert_eq!(set.apply(SECRET), "[redacted:OTHER_KEY]");
601    }
602
603    #[test]
604    fn test_union_merges_sets_and_reruns_the_marker_filter() {
605        let a = redactions(&[("A_TOKEN", "alpha-secret-111")]);
606        let b = redactions(&[("B_TOKEN", "A_TOKEN]-beta-2")]);
607        let merged = Redactions::union([&a, &b, &Redactions::default()]);
608        assert_eq!(merged.apply("alpha-secret-111"), "[redacted:A_TOKEN]");
609        assert_eq!(merged.apply("A_TOKEN]-beta-2"), "[redacted:B_TOKEN]");
610        assert!(Redactions::union([&Redactions::default()]).is_empty());
611        assert_eq!(Redactions::union([&a]), a);
612    }
613
614    fn captured(level: tracing_subscriber::filter::LevelFilter, run: impl FnOnce()) -> Vec<String> {
615        use tracing_subscriber::prelude::*;
616
617        let logs = crate::test_lsp::CapturedLogs::default();
618        let subscriber = tracing_subscriber::registry()
619            .with(level)
620            .with(logs.clone());
621        tracing::subscriber::with_default(subscriber, run);
622        logs.messages()
623    }
624
625    #[test]
626    fn test_note_payload_logs_label_only_at_debug_and_is_silent_at_info() {
627        use tracing_subscriber::filter::LevelFilter;
628
629        let set = redactions(&[("API_TOKEN", SECRET)]);
630
631        let debug = captured(LevelFilter::DEBUG, || {
632            set.note_payload(&format!("let k = {SECRET};"));
633            set.note_payload("clean text");
634        });
635        assert_eq!(debug.len(), 1, "{debug:?}");
636        assert!(debug[0].contains("[redacted:API_TOKEN]"), "{debug:?}");
637        assert!(!debug[0].contains(SECRET), "{debug:?}");
638
639        let info = captured(LevelFilter::INFO, || {
640            set.note_payload(&format!("let k = {SECRET};"));
641        });
642        assert!(info.is_empty(), "{info:?}");
643    }
644
645    #[test]
646    fn test_new_warns_with_label_only_when_a_value_is_dropped() {
647        use tracing_subscriber::filter::LevelFilter;
648
649        let warnings = captured(LevelFilter::WARN, || {
650            let _ = redactions(&[("API_TOKEN", "redacted:API_TOKEN")]);
651        });
652        assert_eq!(warnings.len(), 1, "{warnings:?}");
653        assert!(warnings[0].contains("[redacted:API_TOKEN]"), "{warnings:?}");
654    }
655
656    #[test]
657    fn test_for_server_collects_secret_named_settings_leaves() {
658        let mut config = server_config();
659        config.settings = Some(
660            serde_json::from_value(serde_json::json!({
661                "tool.apiToken": "settings-secret-123",
662                "tool.theme": "plain-visible-value"
663            }))
664            .unwrap(),
665        );
666
667        let set = Redactions::for_server(&config, []);
668
669        assert_eq!(
670            set.apply("settings-secret-123 plain-visible-value"),
671            "[redacted:apiToken] plain-visible-value"
672        );
673    }
674
675    #[test]
676    fn test_apply_finds_json_and_debug_escaped_spellings() {
677        let set = redactions(&[("API_TOKEN", "pa\"ss\\word-12345")]);
678        let frame = serde_json::json!({"token": "pa\"ss\\word-12345"}).to_string();
679        let debug = format!("{:?}", ["pa\"ss\\word-12345"]);
680
681        for text in [frame, debug, "raw pa\"ss\\word-12345".to_owned()] {
682            let cleaned = set.apply(&text);
683            assert!(cleaned.contains("[redacted:API_TOKEN]"), "{cleaned}");
684            assert!(!cleaned.contains("word-12345"), "{cleaned}");
685        }
686    }
687
688    const SECRET: &str = "SuperSecretValue123";
689
690    fn secret_diagnostic() -> lsp_types::Diagnostic {
691        serde_json::from_value(serde_json::json!({
692            "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}},
693            "code": "E-SuperSecretValue123",
694            "codeDescription": {"href": "https://example.com/SuperSecretValue123"},
695            "source": "lint-SuperSecretValue123",
696            "message": {"kind": "markdown", "value": "see `SuperSecretValue123`"},
697            "relatedInformation": [{
698                "location": {
699                    "uri": "file:///SuperSecretValue123/a.rs",
700                    "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}
701                },
702                "message": "defined with SuperSecretValue123"
703            }],
704            "data": {"SuperSecretValue123": ["x", {"nested": "has SuperSecretValue123"}], "n": 3}
705        }))
706        .unwrap()
707    }
708
709    #[test]
710    fn test_redact_diagnostic_covers_text_fields_and_data_values() {
711        let set = redactions(&[("API_TOKEN", SECRET)]);
712        let mut diagnostic = secret_diagnostic();
713
714        set.redact_diagnostic(&mut diagnostic);
715
716        let lsp_types::Message::MarkupContent(content) = &diagnostic.message else {
717            panic!("expected markup content");
718        };
719        assert_eq!(content.value, "see `[redacted:API_TOKEN]`");
720        assert_eq!(
721            diagnostic.source.as_deref(),
722            Some("lint-[redacted:API_TOKEN]")
723        );
724        assert_eq!(
725            diagnostic.code,
726            Some(lsp_types::Code::String("E-[redacted:API_TOKEN]".to_owned()))
727        );
728        let related = diagnostic.related_information.as_ref().unwrap();
729        assert_eq!(related[0].message, "defined with [redacted:API_TOKEN]");
730        let data = diagnostic.data.as_ref().unwrap();
731        assert_eq!(data[SECRET][1]["nested"], "has [redacted:API_TOKEN]");
732        assert_eq!(data["n"], 3);
733    }
734
735    #[test]
736    fn test_redact_diagnostic_keeps_uris() {
737        let set = redactions(&[("API_TOKEN", SECRET)]);
738        let mut diagnostic = secret_diagnostic();
739
740        set.redact_diagnostic(&mut diagnostic);
741
742        let related = diagnostic.related_information.as_ref().unwrap();
743        assert_eq!(
744            AsRef::<str>::as_ref(&related[0].location.uri),
745            "file:///SuperSecretValue123/a.rs"
746        );
747        let href = &diagnostic.code_description.as_ref().unwrap().href;
748        assert_eq!(
749            AsRef::<str>::as_ref(href),
750            "https://example.com/SuperSecretValue123"
751        );
752    }
753
754    #[test]
755    fn test_redact_progress_covers_title_and_message() {
756        let set = redactions(&[("API_TOKEN", SECRET)]);
757        let mut value = serde_json::json!({
758            "kind": "begin", "title": "index SuperSecretValue123", "message": "at SuperSecretValue123"
759        });
760
761        set.redact_progress(&mut value);
762
763        assert_eq!(value["title"], "index [redacted:API_TOKEN]");
764        assert_eq!(value["message"], "at [redacted:API_TOKEN]");
765    }
766
767    #[test]
768    fn test_redact_in_place_leaves_clean_text_untouched() {
769        let set = redactions(&[("API_TOKEN", SECRET)]);
770        let mut clean = String::from("nothing here");
771        let mut dirty = format!("x {SECRET}");
772
773        set.redact_in_place(&mut clean);
774        set.redact_in_place(&mut dirty);
775
776        assert_eq!(clean, "nothing here");
777        assert_eq!(dirty, "x [redacted:API_TOKEN]");
778        assert!(!set.is_empty());
779        assert!(Redactions::default().is_empty());
780    }
781
782    #[test]
783    fn test_protocol_error_masks_secrets_and_keeps_other_text() {
784        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
785
786        let error = set.protocol_error(format_args!("bad value ghp_abcdefgh in {}", "frame"));
787
788        assert_eq!(
789            error.to_string(),
790            "LSP protocol error: bad value [redacted:API_TOKEN] in frame"
791        );
792    }
793
794    #[test]
795    fn test_sanitize_error_redacts_a_secret_and_keeps_a_clean_error() {
796        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
797        let leaking = Error::LspServerError(JsonRpcError {
798            code: -32603,
799            message: "bad token ghp_abcdefgh".into(),
800            data: None,
801        });
802
803        let sanitized = set.sanitize_error(leaking);
804
805        assert!(
806            !sanitized.to_string().contains("ghp_abcdefgh"),
807            "{sanitized}"
808        );
809        assert!(sanitized.to_string().contains("[redacted:API_TOKEN]"));
810        assert_matches!(
811            set.sanitize_error(Error::ServerTerminated),
812            Error::ServerTerminated
813        );
814    }
815
816    #[test]
817    fn test_sanitize_error_redacts_a_secret_carried_only_by_the_source() {
818        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
819        let leaking = Error::ServerSpawnFailed {
820            command: crate::config::ServerCommand::from_static("rust-analyzer"),
821            source: std::io::Error::other("bad token ghp_abcdefgh"),
822        };
823
824        let sanitized = set.sanitize_error(leaking);
825
826        let text = sanitized.to_string();
827        assert_matches!(sanitized, Error::LspProtocolError(_));
828        assert!(!text.contains("ghp_abcdefgh"), "{text}");
829        assert!(text.contains("[redacted:API_TOKEN]"), "{text}");
830    }
831
832    #[test]
833    fn test_apply_borrows_text_without_secrets() {
834        let set = redactions(&[("API_TOKEN", "ghp_abcdefgh")]);
835
836        assert_matches!(set.apply("nothing here"), Cow::Borrowed(_));
837    }
838
839    #[test]
840    fn test_secret_name_matches_case_insensitively() {
841        for name in [
842            "GITHUB_TOKEN",
843            "api_key",
844            "MySecret",
845            "DB_PASSWORD",
846            "AWS_CREDENTIALS",
847            "Authorization",
848        ] {
849            assert!(is_secret_name(name), "{name}");
850        }
851        for name in ["RUSTUP_TOOLCHAIN", "JAVA_HOME", "GOFLAGS", "PATH"] {
852            assert!(!is_secret_name(name), "{name}");
853        }
854    }
855
856    #[test]
857    fn test_secret_name_stays_fail_closed_across_segment_boundaries() {
858        for name in [
859            "AUTHORIZATION",
860            "HTTP_PROXY_AUTHORIZATION",
861            "http.proxyAuthorization",
862            "GIT_AUTHOR_TOKEN",
863            "OPENAI_API_KEY",
864            "apiKey",
865            "APIKEY",
866            "AUTHTOKEN",
867            "db_passwd",
868            "passWord",
869            "passWd",
870            "dbPassWord",
871            "myToKen",
872            "XMLHttpKey",
873            "SSH_AUTH_SOCK_TOKEN",
874            "AUTHORS_KEY",
875            "tokenizers_apikey",
876            "ключ_TOKEN",
877        ] {
878            assert!(is_secret_name(name), "{name}");
879        }
880    }
881
882    #[test]
883    fn test_secret_name_skips_well_known_benign_names() {
884        for name in [
885            "GIT_AUTHOR_NAME",
886            "GIT_AUTHOR_EMAIL",
887            "SSH_AUTH_SOCK",
888            "ssh_auth_sock",
889            "XAUTHORITY",
890            "XAuthority",
891            "TOKENIZERS_PARALLELISM",
892            "KEYBOARD_LAYOUT",
893            "keymap",
894        ] {
895            assert!(!is_secret_name(name), "{name}");
896        }
897    }
898
899    #[test]
900    fn test_name_segments_split_on_separators_and_camel_case() {
901        let segments = |name| name_segments(name).collect::<Vec<_>>();
902        assert_eq!(
903            segments("GIT_AUTHOR-NAME.x"),
904            ["GIT", "AUTHOR", "NAME", "x"]
905        );
906        assert_eq!(segments("proxyAuthorization"), ["proxy", "Authorization"]);
907        assert_eq!(segments("XMLHttpKey"), ["XML", "Http", "Key"]);
908        assert_eq!(segments("v2Key"), ["v2", "Key"]);
909        assert_eq!(segments(""), [""]);
910    }
911
912    #[test]
913    fn test_apply_labels_the_replacement_and_skips_short_values() {
914        let set = redactions(&[("GITHUB_TOKEN", "ghp_abcdefgh"), ("SHORT_KEY", "abc")]);
915
916        assert_eq!(
917            set.apply("t=ghp_abcdefgh k=abc"),
918            "t=[redacted:GITHUB_TOKEN] k=abc"
919        );
920    }
921
922    #[test]
923    fn test_apply_replaces_the_longer_overlapping_secret_whole() {
924        let set = redactions(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]);
925
926        let text = set.apply("v=abcdefghXYZ12345");
927
928        assert_eq!(text, "v=[redacted:B_KEY]");
929    }
930
931    #[test]
932    fn test_new_deduplicates_equal_values() {
933        let set = redactions(&[("A_TOKEN", "samesecret"), ("B_TOKEN", "samesecret")]);
934
935        assert_eq!(set.0.len(), 1);
936    }
937
938    #[test]
939    fn test_label_is_reduced_to_safe_characters() {
940        let set = redactions(&[("bad]\n[label", "secretvalue")]);
941
942        assert_eq!(set.apply("secretvalue"), "[redacted:bad___label]");
943    }
944
945    #[test]
946    fn test_for_server_takes_secret_named_env_only() {
947        let mut config = server_config();
948        config.env = crate::config::ServerEnv::from_entries(
949            [
950                (
951                    crate::config::EnvKey::from_static("API_TOKEN"),
952                    crate::config::EnvValue::from_static("tok-1234567890"),
953                ),
954                (
955                    crate::config::EnvKey::from_static("RUSTUP_TOOLCHAIN"),
956                    crate::config::EnvValue::from_static("nightly-2024-01-01"),
957                ),
958            ],
959            crate::config::HostOs::CURRENT,
960        )
961        .unwrap();
962
963        let set = Redactions::for_server(&config, []);
964
965        let text = set.apply("tok-1234567890 nightly-2024-01-01");
966        assert_eq!(text, "[redacted:API_TOKEN] nightly-2024-01-01");
967    }
968
969    #[test]
970    fn test_for_server_takes_inherited_secret_env() {
971        let set = Redactions::for_server(
972            &server_config(),
973            [
974                ("GITHUB_TOKEN".to_string(), "ghp_inherited123".to_string()),
975                ("HOME".to_string(), "/home/someone-long".to_string()),
976            ],
977        );
978
979        assert_eq!(
980            set.apply("ghp_inherited123 /home/someone-long"),
981            "[redacted:GITHUB_TOKEN] /home/someone-long"
982        );
983    }
984
985    #[test]
986    fn test_for_server_takes_secret_flag_values_in_both_forms() {
987        let mut config = server_config();
988        config.args = crate::config::ServerArgs::try_from(
989            [
990                "--stdio",
991                "--api-key=key-value-1",
992                "--auth-token",
993                "tok-value-2",
994                "--verbose",
995                "--log-level=trace-level",
996            ]
997            .map(String::from)
998            .to_vec(),
999        )
1000        .unwrap();
1001
1002        let set = Redactions::for_server(&config, []);
1003
1004        assert_eq!(
1005            set.apply("key-value-1 tok-value-2 trace-level"),
1006            "[redacted:api-key] [redacted:auth-token] trace-level"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_for_server_takes_string_leaves_under_secret_json_keys() {
1012        let mut config = server_config();
1013        config.initialization_options = Some(serde_json::json!({
1014            "server": {"name": "long-server-name", "apiToken": "init-token-value"},
1015            "credentials": ["first-credential", {"inner": "second-credential"}],
1016            "retries": 3
1017        }));
1018
1019        let set = Redactions::for_server(&config, []);
1020
1021        let text =
1022            set.apply("long-server-name init-token-value first-credential second-credential");
1023        assert_eq!(
1024            text,
1025            "long-server-name [redacted:apiToken] [redacted:credentials] [redacted:credentials]"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_mask_cut_head_hides_a_secret_prefix_at_the_boundary() {
1031        let set = redactions(&[("A_TOKEN", "supersecretvalue")]);
1032
1033        assert_eq!(
1034            set.mask_cut_head("token=supersec"),
1035            "token=[redacted:A_TOKEN]"
1036        );
1037        assert_eq!(set.mask_cut_head("token=sup"), "token=sup");
1038        assert_eq!(set.mask_cut_head("nothing here"), "nothing here");
1039    }
1040
1041    #[test]
1042    fn test_mask_cut_tail_hides_a_secret_suffix_at_the_boundary() {
1043        let set = redactions(&[("A_TOKEN", "supersecretvalue")]);
1044
1045        assert_eq!(
1046            set.mask_cut_tail("retvalue and more"),
1047            "[redacted:A_TOKEN] and more"
1048        );
1049        assert_eq!(set.mask_cut_tail("lue and more"), "lue and more");
1050    }
1051}