1use std::borrow::Cow;
14
15use crate::config::LspServerConfig;
16use crate::error::{Error, ErrorChain};
17
18const MIN_SECRET_BYTES: usize = 8;
20
21const MIN_FRAGMENT_BYTES: usize = 4;
23
24const MAX_LABEL_CHARS: usize = 64;
26
27const SECRET_NAME_PATTERNS: [&str; 6] = ["TOKEN", "KEY", "SECRET", "PASSW", "CRED", "AUTH"];
30
31const BENIGN_SEGMENTS: [&str; 7] = [
34 "AUTHOR",
35 "AUTHORS",
36 "AUTHORITY",
37 "XAUTHORITY",
38 "KEYBOARD",
39 "KEYMAP",
40 "TOKENIZERS",
41];
42
43const BENIGN_NAMES: [&str; 1] = ["SSH_AUTH_SOCK"];
46
47fn 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
72pub 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#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct RedactedText(String);
99
100impl RedactedText {
101 pub(crate) fn fixed(text: &'static str) -> Self {
103 Self(text.to_owned())
104 }
105
106 #[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
119pub trait ServerText {
130 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 escaped: Vec<String>,
156}
157
158impl 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#[derive(Clone, Default, PartialEq, Eq)]
198pub struct Redactions(Vec<Secret>);
199
200impl 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 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 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 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 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 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 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 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 pub(crate) const fn is_empty(&self) -> bool {
345 self.0.is_empty()
346 }
347
348 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 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 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 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 fn redact(&self, text: &str) -> RedactedText {
412 RedactedText(self.apply(text).into_owned())
413 }
414
415 pub(crate) fn protocol_error(&self, message: std::fmt::Arguments<'_>) -> Error {
418 Error::LspProtocolError(self.redact(&message.to_string()))
419 }
420
421 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 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 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}