Skip to main content

tailscale_mcp/
error.rs

1//! The tool-level error model, and the redaction every error passes through.
2//!
3//! Two rules shape this module.
4//!
5//! An operation that runs and fails is a *result*, not a protocol error: the
6//! model asked a sensible question and deserves a structured answer it can act
7//! on. Protocol errors are reserved for requests that were malformed before any
8//! work began — an unknown tool, arguments that do not fit the schema.
9//!
10//! Every error path can carry a secret, because the thing that failed was
11//! usually handed one. Redaction therefore lives here, on the type, rather than
12//! at each call site where it would eventually be forgotten.
13
14use std::borrow::Cow;
15use std::fmt;
16
17use serde::Serialize;
18
19/// The fixed vocabulary of failures a tool can report.
20///
21/// Fixed is the operative word: a client can branch on these, so a new variant
22/// is a compatibility question and not a detail. The text of each is stable.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ErrorCode {
26    /// The `tailscale` binary ran and exited non-zero.
27    CliFailed,
28    /// The control plane returned a status we do not model more precisely.
29    ApiError,
30    /// The operation did not finish inside its budget.
31    Timeout,
32    /// The tool exists but this server was not started with the tier or
33    /// toolset that permits it.
34    NotPermitted,
35    /// The local node refuses the command because the caller is not its
36    /// configured operator.
37    NeedsOperator,
38    /// The installed `tailscale` is older than the command requires.
39    UnsupportedVersion,
40    /// The backend a tool needs is absent: no binary on the path, no
41    /// credential configured, or a daemon that is not answering.
42    BackendUnavailable,
43    /// Arguments parsed but do not describe a workable request.
44    InvalidArgs,
45    /// The command does not exist on this operating system.
46    UnsupportedPlatform,
47    /// The target of the operation does not exist.
48    NotFound,
49    /// The state changed underneath us: a stale ETag, or a resource that
50    /// already exists.
51    Conflict,
52    /// The control plane asked us to slow down.
53    RateLimited,
54    /// The result would exceed the configured size cap.
55    ResultTooLarge,
56    /// The operation is one the caller must state intent for.
57    ConfirmationRequired,
58}
59
60impl ErrorCode {
61    /// Every code, used by the test that proves each one is reachable.
62    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/// A failed tool call, as the client sees it.
106///
107/// Every string field has already been through [`redact`] by the time it is
108/// here: the constructors do it, so a caller cannot forget.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct ToolError {
111    pub code: ErrorCode,
112    pub message: String,
113    /// The process exit code, when a process is what failed.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub exit_code: Option<i32>,
116    /// What the process wrote to its standard error, trimmed and redacted.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub stderr: Option<String>,
119    /// The HTTP status, when the control plane is what failed.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub status: Option<u16>,
122    /// What the caller can do about it. Present on every code where the fix is
123    /// something the caller or operator controls.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub hint: Option<String>,
126}
127
128impl ToolError {
129    /// The general constructor. Prefer the named ones below; this exists for
130    /// the paths that compute their own code.
131    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    /// The `tailscale` binary exited non-zero.
170    ///
171    /// The code is optional because a process killed by a signal has none, and
172    /// saying so is more use to the caller than inventing a number.
173    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    /// The control plane returned a status we do not model more precisely.
189    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    /// A command that did not finish. `printed` is whatever it had said before
200    /// it was stopped, which for a command that waits on someone else is
201    /// usually the whole explanation.
202    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    /// A tool was reached that this server is not permitted to run. In the
217    /// normal case such tools are hidden rather than refused, so this fires
218    /// when a client calls a name it did not get from the listing.
219    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    /// A version this caller holds is no longer the current one. Carries 409
271    /// the way `not_found` carries 404: a client that branches on the status
272    /// should not have to know which of the two this server chose to name.
273    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    /// The wire form: what a client receives as the structured content of a
310    /// failed call. Falls back to a bare code if serialisation ever fails, so
311    /// that a caller always gets something it can branch on.
312    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
319/// The two ways out of a result that will not fit, in the words a caller can
320/// act on. Shared by the tool-result cap and the transport's, which are the
321/// same cap seen from either end.
322const TOO_LARGE_HINT: &str =
323    "Narrow the request, or raise TAILSCALE_MCP_MAX_RESULT_BYTES on the server.";
324
325/// A control-plane failure, in the vocabulary a client can branch on.
326///
327/// `tailscale_rest` deliberately names its variants for what happened rather
328/// than for what a caller should be told, so that the crate can be used without
329/// this server's error model. This is the other half of that arrangement, and
330/// the one place the translation happens: every tailnet tool reaches the
331/// control plane through `?`, so nothing has to remember to call it.
332impl 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            // The statuses the model has its own code for. Everything else
338            // keeps the number, because a client that knows the control-plane
339            // API can read a status this server has no opinion about.
340            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            // 412 is a conflict too, and the description gives it to exactly
348            // one call: a policy write whose `If-Match` no longer matches,
349            // which means somebody else changed the policy since it was read.
350            // The hint is the whole remedy, and it is not the remedy for a
351            // 409, so it is given here rather than folded into `conflict`.
352            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            // Not `not_permitted`: that code means a tool this server was not
368            // started to offer, and its hint names a server flag. A refusal
369            // from the control plane is about the credential instead, and
370            // pointing an operator at the wrong switch is worse than no hint.
371            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            // A request that never became a response. The tailnet surface is
382            // there and unreachable, which is what this code is for.
383            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            // Deliberately not `result_too_large`, whose message states an
390            // exact size: the transport refuses before the whole body has
391            // arrived, so the only honest claim is the one the cap gives.
392            Api::TooLarge { .. } => {
393                Self::new(ErrorCode::ResultTooLarge, error.to_string()).with_hint(TOO_LARGE_HINT)
394            }
395
396            // An answer arrived and could not be read. Nothing the caller did
397            // is wrong, so there is no hint worth giving.
398            Api::Malformed { .. } => Self::new(ErrorCode::ApiError, error.to_string()),
399
400            // No call was made at all: the credential could not be turned into
401            // one, or the client was built wrong.
402            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
417/// The result type every tool handler returns.
418pub type ToolResult<T> = Result<T, ToolError>;
419
420// ---------------------------------------------------------------------------
421// Redaction
422// ---------------------------------------------------------------------------
423
424/// What replaces a secret once it has been found.
425pub const REDACTED: &str = "[redacted]";
426
427/// Remove anything key-shaped from a string.
428///
429/// This is deliberately shape-based rather than value-based. We do hold the
430/// credentials we were configured with, and [`Redactor`] scrubs those by value,
431/// but the strings that pass through here mostly carry secrets we never had:
432/// an auth key the model just minted, a key echoed back in an error, a token in
433/// a URL the CLI printed. Only the shape is common to all of them.
434///
435/// Borrowed back unchanged when there is nothing to remove, which is the usual
436/// case, so this is cheap to apply everywhere.
437pub 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        // Byte indices, because a secret is ASCII and scanning for one is a
445        // byte comparison. But the text around it need not be: a hint with an
446        // em dash in it puts multi-byte characters in this string, and slicing
447        // at a byte inside one panics. A secret can only begin at a boundary,
448        // so a position that is not one cannot be a match.
449        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
475/// If a secret starts at `i`, return how many bytes of the match to keep as a
476/// readable marker, and where the secret ends.
477fn secret_at(input: &str, i: usize) -> Option<(usize, usize)> {
478    // Only consider positions that begin a token, so `not-tskey-auth` in prose
479    // is left alone.
480    if i > 0 && is_token_byte(input.as_bytes()[i - 1]) {
481        return None;
482    }
483    let rest = &input[i..];
484
485    // `tskey-auth-…`, `tskey-api-…`, `tskey-client-…`, and the bare older form.
486    // The prefix is kept so the reader can tell which kind of key was removed.
487    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            // A bare `tskey-` with nothing after it is not a key.
491            if len == 0 {
492                continue;
493            }
494            return Some((prefix.len(), i + prefix.len() + len));
495        }
496    }
497
498    // Private key material, which `status --json` and `debug prefs` print.
499    // The public halves — `nodekey:`, `tlpub:`, `discokey:` — are identifiers a
500    // caller legitimately reads, and are deliberately not here; only the
501    // halves that are secret are removed.
502    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    // `Authorization: Bearer <token>` in a captured header dump.
513    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
526/// How many bytes at the start of `s` belong to a credential-shaped token.
527fn 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/// Scrubs known secret values in addition to key-shaped ones.
536///
537/// Built once at startup from whatever credentials were configured, then
538/// shared. The literal pass matters for the OAuth client secret, which is the
539/// one credential we hold that need not look like a Tailscale key.
540///
541/// [`Redactor::for_credentials`] is how a session gets one, and
542/// `the_session_scrubs_its_own_credential` is what keeps that call in place:
543/// this said it was built from the configured credentials for four releases
544/// during which nothing registered one, so the literal pass ran over an empty
545/// list and only the shape rules did any work.
546#[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    /// Register a value to remove wherever it appears. Very short values are
557    /// ignored: scrubbing a two-character "secret" would mangle every message.
558    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    /// The redactor a session runs with: shape rules, plus whatever secret
572    /// values this session was actually configured with.
573    ///
574    /// A federated credential contributes nothing, and that is not an
575    /// omission: the JWT is read from disk at exchange time and never held, so
576    /// at startup there is no value to register. What it is exchanged *for* is
577    /// a bearer token, which the shape rules cover.
578    #[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    /// Shape-based redaction first, then the known values.
592    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    /// The shape every `Status` test starts from, so each one varies only the
612    /// thing it is about.
613    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        // `not_permitted` names a server flag in its hint, and no flag makes a
661        // rejected credential work. Sending an operator to one would be worse
662        // than sending them nowhere.
663        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        // The same hint the tool-result cap gives, because it is the same cap.
688        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        // None of these reached the network, so none of them is an API error:
698        // what a caller needs to know is that the surface is not there.
699        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        // Nothing the caller did is wrong, so there is nothing to suggest.
722        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 codes_serialise_as_their_documented_strings() {
737        for code in ErrorCode::ALL {
738            let json = serde_json::to_string(code).expect("codes serialise");
739            assert_eq!(json, format!("\"{}\"", code.as_str()));
740        }
741    }
742
743    #[test]
744    fn the_codes_an_operator_can_act_on_carry_a_hint() {
745        let with_hints = [
746            ToolError::not_permitted("tailscale_up", "--allow-write"),
747            ToolError::unsupported_version("tailscale_x", "1.80", "1.70"),
748            ToolError::unsupported_platform("tailscale_systray", "macos"),
749            ToolError::result_too_large(2_000_000, 1_048_576),
750            ToolError::conflict("the policy file changed"),
751            ToolError::confirmation_required("tailscale_down", "disconnects this node"),
752            ToolError::needs_operator(""),
753            ToolError::rate_limited(Some(30)),
754            ToolError::timeout("tailscale ping", 30, ""),
755        ];
756        for err in with_hints {
757            assert!(err.hint.is_some(), "{} should carry a hint", err.code);
758        }
759    }
760
761    #[test]
762    fn a_command_that_hung_reports_what_it_was_waiting_on() {
763        let silent = ToolError::timeout("tailscale funnel 3000", 30, "  ");
764        assert_eq!(
765            silent.message,
766            "tailscale funnel 3000 did not finish within 30s"
767        );
768
769        let spoke = ToolError::timeout(
770            "tailscale funnel 3000",
771            30,
772            "Funnel is not enabled on your tailnet.\nTo enable, visit:\n\n\thttps://login.example.com/f/funnel\n",
773        );
774        assert!(
775            spoke.message.contains("https://login.example.com/f/funnel"),
776            "the caller cannot act on what it was not told: {}",
777            spoke.message
778        );
779        assert_ne!(
780            spoke.hint, silent.hint,
781            "a command that explained itself needs different advice from one that did not"
782        );
783    }
784
785    #[test]
786    fn absent_fields_are_omitted_from_the_wire_form() {
787        let err = ToolError::invalid_args("port must be between 1 and 65535");
788        let json = serde_json::to_value(&err).expect("errors serialise");
789        let obj = json.as_object().expect("an object");
790        assert_eq!(obj.len(), 2, "only code and message: {obj:?}");
791        assert_eq!(obj["code"], "invalid_args");
792    }
793
794    #[test]
795    fn key_shaped_values_are_removed_from_every_field() {
796        let err = ToolError::cli_failed(
797            "tailscale up",
798            Some(1),
799            "invalid key: tskey-auth-example1CNTRL-secretpart",
800        );
801        let stderr = err.stderr.expect("stderr is captured");
802        assert!(!stderr.contains("secretpart"), "{stderr}");
803        assert!(stderr.contains("tskey-auth-[redacted]"), "{stderr}");
804    }
805
806    #[test]
807    fn a_command_killed_by_a_signal_reports_no_exit_code() {
808        let err = ToolError::cli_failed("tailscale up", None, "");
809        assert_eq!(err.exit_code, None);
810        assert!(err.message.contains("terminated"), "{}", err.message);
811    }
812
813    #[test]
814    fn each_key_shape_is_recognised() {
815        for input in [
816            "tskey-auth-example-def456",
817            "tskey-api-example-def456",
818            "tskey-client-example-def456",
819            "tskey-exampledef456",
820        ] {
821            let out = redact(input);
822            assert!(!out.contains("def456"), "{input} -> {out}");
823            assert!(out.ends_with(REDACTED), "{input} -> {out}");
824        }
825    }
826
827    #[test]
828    fn bearer_tokens_are_removed() {
829        let out = redact("Authorization: Bearer tskey-api-example-def");
830        assert_eq!(out, "Authorization: Bearer [redacted]");
831    }
832
833    #[test]
834    fn prose_that_merely_mentions_a_key_survives() {
835        // No token follows, so there is nothing to remove.
836        assert_eq!(
837            redact("pass a tskey- prefixed value"),
838            "pass a tskey- prefixed value"
839        );
840        // A word ending in the prefix is not the start of a token.
841        assert_eq!(redact("see mytskey-auth-notes"), "see mytskey-auth-notes");
842    }
843
844    #[test]
845    fn text_that_is_not_ascii_passes_through_rather_than_panicking() {
846        // Every message and hint on its way to a caller goes through here, and
847        // this server's own prose contains em dashes. Scanning by byte index
848        // meant a slice could land inside one, and a panic in a tool handler
849        // takes the session down rather than failing the call.
850        let prose = "`provider` is one of falcon, intune — none of them is `wizardry`";
851        assert_eq!(redact(prose), prose);
852
853        // The same string with a secret in it still loses the secret.
854        let with_key = format!("{prose}, and the key tskey-auth-example1CNTRL-secret is stale");
855        let cleaned = redact(&with_key);
856        assert!(cleaned.contains("tskey-auth-[redacted]"), "{cleaned}");
857        assert!(!cleaned.contains("secret is stale"), "{cleaned}");
858        assert!(cleaned.contains("—"), "the prose survives: {cleaned}");
859    }
860
861    #[test]
862    fn the_redactor_also_scrubs_values_it_was_given() {
863        let r = Redactor::new().with_secret("an-oauth-client-secret-value");
864        let out = r.apply("failed with an-oauth-client-secret-value and tskey-api-example-b");
865        assert_eq!(
866            out,
867            format!("failed with {REDACTED} and tskey-api-{REDACTED}")
868        );
869    }
870
871    #[test]
872    fn the_redactor_ignores_values_too_short_to_be_secrets() {
873        let r = Redactor::new().with_secret("abc");
874        assert_eq!(
875            r.apply("abc is a common substring"),
876            "abc is a common substring"
877        );
878    }
879
880    #[test]
881    fn private_key_material_is_removed_and_the_public_halves_are_not() {
882        // `status --json` and `debug prefs` print both, and a caller reading a
883        // status needs the public ones to identify a node at all.
884        let printed = "nodekey:1111 privkey:aaaabbbbcccc tlpub:2222 nlpriv:ddddeeeeffff";
885        let left = redact(printed);
886        assert!(left.contains("nodekey:1111"), "{left}");
887        assert!(left.contains("tlpub:2222"), "{left}");
888        assert!(!left.contains("aaaabbbbcccc"), "{left}");
889        assert!(!left.contains("ddddeeeeffff"), "{left}");
890        // The prefix stays, so a reader can tell what was removed.
891        assert!(left.contains("privkey:[redacted]"), "{left}");
892        assert!(left.contains("nlpriv:[redacted]"), "{left}");
893
894        // A bare prefix with nothing after it is not key material.
895        assert_eq!(
896            redact("privkey: is a field name"),
897            "privkey: is a field name"
898        );
899    }
900}