tailscale-mcp 1.3.2

MCP server for Tailscale: the local node through the CLI, the tailnet through the control-plane API
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
//! The tool-level error model, and the redaction every error passes through.
//!
//! Two rules shape this module.
//!
//! An operation that runs and fails is a *result*, not a protocol error: the
//! model asked a sensible question and deserves a structured answer it can act
//! on. Protocol errors are reserved for requests that were malformed before any
//! work began — an unknown tool, arguments that do not fit the schema.
//!
//! Every error path can carry a secret, because the thing that failed was
//! usually handed one. Redaction therefore lives here, on the type, rather than
//! at each call site where it would eventually be forgotten.

use std::borrow::Cow;
use std::fmt;

use serde::Serialize;

/// The fixed vocabulary of failures a tool can report.
///
/// Fixed is the operative word: a client can branch on these, so a new variant
/// is a compatibility question and not a detail. The text of each is stable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
    /// The `tailscale` binary ran and exited non-zero.
    CliFailed,
    /// The control plane returned a status we do not model more precisely.
    ApiError,
    /// The operation did not finish inside its budget.
    Timeout,
    /// The tool exists but this server was not started with the tier or
    /// toolset that permits it.
    NotPermitted,
    /// The local node refuses the command because the caller is not its
    /// configured operator.
    NeedsOperator,
    /// The installed `tailscale` is older than the command requires.
    UnsupportedVersion,
    /// The backend a tool needs is absent: no binary on the path, no
    /// credential configured, or a daemon that is not answering.
    BackendUnavailable,
    /// Arguments parsed but do not describe a workable request.
    InvalidArgs,
    /// The command does not exist on this operating system.
    UnsupportedPlatform,
    /// The target of the operation does not exist.
    NotFound,
    /// The state changed underneath us: a stale ETag, or a resource that
    /// already exists.
    Conflict,
    /// The control plane asked us to slow down.
    RateLimited,
    /// The result would exceed the configured size cap.
    ResultTooLarge,
    /// The operation is one the caller must state intent for.
    ConfirmationRequired,
}

impl ErrorCode {
    /// Every code, used by the test that proves each one is reachable.
    pub const ALL: &'static [ErrorCode] = &[
        Self::CliFailed,
        Self::ApiError,
        Self::Timeout,
        Self::NotPermitted,
        Self::NeedsOperator,
        Self::UnsupportedVersion,
        Self::BackendUnavailable,
        Self::InvalidArgs,
        Self::UnsupportedPlatform,
        Self::NotFound,
        Self::Conflict,
        Self::RateLimited,
        Self::ResultTooLarge,
        Self::ConfirmationRequired,
    ];

    pub const fn as_str(self) -> &'static str {
        match self {
            Self::CliFailed => "cli_failed",
            Self::ApiError => "api_error",
            Self::Timeout => "timeout",
            Self::NotPermitted => "not_permitted",
            Self::NeedsOperator => "needs_operator",
            Self::UnsupportedVersion => "unsupported_version",
            Self::BackendUnavailable => "backend_unavailable",
            Self::InvalidArgs => "invalid_args",
            Self::UnsupportedPlatform => "unsupported_platform",
            Self::NotFound => "not_found",
            Self::Conflict => "conflict",
            Self::RateLimited => "rate_limited",
            Self::ResultTooLarge => "result_too_large",
            Self::ConfirmationRequired => "confirmation_required",
        }
    }
}

impl fmt::Display for ErrorCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A failed tool call, as the client sees it.
///
/// Every string field has already been through [`redact`] by the time it is
/// here: the constructors do it, so a caller cannot forget.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ToolError {
    pub code: ErrorCode,
    pub message: String,
    /// The process exit code, when a process is what failed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// What the process wrote to its standard error, trimmed and redacted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stderr: Option<String>,
    /// The HTTP status, when the control plane is what failed.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<u16>,
    /// What the caller can do about it. Present on every code where the fix is
    /// something the caller or operator controls.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
}

