1use std::borrow::Cow;
14
15use crate::config::LspServerConfig;
16use crate::error::Error;
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.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 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(&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
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}