1use std::borrow::Cow;
15use std::fmt;
16
17use serde::Serialize;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ErrorCode {
26 CliFailed,
28 ApiError,
30 Timeout,
32 NotPermitted,
35 NeedsOperator,
38 UnsupportedVersion,
40 BackendUnavailable,
43 InvalidArgs,
45 UnsupportedPlatform,
47 NotFound,
49 Conflict,
52 RateLimited,
54 ResultTooLarge,
56 ConfirmationRequired,
58}
59
60impl ErrorCode {
61 pub const ALL: &'static [ErrorCode] = &[
63 Self::CliFailed,
64 Self::ApiError,
65 Self::Timeout,
66 Self::NotPermitted,
67 Self::NeedsOperator,
68 Self::UnsupportedVersion,
69 Self::BackendUnavailable,
70 Self::InvalidArgs,
71 Self::UnsupportedPlatform,
72 Self::NotFound,
73 Self::Conflict,
74 Self::RateLimited,
75 Self::ResultTooLarge,
76 Self::ConfirmationRequired,
77 ];
78
79 pub const fn as_str(self) -> &'static str {
80 match self {
81 Self::CliFailed => "cli_failed",
82 Self::ApiError => "api_error",
83 Self::Timeout => "timeout",
84 Self::NotPermitted => "not_permitted",
85 Self::NeedsOperator => "needs_operator",
86 Self::UnsupportedVersion => "unsupported_version",
87 Self::BackendUnavailable => "backend_unavailable",
88 Self::InvalidArgs => "invalid_args",
89 Self::UnsupportedPlatform => "unsupported_platform",
90 Self::NotFound => "not_found",
91 Self::Conflict => "conflict",
92 Self::RateLimited => "rate_limited",
93 Self::ResultTooLarge => "result_too_large",
94 Self::ConfirmationRequired => "confirmation_required",
95 }
96 }
97}
98
99impl fmt::Display for ErrorCode {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 f.write_str(self.as_str())
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct ToolError {
111 pub code: ErrorCode,
112 pub message: String,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub exit_code: Option<i32>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub stderr: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub status: Option<u16>,
122 #[serde(skip_serializing_if = "Option::is_none")]
125 pub hint: Option<String>,
126}
127
128impl ToolError {
129 pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
132 Self {
133 code,
134 message: redact(&message.into()).into_owned(),
135 exit_code: None,
136 stderr: None,
137 status: None,
138 hint: None,
139 }
140 }
141
142 #[must_use]
143 pub fn with_exit_code(mut self, exit_code: i32) -> Self {
144 self.exit_code = Some(exit_code);
145 self
146 }
147
148 #[must_use]
149 pub fn with_stderr(mut self, stderr: impl AsRef<str>) -> Self {
150 let trimmed = stderr.as_ref().trim();
151 if !trimmed.is_empty() {
152 self.stderr = Some(redact(trimmed).into_owned());
153 }
154 self
155 }
156
157 #[must_use]
158 pub fn with_status(mut self, status: u16) -> Self {
159 self.status = Some(status);
160 self
161 }
162
163 #[must_use]
164 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
165 self.hint = Some(redact(&hint.into()).into_owned());
166 self
167 }
168
169 pub fn cli_failed(argv0: &str, exit_code: Option<i32>, stderr: &str) -> Self {
174 let mut err = Self::new(
175 ErrorCode::CliFailed,
176 match exit_code {
177 Some(code) => format!("`{argv0}` exited with status {code}"),
178 None => format!("`{argv0}` was terminated before it exited"),
179 },
180 )
181 .with_stderr(stderr);
182 if let Some(code) = exit_code {
183 err = err.with_exit_code(code);
184 }
185 err
186 }
187
188 pub fn api_error(status: u16, body: &str) -> Self {
190 let body = body.trim();
191 let message = if body.is_empty() {
192 format!("the control plane returned HTTP {status}")
193 } else {
194 format!("the control plane returned HTTP {status}: {body}")
195 };
196 Self::new(ErrorCode::ApiError, message).with_status(status)
197 }
198
199 pub fn timeout(what: &str, seconds: u64, printed: &str) -> Self {
203 let printed = printed.trim();
204 let mut message = format!("{what} did not finish within {seconds}s");
205 if !printed.is_empty() {
206 message.push_str(", having said: ");
207 message.push_str(printed);
208 }
209 Self::new(ErrorCode::Timeout, message).with_hint(if printed.is_empty() {
210 "Raise the timeout, or narrow what the call asks for."
211 } else {
212 "The command was waiting on something. Act on what it printed, then call again."
213 })
214 }
215
216 pub fn not_permitted(tool: &str, needs: &str) -> Self {
220 Self::new(
221 ErrorCode::NotPermitted,
222 format!("`{tool}` is not available on this server"),
223 )
224 .with_hint(format!("Start the server with {needs} to enable it."))
225 }
226
227 pub fn needs_operator(stderr: &str) -> Self {
228 Self::new(
229 ErrorCode::NeedsOperator,
230 "the local node refused the command because this user is not its operator",
231 )
232 .with_stderr(stderr)
233 .with_hint(
234 "Run `tailscale set --operator=$USER` as an administrator, \
235 or run the server as the operator user.",
236 )
237 }
238
239 pub fn unsupported_version(tool: &str, needs: &str, found: &str) -> Self {
240 Self::new(
241 ErrorCode::UnsupportedVersion,
242 format!("`{tool}` needs Tailscale {needs} or newer; this node runs {found}"),
243 )
244 .with_hint("Upgrade Tailscale on this node.")
245 }
246
247 pub fn backend_unavailable(what: &str, why: &str) -> Self {
248 Self::new(
249 ErrorCode::BackendUnavailable,
250 format!("{what} is unavailable: {why}"),
251 )
252 }
253
254 pub fn invalid_args(message: impl Into<String>) -> Self {
255 Self::new(ErrorCode::InvalidArgs, message)
256 }
257
258 pub fn unsupported_platform(tool: &str, platform: &str) -> Self {
259 Self::new(
260 ErrorCode::UnsupportedPlatform,
261 format!("`{tool}` does not exist on {platform}"),
262 )
263 .with_hint("This command is available on other operating systems only.")
264 }
265
266 pub fn not_found(what: &str) -> Self {
267 Self::new(ErrorCode::NotFound, format!("{what} was not found")).with_status(404)
268 }
269
270 pub fn conflict(message: impl Into<String>) -> Self {
274 Self::new(ErrorCode::Conflict, message)
275 .with_status(409)
276 .with_hint(
277 "Re-read the resource to get its current version, then retry with that version.",
278 )
279 }
280
281 pub fn rate_limited(retry_after: Option<u64>) -> Self {
282 let err = Self::new(
283 ErrorCode::RateLimited,
284 "the control plane is rate-limiting this client",
285 )
286 .with_status(429);
287 match retry_after {
288 Some(secs) => err.with_hint(format!("Retry after {secs}s.")),
289 None => err.with_hint("Retry after a short delay."),
290 }
291 }
292
293 pub fn result_too_large(bytes: usize, cap: usize) -> Self {
294 Self::new(
295 ErrorCode::ResultTooLarge,
296 format!("the result is {bytes} bytes, over the {cap} byte cap"),
297 )
298 .with_hint(TOO_LARGE_HINT)
299 }
300
301 pub fn confirmation_required(tool: &str, consequence: &str) -> Self {
302 Self::new(
303 ErrorCode::ConfirmationRequired,
304 format!("`{tool}` {consequence}"),
305 )
306 .with_hint("Repeat the call with `confirm: true` if that is what you intend.")
307 }
308
309 pub fn to_value(&self) -> serde_json::Value {
313 serde_json::to_value(self).unwrap_or_else(
314 |_| serde_json::json!({ "code": self.code.as_str(), "message": self.message }),
315 )
316 }
317}
318
319const TOO_LARGE_HINT: &str =
323 "Narrow the request, or raise TAILSCALE_MCP_MAX_RESULT_BYTES on the server.";
324
325impl From<tailscale_rest::ApiError> for ToolError {
333 fn from(error: tailscale_rest::ApiError) -> Self {
334 use tailscale_rest::ApiError as Api;
335
336 match &error {
337 Api::Status {
341 status, message, ..
342 } if *status == 404 => Self::new(ErrorCode::NotFound, message.clone()).with_status(404),
343 Api::Status {
344 status, message, ..
345 } if *status == 409 => Self::conflict(message.clone()),
346
347 Api::Status {
353 status, message, ..
354 } if *status == 412 => Self::new(ErrorCode::Conflict, message.clone())
355 .with_status(412)
356 .with_hint(
357 "The document changed since it was read. Read it again with \
358 `tailnet_policy_get`, re-apply the change to what came back, and \
359 write it with the new `etag`.",
360 ),
361 Api::Status {
362 status,
363 retry_after,
364 ..
365 } if *status == 429 => Self::rate_limited(retry_after.map(|d| d.as_secs())),
366
367 Api::Status {
372 status, message, ..
373 } if matches!(status, 401 | 403) => Self::api_error(*status, message).with_hint(
374 "Check that the control-plane credential is current and carries the \
375 scopes this call needs.",
376 ),
377 Api::Status {
378 status, message, ..
379 } => Self::api_error(*status, message),
380
381 Api::Transport { .. } => {
384 Self::backend_unavailable("the control plane", &error.to_string())
385 }
386
387 Api::Timeout { request, budget } => Self::timeout(request, budget.as_secs(), ""),
388
389 Api::TooLarge { .. } => {
393 Self::new(ErrorCode::ResultTooLarge, error.to_string()).with_hint(TOO_LARGE_HINT)
394 }
395
396 Api::Malformed { .. } => Self::new(ErrorCode::ApiError, error.to_string()),
399
400 Api::Token(_) | Api::JwtFile { .. } | Api::Config(_) => {
403 Self::backend_unavailable("the tailnet surface", &error.to_string())
404 }
405 }
406 }
407}
408
409impl fmt::Display for ToolError {
410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411 write!(f, "{}: {}", self.code, self.message)
412 }
413}
414
415impl std::error::Error for ToolError {}
416
417pub type ToolResult<T> = Result<T, ToolError>;
419
420pub const REDACTED: &str = "[redacted]";
426
427pub fn redact(input: &str) -> Cow<'_, str> {
438 let mut out: Option<String> = None;
439 let bytes = input.as_bytes();
440 let mut i = 0;
441 let mut copied = 0;
442
443 while i < bytes.len() {
444 if !input.is_char_boundary(i) {
450 i += 1;
451 continue;
452 }
453 let hit = secret_at(input, i);
454 match hit {
455 Some((keep, end)) => {
456 let out = out.get_or_insert_with(String::new);
457 out.push_str(&input[copied..i + keep]);
458 out.push_str(REDACTED);
459 copied = end;
460 i = end;
461 }
462 None => i += 1,
463 }
464 }
465
466 match out {
467 Some(mut out) => {
468 out.push_str(&input[copied..]);
469 Cow::Owned(out)
470 }
471 None => Cow::Borrowed(input),
472 }
473}
474
475fn secret_at(input: &str, i: usize) -> Option<(usize, usize)> {
478 if i > 0 && is_token_byte(input.as_bytes()[i - 1]) {
481 return None;
482 }
483 let rest = &input[i..];
484
485 for prefix in ["tskey-auth-", "tskey-api-", "tskey-client-", "tskey-"] {
488 if let Some(tail) = rest.strip_prefix(prefix) {
489 let len = token_len(tail);
490 if len == 0 {
492 continue;
493 }
494 return Some((prefix.len(), i + prefix.len() + len));
495 }
496 }
497
498 for prefix in ["privkey:", "nlpriv:"] {
503 if let Some(tail) = rest.strip_prefix(prefix) {
504 let len = token_len(tail);
505 if len == 0 {
506 continue;
507 }
508 return Some((prefix.len(), i + prefix.len() + len));
509 }
510 }
511
512 for prefix in ["Bearer ", "bearer "] {
514 if let Some(tail) = rest.strip_prefix(prefix) {
515 let len = token_len(tail);
516 if len == 0 {
517 continue;
518 }
519 return Some((prefix.len(), i + prefix.len() + len));
520 }
521 }
522
523 None
524}
525
526fn token_len(s: &str) -> usize {
528 s.bytes().take_while(|b| is_token_byte(*b)).count()
529}
530
531const fn is_token_byte(b: u8) -> bool {
532 b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
533}
534
535#[derive(Debug, Clone, Default)]
547pub struct Redactor {
548 secrets: Vec<String>,
549}
550
551impl Redactor {
552 pub fn new() -> Self {
553 Self::default()
554 }
555
556 pub fn add_secret(&mut self, secret: impl Into<String>) {
559 let secret = secret.into();
560 if secret.len() >= 8 && !self.secrets.contains(&secret) {
561 self.secrets.push(secret);
562 }
563 }
564
565 #[must_use]
566 pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
567 self.add_secret(secret);
568 self
569 }
570
571 #[must_use]
579 pub fn for_credentials(credentials: Option<&tailscale_rest::Credentials>) -> Self {
580 let mut redactor = Self::new();
581 match credentials {
582 Some(tailscale_rest::Credentials::ApiKey(key)) => redactor.add_secret(key.expose()),
583 Some(tailscale_rest::Credentials::OauthClient { client_secret, .. }) => {
584 redactor.add_secret(client_secret.expose());
585 }
586 Some(tailscale_rest::Credentials::Federated { .. }) | None => {}
587 }
588 redactor
589 }
590
591 pub fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> {
593 let mut current = redact(input);
594 for secret in &self.secrets {
595 if current.contains(secret.as_str()) {
596 current = Cow::Owned(current.replace(secret.as_str(), REDACTED));
597 }
598 }
599 current
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 use std::time::Duration;
608
609 use tailscale_rest::ApiError;
610
611 fn answered(status: u16, message: &str) -> ApiError {
614 ApiError::Status {
615 request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
616 status,
617 message: message.to_owned(),
618 retry_after: None,
619 }
620 }
621
622 #[test]
623 fn a_status_the_model_has_a_code_for_is_told_in_that_code() {
624 for (status, expected) in [
625 (404, ErrorCode::NotFound),
626 (409, ErrorCode::Conflict),
627 (429, ErrorCode::RateLimited),
628 ] {
629 let error = ToolError::from(answered(status, "no such device"));
630 assert_eq!(error.code, expected, "HTTP {status}");
631 assert_eq!(error.status, Some(status));
632 }
633 }
634
635 #[test]
636 fn a_status_the_model_has_no_code_for_keeps_its_number() {
637 let error = ToolError::from(answered(422, "hostname is already taken"));
638 assert_eq!(error.code, ErrorCode::ApiError);
639 assert_eq!(error.status, Some(422));
640 assert!(
641 error.message.contains("hostname is already taken"),
642 "the control plane's own words should survive: {}",
643 error.message
644 );
645 }
646
647 #[test]
648 fn the_servers_backoff_becomes_the_wait_the_caller_is_told_about() {
649 let error = ToolError::from(ApiError::Status {
650 request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
651 status: 429,
652 message: "slow down".to_owned(),
653 retry_after: Some(Duration::from_secs(30)),
654 });
655 assert_eq!(error.hint.as_deref(), Some("Retry after 30s."));
656 }
657
658 #[test]
659 fn a_refused_credential_is_not_reported_as_a_missing_switch() {
660 for status in [401, 403] {
664 let error = ToolError::from(answered(status, "invalid key"));
665 assert_eq!(error.code, ErrorCode::ApiError, "HTTP {status}");
666 let hint = error.hint.as_deref().unwrap_or_default();
667 assert!(
668 hint.contains("credential") && hint.contains("scopes"),
669 "HTTP {status} should point at the credential: {hint}"
670 );
671 assert!(
672 !hint.contains("--"),
673 "HTTP {status} should not name a server flag: {hint}"
674 );
675 }
676 }
677
678 #[test]
679 fn an_answer_over_the_cap_says_so_with_the_narrowing_available() {
680 let error = ToolError::from(ApiError::TooLarge {
681 request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
682 cap: 1024,
683 });
684 assert_eq!(error.code, ErrorCode::ResultTooLarge);
685 assert!(error.message.contains("1024"), "{}", error.message);
686 assert_eq!(error.hint.as_deref(), Some(TOO_LARGE_HINT));
687 assert_eq!(
689 error.hint,
690 ToolError::result_too_large(2048, 1024).hint,
691 "one cap should not have two answers"
692 );
693 }
694
695 #[test]
696 fn a_credential_that_could_not_be_used_is_the_surface_being_unavailable() {
697 for error in [
700 ApiError::Token("the token endpoint answered with 400".to_owned()),
701 ApiError::JwtFile {
702 path: std::path::PathBuf::from("/run/identity.jwt"),
703 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
704 },
705 ApiError::Config("`http://elsewhere` is neither https nor loopback".to_owned()),
706 ] {
707 let reported = ToolError::from(error);
708 assert_eq!(reported.code, ErrorCode::BackendUnavailable);
709 assert!(reported.status.is_none());
710 }
711 }
712
713 #[test]
714 fn a_body_that_could_not_be_read_is_the_control_plane_being_wrong() {
715 let source = serde_json::from_str::<i32>("not a number").expect_err("this does not parse");
716 let error = ToolError::from(ApiError::Malformed {
717 request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
718 source,
719 });
720 assert_eq!(error.code, ErrorCode::ApiError);
721 assert!(error.hint.is_none());
723 }
724
725 #[test]
726 fn a_call_that_ran_out_of_budget_is_a_timeout_naming_the_budget() {
727 let error = ToolError::from(ApiError::Timeout {
728 request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
729 budget: Duration::from_secs(30),
730 });
731 assert_eq!(error.code, ErrorCode::Timeout);
732 assert!(error.message.contains("30s"), "{}", error.message);
733 }
734
735 #[test]
736 fn every_code_has_a_distinct_stable_name() {
737 let mut names: Vec<&str> = ErrorCode::ALL.iter().map(|c| c.as_str()).collect();
738 assert_eq!(names.len(), 14, "the code vocabulary is fixed at fourteen");
739 names.sort_unstable();
740 let before = names.len();
741 names.dedup();
742 assert_eq!(before, names.len(), "duplicate error code name");
743 for name in names {
744 assert!(
745 name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
746 "{name} is not snake_case"
747 );
748 }
749 }
750
751 #[test]
752 fn codes_serialise_as_their_documented_strings() {
753 for code in ErrorCode::ALL {
754 let json = serde_json::to_string(code).expect("codes serialise");
755 assert_eq!(json, format!("\"{}\"", code.as_str()));
756 }
757 }
758
759 #[test]
760 fn the_codes_an_operator_can_act_on_carry_a_hint() {
761 let with_hints = [
762 ToolError::not_permitted("tailscale_up", "--allow-write"),
763 ToolError::unsupported_version("tailscale_x", "1.80", "1.70"),
764 ToolError::unsupported_platform("tailscale_systray", "macos"),
765 ToolError::result_too_large(2_000_000, 1_048_576),
766 ToolError::conflict("the policy file changed"),
767 ToolError::confirmation_required("tailscale_down", "disconnects this node"),
768 ToolError::needs_operator(""),
769 ToolError::rate_limited(Some(30)),
770 ToolError::timeout("tailscale ping", 30, ""),
771 ];
772 for err in with_hints {
773 assert!(err.hint.is_some(), "{} should carry a hint", err.code);
774 }
775 }
776
777 #[test]
778 fn a_command_that_hung_reports_what_it_was_waiting_on() {
779 let silent = ToolError::timeout("tailscale funnel 3000", 30, " ");
780 assert_eq!(
781 silent.message,
782 "tailscale funnel 3000 did not finish within 30s"
783 );
784
785 let spoke = ToolError::timeout(
786 "tailscale funnel 3000",
787 30,
788 "Funnel is not enabled on your tailnet.\nTo enable, visit:\n\n\thttps://login.example.com/f/funnel\n",
789 );
790 assert!(
791 spoke.message.contains("https://login.example.com/f/funnel"),
792 "the caller cannot act on what it was not told: {}",
793 spoke.message
794 );
795 assert_ne!(
796 spoke.hint, silent.hint,
797 "a command that explained itself needs different advice from one that did not"
798 );
799 }
800
801 #[test]
802 fn absent_fields_are_omitted_from_the_wire_form() {
803 let err = ToolError::invalid_args("port must be between 1 and 65535");
804 let json = serde_json::to_value(&err).expect("errors serialise");
805 let obj = json.as_object().expect("an object");
806 assert_eq!(obj.len(), 2, "only code and message: {obj:?}");
807 assert_eq!(obj["code"], "invalid_args");
808 }
809
810 #[test]
811 fn key_shaped_values_are_removed_from_every_field() {
812 let err = ToolError::cli_failed(
813 "tailscale up",
814 Some(1),
815 "invalid key: tskey-auth-example1CNTRL-secretpart",
816 );
817 let stderr = err.stderr.expect("stderr is captured");
818 assert!(!stderr.contains("secretpart"), "{stderr}");
819 assert!(stderr.contains("tskey-auth-[redacted]"), "{stderr}");
820 }
821
822 #[test]
823 fn a_command_killed_by_a_signal_reports_no_exit_code() {
824 let err = ToolError::cli_failed("tailscale up", None, "");
825 assert_eq!(err.exit_code, None);
826 assert!(err.message.contains("terminated"), "{}", err.message);
827 }
828
829 #[test]
830 fn each_key_shape_is_recognised() {
831 for input in [
832 "tskey-auth-example-def456",
833 "tskey-api-example-def456",
834 "tskey-client-example-def456",
835 "tskey-exampledef456",
836 ] {
837 let out = redact(input);
838 assert!(!out.contains("def456"), "{input} -> {out}");
839 assert!(out.ends_with(REDACTED), "{input} -> {out}");
840 }
841 }
842
843 #[test]
844 fn bearer_tokens_are_removed() {
845 let out = redact("Authorization: Bearer tskey-api-example-def");
846 assert_eq!(out, "Authorization: Bearer [redacted]");
847 }
848
849 #[test]
850 fn several_secrets_in_one_string_are_all_removed() {
851 let out = redact("old tskey-auth-example-1 new tskey-auth-example-2 done");
852 assert_eq!(
853 out,
854 "old tskey-auth-[redacted] new tskey-auth-[redacted] done"
855 );
856 }
857
858 #[test]
859 fn prose_that_merely_mentions_a_key_survives() {
860 assert_eq!(
862 redact("pass a tskey- prefixed value"),
863 "pass a tskey- prefixed value"
864 );
865 assert_eq!(redact("see mytskey-auth-notes"), "see mytskey-auth-notes");
867 }
868
869 #[test]
870 fn clean_strings_are_borrowed_not_copied() {
871 assert!(matches!(redact("nothing to see here"), Cow::Borrowed(_)));
872 }
873
874 #[test]
875 fn text_that_is_not_ascii_passes_through_rather_than_panicking() {
876 let prose = "`provider` is one of falcon, intune — none of them is `wizardry`";
881 assert_eq!(redact(prose), prose);
882
883 let with_key = format!("{prose}, and the key tskey-auth-example1CNTRL-secret is stale");
885 let cleaned = redact(&with_key);
886 assert!(cleaned.contains("tskey-auth-[redacted]"), "{cleaned}");
887 assert!(!cleaned.contains("secret is stale"), "{cleaned}");
888 assert!(cleaned.contains("—"), "the prose survives: {cleaned}");
889 }
890
891 #[test]
892 fn the_redactor_also_scrubs_values_it_was_given() {
893 let r = Redactor::new().with_secret("an-oauth-client-secret-value");
894 let out = r.apply("failed with an-oauth-client-secret-value and tskey-api-example-b");
895 assert_eq!(
896 out,
897 format!("failed with {REDACTED} and tskey-api-{REDACTED}")
898 );
899 }
900
901 #[test]
902 fn the_redactor_ignores_values_too_short_to_be_secrets() {
903 let r = Redactor::new().with_secret("abc");
904 assert_eq!(
905 r.apply("abc is a common substring"),
906 "abc is a common substring"
907 );
908 }
909
910 #[test]
911 fn private_key_material_is_removed_and_the_public_halves_are_not() {
912 let printed = "nodekey:1111 privkey:aaaabbbbcccc tlpub:2222 nlpriv:ddddeeeeffff";
915 let left = redact(printed);
916 assert!(left.contains("nodekey:1111"), "{left}");
917 assert!(left.contains("tlpub:2222"), "{left}");
918 assert!(!left.contains("aaaabbbbcccc"), "{left}");
919 assert!(!left.contains("ddddeeeeffff"), "{left}");
920 assert!(left.contains("privkey:[redacted]"), "{left}");
922 assert!(left.contains("nlpriv:[redacted]"), "{left}");
923
924 assert_eq!(
926 redact("privkey: is a field name"),
927 "privkey: is a field name"
928 );
929 }
930}