impl ToolError {
    /// The general constructor. Prefer the named ones below; this exists for
    /// the paths that compute their own code.
    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
        Self {
            code,
            message: redact(&message.into()).into_owned(),
            exit_code: None,
            stderr: None,
            status: None,
            hint: None,
        }
    }

    #[must_use]
    pub fn with_exit_code(mut self, exit_code: i32) -> Self {
        self.exit_code = Some(exit_code);
        self
    }

    #[must_use]
    pub fn with_stderr(mut self, stderr: impl AsRef<str>) -> Self {
        let trimmed = stderr.as_ref().trim();
        if !trimmed.is_empty() {
            self.stderr = Some(redact(trimmed).into_owned());
        }
        self
    }

    #[must_use]
    pub fn with_status(mut self, status: u16) -> Self {
        self.status = Some(status);
        self
    }

    #[must_use]
    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
        self.hint = Some(redact(&hint.into()).into_owned());
        self
    }

    /// The `tailscale` binary exited non-zero.
    ///
    /// The code is optional because a process killed by a signal has none, and
    /// saying so is more use to the caller than inventing a number.
    pub fn cli_failed(argv0: &str, exit_code: Option<i32>, stderr: &str) -> Self {
        let mut err = Self::new(
            ErrorCode::CliFailed,
            match exit_code {
                Some(code) => format!("`{argv0}` exited with status {code}"),
                None => format!("`{argv0}` was terminated before it exited"),
            },
        )
        .with_stderr(stderr);
        if let Some(code) = exit_code {
            err = err.with_exit_code(code);
        }
        err
    }

    /// The control plane returned a status we do not model more precisely.
    pub fn api_error(status: u16, body: &str) -> Self {
        let body = body.trim();
        let message = if body.is_empty() {
            format!("the control plane returned HTTP {status}")
        } else {
            format!("the control plane returned HTTP {status}: {body}")
        };
        Self::new(ErrorCode::ApiError, message).with_status(status)
    }

    /// A command that did not finish. `printed` is whatever it had said before
    /// it was stopped, which for a command that waits on someone else is
    /// usually the whole explanation.
    pub fn timeout(what: &str, seconds: u64, printed: &str) -> Self {
        let printed = printed.trim();
        let mut message = format!("{what} did not finish within {seconds}s");
        if !printed.is_empty() {
            message.push_str(", having said: ");
            message.push_str(printed);
        }
        Self::new(ErrorCode::Timeout, message).with_hint(if printed.is_empty() {
            "Raise the timeout, or narrow what the call asks for."
        } else {
            "The command was waiting on something. Act on what it printed, then call again."
        })
    }

    /// A tool was reached that this server is not permitted to run. In the
    /// normal case such tools are hidden rather than refused, so this fires
    /// when a client calls a name it did not get from the listing.
    pub fn not_permitted(tool: &str, needs: &str) -> Self {
        Self::new(
            ErrorCode::NotPermitted,
            format!("`{tool}` is not available on this server"),
        )
        .with_hint(format!("Start the server with {needs} to enable it."))
    }

    pub fn needs_operator(stderr: &str) -> Self {
        Self::new(
            ErrorCode::NeedsOperator,
            "the local node refused the command because this user is not its operator",
        )
        .with_stderr(stderr)
        .with_hint(
            "Run `tailscale set --operator=$USER` as an administrator, \
             or run the server as the operator user.",
        )
    }

    pub fn unsupported_version(tool: &str, needs: &str, found: &str) -> Self {
        Self::new(
            ErrorCode::UnsupportedVersion,
            format!("`{tool}` needs Tailscale {needs} or newer; this node runs {found}"),
        )
        .with_hint("Upgrade Tailscale on this node.")
    }

    pub fn backend_unavailable(what: &str, why: &str) -> Self {
        Self::new(
            ErrorCode::BackendUnavailable,
            format!("{what} is unavailable: {why}"),
        )
    }

    pub fn invalid_args(message: impl Into<String>) -> Self {
        Self::new(ErrorCode::InvalidArgs, message)
    }

    pub fn unsupported_platform(tool: &str, platform: &str) -> Self {
        Self::new(
            ErrorCode::UnsupportedPlatform,
            format!("`{tool}` does not exist on {platform}"),
        )
        .with_hint("This command is available on other operating systems only.")
    }

    pub fn not_found(what: &str) -> Self {
        Self::new(ErrorCode::NotFound, format!("{what} was not found")).with_status(404)
    }

    /// A version this caller holds is no longer the current one. Carries 409
    /// the way `not_found` carries 404: a client that branches on the status
    /// should not have to know which of the two this server chose to name.
    pub fn conflict(message: impl Into<String>) -> Self {
        Self::new(ErrorCode::Conflict, message)
            .with_status(409)
            .with_hint(
                "Re-read the resource to get its current version, then retry with that version.",
            )
    }

    pub fn rate_limited(retry_after: Option<u64>) -> Self {
        let err = Self::new(
            ErrorCode::RateLimited,
            "the control plane is rate-limiting this client",
        )
        .with_status(429);
        match retry_after {
            Some(secs) => err.with_hint(format!("Retry after {secs}s.")),
            None => err.with_hint("Retry after a short delay."),
        }
    }

    pub fn result_too_large(bytes: usize, cap: usize) -> Self {
        Self::new(
            ErrorCode::ResultTooLarge,
            format!("the result is {bytes} bytes, over the {cap} byte cap"),
        )
        .with_hint(TOO_LARGE_HINT)
    }

    pub fn confirmation_required(tool: &str, consequence: &str) -> Self {
        Self::new(
            ErrorCode::ConfirmationRequired,
            format!("`{tool}` {consequence}"),
        )
        .with_hint("Repeat the call with `confirm: true` if that is what you intend.")
    }

    /// The wire form: what a client receives as the structured content of a
    /// failed call. Falls back to a bare code if serialisation ever fails, so
    /// that a caller always gets something it can branch on.
    pub fn to_value(&self) -> serde_json::Value {
        serde_json::to_value(self).unwrap_or_else(
            |_| serde_json::json!({ "code": self.code.as_str(), "message": self.message }),
        )
    }
}

/// The two ways out of a result that will not fit, in the words a caller can
/// act on. Shared by the tool-result cap and the transport's, which are the
/// same cap seen from either end.
const TOO_LARGE_HINT: &str =
    "Narrow the request, or raise TAILSCALE_MCP_MAX_RESULT_BYTES on the server.";

/// A control-plane failure, in the vocabulary a client can branch on.
///
/// `tailscale_rest` deliberately names its variants for what happened rather
/// than for what a caller should be told, so that the crate can be used without
/// this server's error model. This is the other half of that arrangement, and
/// the one place the translation happens: every tailnet tool reaches the
/// control plane through `?`, so nothing has to remember to call it.
impl From<tailscale_rest::ApiError> for ToolError {
    fn from(error: tailscale_rest::ApiError) -> Self {
        use tailscale_rest::ApiError as Api;

        match &error {
            // The statuses the model has its own code for. Everything else
            // keeps the number, because a client that knows the control-plane
            // API can read a status this server has no opinion about.
            Api::Status {
                status, message, ..
            } if *status == 404 => Self::new(ErrorCode::NotFound, message.clone()).with_status(404),
            Api::Status {
                status, message, ..
            } if *status == 409 => Self::conflict(message.clone()),

            // 412 is a conflict too, and the description gives it to exactly
            // one call: a policy write whose `If-Match` no longer matches,
            // which means somebody else changed the policy since it was read.
            // The hint is the whole remedy, and it is not the remedy for a
            // 409, so it is given here rather than folded into `conflict`.
            Api::Status {
                status, message, ..
            } if *status == 412 => Self::new(ErrorCode::Conflict, message.clone())
                .with_status(412)
                .with_hint(
                    "The document changed since it was read. Read it again with \
                 `tailnet_policy_get`, re-apply the change to what came back, and \
                 write it with the new `etag`.",
                ),
            Api::Status {
                status,
                retry_after,
                ..
            } if *status == 429 => Self::rate_limited(retry_after.map(|d| d.as_secs())),

            // Not `not_permitted`: that code means a tool this server was not
            // started to offer, and its hint names a server flag. A refusal
            // from the control plane is about the credential instead, and
            // pointing an operator at the wrong switch is worse than no hint.
            Api::Status {
                status, message, ..
            } if matches!(status, 401 | 403) => Self::api_error(*status, message).with_hint(
                "Check that the control-plane credential is current and carries the \
                 scopes this call needs.",
            ),
            Api::Status {
                status, message, ..
            } => Self::api_error(*status, message),

            // A request that never became a response. The tailnet surface is
            // there and unreachable, which is what this code is for.
            Api::Transport { .. } => {
                Self::backend_unavailable("the control plane", &error.to_string())
            }

            Api::Timeout { request, budget } => Self::timeout(request, budget.as_secs(), ""),

            // Deliberately not `result_too_large`, whose message states an
            // exact size: the transport refuses before the whole body has
            // arrived, so the only honest claim is the one the cap gives.
            Api::TooLarge { .. } => {
                Self::new(ErrorCode::ResultTooLarge, error.to_string()).with_hint(TOO_LARGE_HINT)
            }

            // An answer arrived and could not be read. Nothing the caller did
            // is wrong, so there is no hint worth giving.
            Api::Malformed { .. } => Self::new(ErrorCode::ApiError, error.to_string()),

            // No call was made at all: the credential could not be turned into
            // one, or the client was built wrong.
            Api::Token(_) | Api::JwtFile { .. } | Api::Config(_) => {
                Self::backend_unavailable("the tailnet surface", &error.to_string())
            }
        }
    }
}

impl fmt::Display for ToolError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.code, self.message)
    }
}

impl std::error::Error for ToolError {}

/// The result type every tool handler returns.
pub type ToolResult<T> = Result<T, ToolError>;

// ---------------------------------------------------------------------------
// Redaction
// ---------------------------------------------------------------------------

/// What replaces a secret once it has been found.
pub const REDACTED: &str = "[redacted]";

/// Remove anything key-shaped from a string.
///
/// This is deliberately shape-based rather than value-based. We do hold the
/// credentials we were configured with, and [`Redactor`] scrubs those by value,
/// but the strings that pass through here mostly carry secrets we never had:
/// an auth key the model just minted, a key echoed back in an error, a token in
/// a URL the CLI printed. Only the shape is common to all of them.
///
/// Borrowed back unchanged when there is nothing to remove, which is the usual
/// case, so this is cheap to apply everywhere.
pub fn redact(input: &str) -> Cow<'_, str> {
    let mut out: Option<String> = None;
    let bytes = input.as_bytes();
    let mut i = 0;
    let mut copied = 0;

    while i < bytes.len() {
        // Byte indices, because a secret is ASCII and scanning for one is a
        // byte comparison. But the text around it need not be: a hint with an
        // em dash in it puts multi-byte characters in this string, and slicing
        // at a byte inside one panics. A secret can only begin at a boundary,
        // so a position that is not one cannot be a match.
        if !input.is_char_boundary(i) {
            i += 1;
            continue;
        }
        let hit = secret_at(input, i);
        match hit {
            Some((keep, end)) => {
                let out = out.get_or_insert_with(String::new);
                out.push_str(&input[copied..i + keep]);
                out.push_str(REDACTED);
                copied = end;
                i = end;
            }
            None => i += 1,
        }
    }

    match out {
        Some(mut out) => {
            out.push_str(&input[copied..]);
            Cow::Owned(out)
        }
        None => Cow::Borrowed(input),
    }
}

/// If a secret starts at `i`, return how many bytes of the match to keep as a
/// readable marker, and where the secret ends.
fn secret_at(input: &str, i: usize) -> Option<(usize, usize)> {
    // Only consider positions that begin a token, so `not-tskey-auth` in prose
    // is left alone.
    if i > 0 && is_token_byte(input.as_bytes()[i - 1]) {
        return None;
    }
    let rest = &input[i..];

    // `tskey-auth-…`, `tskey-api-…`, `tskey-client-…`, and the bare older form.
    // The prefix is kept so the reader can tell which kind of key was removed.
    for prefix in ["tskey-auth-", "tskey-api-", "tskey-client-", "tskey-"] {
        if let Some(tail) = rest.strip_prefix(prefix) {
            let len = token_len(tail);
            // A bare `tskey-` with nothing after it is not a key.
            if len == 0 {
                continue;
            }
            return Some((prefix.len(), i + prefix.len() + len));
        }
    }

    // Private key material, which `status --json` and `debug prefs` print.
    // The public halves — `nodekey:`, `tlpub:`, `discokey:` — are identifiers a
    // caller legitimately reads, and are deliberately not here; only the
    // halves that are secret are removed.
    for prefix in ["privkey:", "nlpriv:"] {
        if let Some(tail) = rest.strip_prefix(prefix) {
            let len = token_len(tail);
            if len == 0 {
                continue;
            }
            return Some((prefix.len(), i + prefix.len() + len));
        }
    }

    // `Authorization: Bearer <token>` in a captured header dump.
    for prefix in ["Bearer ", "bearer "] {
        if let Some(tail) = rest.strip_prefix(prefix) {
            let len = token_len(tail);
            if len == 0 {
                continue;
            }
            return Some((prefix.len(), i + prefix.len() + len));
        }
    }

    None
}

/// How many bytes at the start of `s` belong to a credential-shaped token.
fn token_len(s: &str) -> usize {
    s.bytes().take_while(|b| is_token_byte(*b)).count()
}

const fn is_token_byte(b: u8) -> bool {
    b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.'
}

/// Scrubs known secret values in addition to key-shaped ones.
///
/// Built once at startup from whatever credentials were configured, then
/// shared. The literal pass matters for the OAuth client secret, which is the
/// one credential we hold that need not look like a Tailscale key.
///
/// [`Redactor::for_credentials`] is how a session gets one, and
/// `the_session_scrubs_its_own_credential` is what keeps that call in place:
/// this said it was built from the configured credentials for four releases
/// during which nothing registered one, so the literal pass ran over an empty
/// list and only the shape rules did any work.
#[derive(Debug, Clone, Default)]
pub struct Redactor {
    secrets: Vec<String>,
}

impl Redactor {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a value to remove wherever it appears. Very short values are
    /// ignored: scrubbing a two-character "secret" would mangle every message.
    pub fn add_secret(&mut self, secret: impl Into<String>) {
        let secret = secret.into();
        if secret.len() >= 8 && !self.secrets.contains(&secret) {
            self.secrets.push(secret);
        }
    }

    #[must_use]
    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
        self.add_secret(secret);
        self
    }

    /// The redactor a session runs with: shape rules, plus whatever secret
    /// values this session was actually configured with.
    ///
    /// A federated credential contributes nothing, and that is not an
    /// omission: the JWT is read from disk at exchange time and never held, so
    /// at startup there is no value to register. What it is exchanged *for* is
    /// a bearer token, which the shape rules cover.
    #[must_use]
    pub fn for_credentials(credentials: Option<&tailscale_rest::Credentials>) -> Self {
        let mut redactor = Self::new();
        match credentials {
            Some(tailscale_rest::Credentials::ApiKey(key)) => redactor.add_secret(key.expose()),
            Some(tailscale_rest::Credentials::OauthClient { client_secret, .. }) => {
                redactor.add_secret(client_secret.expose());
            }
            Some(tailscale_rest::Credentials::Federated { .. }) | None => {}
        }
        redactor
    }

    /// Shape-based redaction first, then the known values.
    pub fn apply<'a>(&self, input: &'a str) -> Cow<'a, str> {
        let mut current = redact(input);
        for secret in &self.secrets {
            if current.contains(secret.as_str()) {
                current = Cow::Owned(current.replace(secret.as_str(), REDACTED));
            }
        }
        current
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::time::Duration;

    use tailscale_rest::ApiError;

    /// The shape every `Status` test starts from, so each one varies only the
    /// thing it is about.
    fn answered(status: u16, message: &str) -> ApiError {
        ApiError::Status {
            request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
            status,
            message: message.to_owned(),
            retry_after: None,
        }
    }

    #[test]
    fn a_status_the_model_has_a_code_for_is_told_in_that_code() {
        for (status, expected) in [
            (404, ErrorCode::NotFound),
            (409, ErrorCode::Conflict),
            (429, ErrorCode::RateLimited),
        ] {
            let error = ToolError::from(answered(status, "no such device"));
            assert_eq!(error.code, expected, "HTTP {status}");
            assert_eq!(error.status, Some(status));
        }
    }

    #[test]
    fn a_status_the_model_has_no_code_for_keeps_its_number() {
        let error = ToolError::from(answered(422, "hostname is already taken"));
        assert_eq!(error.code, ErrorCode::ApiError);
        assert_eq!(error.status, Some(422));
        assert!(
            error.message.contains("hostname is already taken"),
            "the control plane's own words should survive: {}",
            error.message
        );
    }

    #[test]
    fn the_servers_backoff_becomes_the_wait_the_caller_is_told_about() {
        let error = ToolError::from(ApiError::Status {
            request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
            status: 429,
            message: "slow down".to_owned(),
            retry_after: Some(Duration::from_secs(30)),
        });
        assert_eq!(error.hint.as_deref(), Some("Retry after 30s."));
    }

    #[test]
    fn a_refused_credential_is_not_reported_as_a_missing_switch() {
        // `not_permitted` names a server flag in its hint, and no flag makes a
        // rejected credential work. Sending an operator to one would be worse
        // than sending them nowhere.
        for status in [401, 403] {
            let error = ToolError::from(answered(status, "invalid key"));
            assert_eq!(error.code, ErrorCode::ApiError, "HTTP {status}");
            let hint = error.hint.as_deref().unwrap_or_default();
            assert!(
                hint.contains("credential") && hint.contains("scopes"),
                "HTTP {status} should point at the credential: {hint}"
            );
            assert!(
                !hint.contains("--"),
                "HTTP {status} should not name a server flag: {hint}"
            );
        }
    }

    #[test]
    fn an_answer_over_the_cap_says_so_with_the_narrowing_available() {
        let error = ToolError::from(ApiError::TooLarge {
            request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
            cap: 1024,
        });
        assert_eq!(error.code, ErrorCode::ResultTooLarge);
        assert!(error.message.contains("1024"), "{}", error.message);
        assert_eq!(error.hint.as_deref(), Some(TOO_LARGE_HINT));
        // The same hint the tool-result cap gives, because it is the same cap.
        assert_eq!(
            error.hint,
            ToolError::result_too_large(2048, 1024).hint,
            "one cap should not have two answers"
        );
    }

    #[test]
    fn a_credential_that_could_not_be_used_is_the_surface_being_unavailable() {
        // None of these reached the network, so none of them is an API error:
        // what a caller needs to know is that the surface is not there.
        for error in [
            ApiError::Token("the token endpoint answered with 400".to_owned()),
            ApiError::JwtFile {
                path: std::path::PathBuf::from("/run/identity.jwt"),
                source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
            },
            ApiError::Config("`http://elsewhere` is neither https nor loopback".to_owned()),
        ] {
            let reported = ToolError::from(error);
            assert_eq!(reported.code, ErrorCode::BackendUnavailable);
            assert!(reported.status.is_none());
        }
    }

    #[test]
    fn a_body_that_could_not_be_read_is_the_control_plane_being_wrong() {
        let source = serde_json::from_str::<i32>("not a number").expect_err("this does not parse");
        let error = ToolError::from(ApiError::Malformed {
            request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
            source,
        });
        assert_eq!(error.code, ErrorCode::ApiError);
        // Nothing the caller did is wrong, so there is nothing to suggest.
        assert!(error.hint.is_none());
    }

    #[test]
    fn a_call_that_ran_out_of_budget_is_a_timeout_naming_the_budget() {
        let error = ToolError::from(ApiError::Timeout {
            request: "GET /api/v2/tailnet/example.com/devices".to_owned(),
            budget: Duration::from_secs(30),
        });
        assert_eq!(error.code, ErrorCode::Timeout);
        assert!(error.message.contains("30s"), "{}", error.message);
    }

    #[test]
    fn every_code_has_a_distinct_stable_name() {
        let mut names: Vec<&str> = ErrorCode::ALL.iter().map(|c| c.as_str()).collect();
        assert_eq!(names.len(), 14, "the code vocabulary is fixed at fourteen");
        names.sort_unstable();
        let before = names.len();
        names.dedup();
        assert_eq!(before, names.len(), "duplicate error code name");
        for name in names {
            assert!(
                name.chars().all(|c| c.is_ascii_lowercase() || c == '_'),
                "{name} is not snake_case"
            );
        }
    }

    #[test]
    fn codes_serialise_as_their_documented_strings() {
        for code in ErrorCode::ALL {
            let json = serde_json::to_string(code).expect("codes serialise");
            assert_eq!(json, format!("\"{}\"", code.as_str()));
        }
    }

    #[test]
    fn the_codes_an_operator_can_act_on_carry_a_hint() {
        let with_hints = [
            ToolError::not_permitted("tailscale_up", "--allow-write"),
            ToolError::unsupported_version("tailscale_x", "1.80", "1.70"),
            ToolError::unsupported_platform("tailscale_systray", "macos"),
            ToolError::result_too_large(2_000_000, 1_048_576),
            ToolError::conflict("the policy file changed"),
            ToolError::confirmation_required("tailscale_down", "disconnects this node"),
            ToolError::needs_operator(""),
            ToolError::rate_limited(Some(30)),
            ToolError::timeout("tailscale ping", 30, ""),
        ];
        for err in with_hints {
            assert!(err.hint.is_some(), "{} should carry a hint", err.code);
        }
    }

    #[test]
    fn a_command_that_hung_reports_what_it_was_waiting_on() {
        let silent = ToolError::timeout("tailscale funnel 3000", 30, "  ");
        assert_eq!(
            silent.message,
            "tailscale funnel 3000 did not finish within 30s"
        );

        let spoke = ToolError::timeout(
            "tailscale funnel 3000",
            30,
            "Funnel is not enabled on your tailnet.\nTo enable, visit:\n\n\thttps://login.example.com/f/funnel\n",
        );
        assert!(
            spoke.message.contains("https://login.example.com/f/funnel"),
            "the caller cannot act on what it was not told: {}",
            spoke.message
        );
        assert_ne!(
            spoke.hint, silent.hint,
            "a command that explained itself needs different advice from one that did not"
        );
    }

    #[test]
    fn absent_fields_are_omitted_from_the_wire_form() {
        let err = ToolError::invalid_args("port must be between 1 and 65535");
        let json = serde_json::to_value(&err).expect("errors serialise");
        let obj = json.as_object().expect("an object");
        assert_eq!(obj.len(), 2, "only code and message: {obj:?}");
        assert_eq!(obj["code"], "invalid_args");
    }

    #[test]
    fn key_shaped_values_are_removed_from_every_field() {
        let err = ToolError::cli_failed(
            "tailscale up",
            Some(1),
            "invalid key: tskey-auth-example1CNTRL-secretpart",
        );
        let stderr = err.stderr.expect("stderr is captured");
        assert!(!stderr.contains("secretpart"), "{stderr}");
        assert!(stderr.contains("tskey-auth-[redacted]"), "{stderr}");
    }

    #[test]
    fn a_command_killed_by_a_signal_reports_no_exit_code() {
        let err = ToolError::cli_failed("tailscale up", None, "");
        assert_eq!(err.exit_code, None);
        assert!(err.message.contains("terminated"), "{}", err.message);
    }

    #[test]
    fn each_key_shape_is_recognised() {
        for input in [
            "tskey-auth-example-def456",
            "tskey-api-example-def456",
            "tskey-client-example-def456",
            "tskey-exampledef456",
        ] {
            let out = redact(input);
            assert!(!out.contains("def456"), "{input} -> {out}");
            assert!(out.ends_with(REDACTED), "{input} -> {out}");
        }
    }

    #[test]
    fn bearer_tokens_are_removed() {
        let out = redact("Authorization: Bearer tskey-api-example-def");
        assert_eq!(out, "Authorization: Bearer [redacted]");
    }

    #[test]
    fn several_secrets_in_one_string_are_all_removed() {
        let out = redact("old tskey-auth-example-1 new tskey-auth-example-2 done");
        assert_eq!(
            out,
            "old tskey-auth-[redacted] new tskey-auth-[redacted] done"
        );
    }

    #[test]
    fn prose_that_merely_mentions_a_key_survives() {
        // No token follows, so there is nothing to remove.
        assert_eq!(
            redact("pass a tskey- prefixed value"),
            "pass a tskey- prefixed value"
        );
        // A word ending in the prefix is not the start of a token.
        assert_eq!(redact("see mytskey-auth-notes"), "see mytskey-auth-notes");
    }

    #[test]
    fn clean_strings_are_borrowed_not_copied() {
        assert!(matches!(redact("nothing to see here"), Cow::Borrowed(_)));
    }

    #[test]
    fn text_that_is_not_ascii_passes_through_rather_than_panicking() {
        // Every message and hint on its way to a caller goes through here, and
        // this server's own prose contains em dashes. Scanning by byte index
        // meant a slice could land inside one, and a panic in a tool handler
        // takes the session down rather than failing the call.
        let prose = "`provider` is one of falcon, intune — none of them is `wizardry`";
        assert_eq!(redact(prose), prose);

        // The same string with a secret in it still loses the secret.
        let with_key = format!("{prose}, and the key tskey-auth-example1CNTRL-secret is stale");
        let cleaned = redact(&with_key);
        assert!(cleaned.contains("tskey-auth-[redacted]"), "{cleaned}");
        assert!(!cleaned.contains("secret is stale"), "{cleaned}");
        assert!(cleaned.contains("—"), "the prose survives: {cleaned}");
    }

    #[test]
    fn the_redactor_also_scrubs_values_it_was_given() {
        let r = Redactor::new().with_secret("an-oauth-client-secret-value");
        let out = r.apply("failed with an-oauth-client-secret-value and tskey-api-example-b");
        assert_eq!(
            out,
            format!("failed with {REDACTED} and tskey-api-{REDACTED}")
        );
    }

    #[test]
    fn the_redactor_ignores_values_too_short_to_be_secrets() {
        let r = Redactor::new().with_secret("abc");
        assert_eq!(
            r.apply("abc is a common substring"),
            "abc is a common substring"
        );
    }

    #[test]
    fn private_key_material_is_removed_and_the_public_halves_are_not() {
        // `status --json` and `debug prefs` print both, and a caller reading a
        // status needs the public ones to identify a node at all.
        let printed = "nodekey:1111 privkey:aaaabbbbcccc tlpub:2222 nlpriv:ddddeeeeffff";
        let left = redact(printed);
        assert!(left.contains("nodekey:1111"), "{left}");
        assert!(left.contains("tlpub:2222"), "{left}");
        assert!(!left.contains("aaaabbbbcccc"), "{left}");
        assert!(!left.contains("ddddeeeeffff"), "{left}");
        // The prefix stays, so a reader can tell what was removed.
        assert!(left.contains("privkey:[redacted]"), "{left}");
        assert!(left.contains("nlpriv:[redacted]"), "{left}");

        // A bare prefix with nothing after it is not key material.
        assert_eq!(
            redact("privkey: is a field name"),
            "privkey: is a field name"
        );
    }
}