Skip to main content

tailscale_rest/
client.rs

1//! The transport: one client, and the rules every call goes through.
2//!
3//! Everything above this module builds a path and reads a body. What lives
4//! here is the part that is the same for all 93 tailnet tools — which
5//! credential goes on the wire, when a failed call is worth repeating, how many
6//! may be in flight at once, and how large an answer this server will hold.
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14use tokio::sync::Semaphore;
15
16use crate::credentials::{Credentials, DEFAULT_TAILNET};
17use crate::error::{ApiError, Idempotence, describe};
18use crate::token::Tokens;
19
20/// The control plane. Pinned rather than configured, because a server that can
21/// be pointed at another host is a server that can be pointed at an attacker's.
22pub const DEFAULT_BASE_URL: &str = "https://api.tailscale.com";
23
24/// How long one call may take, across every attempt it makes.
25pub const DEFAULT_BUDGET: Duration = Duration::from_secs(30);
26
27/// How many calls may be in flight at once.
28///
29/// The control plane rate-limits per tailnet, and an agent that fans out over
30/// a device list can produce a hundred calls from one thought. Holding the
31/// fan-in here turns that into a queue rather than a wall of 429s.
32pub const DEFAULT_CONCURRENCY: usize = 8;
33
34/// The two statuses this module decides something about, named so that the
35/// decision and the check are spelled the same way wherever they appear.
36const UNAUTHORIZED: u16 = 401;
37const TOO_MANY_REQUESTS: u16 = 429;
38
39/// How many times one call is attempted, first try included.
40const MAX_ATTEMPTS: u32 = 4;
41
42/// The first backoff, doubled per retry.
43const BASE_BACKOFF: Duration = Duration::from_millis(250);
44
45/// The longest wait honoured from a `Retry-After`.
46///
47/// A server asking for ten minutes is asking for longer than any call budget,
48/// and sleeping on it would only spend the budget doing nothing.
49const MAX_BACKOFF: Duration = Duration::from_secs(20);
50
51/// How much of a failed call's body is read before it is described.
52///
53/// The size cap is about results; an error message is not a result, and reading
54/// a megabyte of one to print a sentence helps nobody.
55const MAX_ERROR_BYTES: usize = 8 * 1024;
56
57/// How to reach the control plane.
58#[derive(Debug, Clone)]
59pub struct ClientConfig {
60    /// Where to send. [`DEFAULT_BASE_URL`] unless a test says otherwise.
61    pub base_url: String,
62    /// The tailnet a path means when the caller does not name one.
63    pub tailnet: String,
64    pub credentials: Credentials,
65    /// The whole of one call, retries and backoff included.
66    pub budget: Duration,
67    pub concurrency: usize,
68    /// The largest body this server will hold in memory.
69    pub max_response_bytes: usize,
70    pub user_agent: String,
71}
72
73impl ClientConfig {
74    /// A configuration pointed at the real control plane.
75    pub fn new(credentials: Credentials) -> Self {
76        Self {
77            base_url: DEFAULT_BASE_URL.to_owned(),
78            tailnet: DEFAULT_TAILNET.to_owned(),
79            credentials,
80            budget: DEFAULT_BUDGET,
81            concurrency: DEFAULT_CONCURRENCY,
82            max_response_bytes: 1 << 20,
83            user_agent: format!("tailscale-mcp/{}", env!("CARGO_PKG_VERSION")),
84        }
85    }
86}
87
88/// A client for the control plane.
89///
90/// Cloning is cheap and shares everything: the connection pool, the token, and
91/// — the point of sharing it — the concurrency limit. Two clients would be two
92/// limits, which is one limit too many.
93#[derive(Debug, Clone)]
94pub struct Client {
95    inner: Arc<Inner>,
96}
97
98#[derive(Debug)]
99struct Inner {
100    http: reqwest::Client,
101    tokens: Tokens,
102    base_url: String,
103    tailnet: String,
104    budget: Duration,
105    max_response_bytes: usize,
106    in_flight: Semaphore,
107}
108
109impl Client {
110    pub fn new(config: ClientConfig) -> Result<Self, ApiError> {
111        let base_url = checked_base_url(&config.base_url)?;
112        if config.concurrency == 0 {
113            return Err(ApiError::Config(
114                "at least one call has to be allowed in flight".to_owned(),
115            ));
116        }
117        if config.max_response_bytes == 0 {
118            return Err(ApiError::Config(
119                "a response size cap of zero would reject every answer".to_owned(),
120            ));
121        }
122
123        let http = reqwest::Client::builder()
124            .user_agent(config.user_agent)
125            // Not the call budget: that covers every attempt together and is
126            // applied around the retry loop. This is the ceiling on one of
127            // them, so a stalled connection cannot eat a whole budget alone.
128            .timeout(config.budget)
129            .build()
130            .map_err(|source| {
131                ApiError::Config(format!("the HTTP client could not be built: {source}"))
132            })?;
133
134        Ok(Self {
135            inner: Arc::new(Inner {
136                tokens: Tokens::new(config.credentials, &base_url, http.clone()),
137                http,
138                base_url,
139                tailnet: config.tailnet,
140                budget: config.budget,
141                max_response_bytes: config.max_response_bytes,
142                in_flight: Semaphore::new(config.concurrency),
143            }),
144        })
145    }
146
147    /// The tailnet a call means when the caller does not name one.
148    pub fn tailnet(&self) -> &str {
149        &self.inner.tailnet
150    }
151
152    /// The path of a tailnet-scoped resource, for `tailnet` or the default.
153    ///
154    /// Every tailnet path is built here so that a tailnet name carrying a
155    /// slash cannot reach into a path it was not given.
156    pub fn tailnet_path(&self, tailnet: Option<&str>, rest: &str) -> String {
157        let tailnet = tailnet.map_or(self.tailnet(), str::trim);
158        let tailnet = if tailnet.is_empty() {
159            self.tailnet()
160        } else {
161            tailnet
162        };
163        format!("/api/v2/tailnet/{}{rest}", escape(tailnet))
164    }
165
166    pub fn get(&self, path: impl Into<String>) -> RequestBuilder<'_> {
167        self.request(reqwest::Method::GET, path)
168    }
169
170    pub fn post(&self, path: impl Into<String>) -> RequestBuilder<'_> {
171        self.request(reqwest::Method::POST, path)
172    }
173
174    pub fn put(&self, path: impl Into<String>) -> RequestBuilder<'_> {
175        self.request(reqwest::Method::PUT, path)
176    }
177
178    pub fn patch(&self, path: impl Into<String>) -> RequestBuilder<'_> {
179        self.request(reqwest::Method::PATCH, path)
180    }
181
182    pub fn delete(&self, path: impl Into<String>) -> RequestBuilder<'_> {
183        self.request(reqwest::Method::DELETE, path)
184    }
185
186    fn request(&self, method: reqwest::Method, path: impl Into<String>) -> RequestBuilder<'_> {
187        RequestBuilder {
188            client: self,
189            method,
190            path: path.into(),
191            query: Vec::new(),
192            headers: Vec::new(),
193            body: None,
194            budget: self.inner.budget,
195            broken: None,
196        }
197    }
198}
199
200/// One call, before it is sent.
201#[derive(Debug)]
202pub struct RequestBuilder<'a> {
203    client: &'a Client,
204    method: reqwest::Method,
205    path: String,
206    query: Vec<(String, String)>,
207    headers: Vec<(String, String)>,
208    body: Option<Body>,
209    budget: Duration,
210    /// A failure that happened while the call was being built, kept until
211    /// there is somewhere to return it from.
212    broken: Option<ApiError>,
213}
214
215/// What a request carries, and how it is spelled on the wire.
216///
217/// Two shapes rather than one, because the policy endpoints take a document
218/// this server did not author and must not reformat.
219#[derive(Debug, Clone)]
220enum Body {
221    Json(Value),
222    Text { content_type: String, text: String },
223}
224
225impl RequestBuilder<'_> {
226    /// A query parameter. Repeating a name sends it twice, which is how the
227    /// API spells a list.
228    #[must_use]
229    pub fn query(mut self, name: &str, value: impl std::fmt::Display) -> Self {
230        self.query.push((name.to_owned(), value.to_string()));
231        self
232    }
233
234    /// A query parameter, if there is one to send.
235    #[must_use]
236    pub fn maybe_query(self, name: &str, value: Option<impl std::fmt::Display>) -> Self {
237        match value {
238            Some(value) => self.query(name, value),
239            None => self,
240        }
241    }
242
243    /// A header. `If-Match` on the policy file is what this is for.
244    #[must_use]
245    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
246        self.headers.push((name.to_owned(), value.into()));
247        self
248    }
249
250    /// A JSON body.
251    #[must_use]
252    pub fn json(mut self, body: &impl Serialize) -> Self {
253        match serde_json::to_value(body) {
254            Ok(value) => self.body = Some(Body::Json(value)),
255            Err(source) => {
256                self.broken.get_or_insert(ApiError::Config(format!(
257                    "the request body could not be built: {source}"
258                )));
259            }
260        }
261        self
262    }
263
264    /// A body that is text rather than JSON, under a content type of its own.
265    ///
266    /// The policy file is the reason: it is HuJSON — JSON with comments and
267    /// trailing commas — and a caller who wrote one wants it sent as written,
268    /// comments included. Sending it as a JSON string would send the document
269    /// quoted and escaped, which is a different document.
270    #[must_use]
271    pub fn text(mut self, content_type: &str, body: impl Into<String>) -> Self {
272        self.body = Some(Body::Text {
273            content_type: content_type.to_owned(),
274            text: body.into(),
275        });
276        self
277    }
278
279    /// The whole of this call, retries and backoff included: past it the call
280    /// ends as a timeout whatever it was doing.
281    ///
282    /// The default is the client's, which is the tool timeout; a tool that
283    /// waits on something slower passes its own. One attempt is separately
284    /// capped at the client's budget, so raising this raises how long a call
285    /// may spend across attempts rather than how long one may stall.
286    #[must_use]
287    pub fn budget(mut self, budget: Duration) -> Self {
288        self.budget = budget;
289        self
290    }
291
292    /// Send it, and read the answer as JSON.
293    ///
294    /// An empty body — which is what a successful `DELETE` sends — comes back
295    /// as [`Value::Null`] rather than as a failure to parse nothing.
296    pub async fn send(self) -> Result<Value, ApiError> {
297        let request = self.describe_request();
298        let answer = self.send_raw().await?;
299        parse(&answer.bytes, &request)
300    }
301
302    /// Send it, and read the answer as a particular shape.
303    pub async fn send_as<T: DeserializeOwned>(self) -> Result<T, ApiError> {
304        Ok(self.send_answer().await?.value)
305    }
306
307    /// Send it, and read the answer both ways at once.
308    ///
309    /// ADR-0003 asks for "the parsed model together with the raw body and the
310    /// headers that matter", and this is why: a tool forwards the body it was
311    /// given, unrenamed and with every field the control plane sent, while the
312    /// server reads the typed value to decide what to do next. Parsing twice
313    /// would be two chances to disagree, so the model is deserialised from the
314    /// [`Value`] rather than from the bytes a second time.
315    ///
316    /// An empty body reads as [`Value::Null`], the same as [`send`] gives it,
317    /// and `T` has to be a type that can read null — [`Value`] or `()` or an
318    /// [`Option`]. A model cannot: every one of them carries a flattened map
319    /// of unknown fields, which makes it a map to serde, and serde will not
320    /// read a map from null. That is the right way round, because the
321    /// endpoints that answer with nothing are the deletions, and a deletion
322    /// has no model to answer with.
323    ///
324    /// [`send`]: RequestBuilder::send
325    pub async fn send_answer<T: DeserializeOwned>(self) -> Result<Answer<T>, ApiError> {
326        let request = self.describe_request();
327        let answer = self.send_raw().await?;
328        let raw = parse(&answer.bytes, &request)?;
329        let value =
330            T::deserialize(&raw).map_err(|source| ApiError::Malformed { request, source })?;
331        Ok(Answer {
332            value,
333            raw,
334            etag: answer.etag,
335        })
336    }
337
338    /// Send it, and read the answer as text.
339    ///
340    /// The policy file is HuJSON — comments and trailing commas — so it is not
341    /// JSON to parse, and its `ETag` is what a later write has to quote.
342    pub async fn send_text(self) -> Result<TextBody, ApiError> {
343        let answer = self.send_raw().await?;
344        Ok(TextBody {
345            // The API sends UTF-8; anything else is a corrupted body, and
346            // replacing the bad bytes says so more usefully than a parse
347            // error about an offset nobody can see.
348            text: String::from_utf8_lossy(&answer.bytes).into_owned(),
349            etag: answer.etag,
350        })
351    }
352
353    /// `GET /api/v2/tailnet/-/devices`, for saying which call this was.
354    fn describe_request(&self) -> String {
355        format!("{} {}", self.method, self.path)
356    }
357
358    /// Every attempt this call is allowed, and whatever the last one produced.
359    ///
360    /// The budget bounds the whole of it. The retry loop stops short of a
361    /// sleep that would run past the deadline, which is the tidy way out and
362    /// leaves the caller holding the failure that caused the wait; the timeout
363    /// around the loop is the untidy one, for an attempt that is still going
364    /// when the budget is already spent.
365    async fn send_raw(self) -> Result<RawBody, ApiError> {
366        let request = self.describe_request();
367        let budget = self.budget;
368        match tokio::time::timeout(budget, self.attempts()).await {
369            Ok(answer) => answer,
370            Err(_) => Err(ApiError::Timeout { request, budget }),
371        }
372    }
373
374    /// The retry loop itself, bounded from outside by [`Self::send_raw`].
375    async fn attempts(self) -> Result<RawBody, ApiError> {
376        if let Some(broken) = self.broken {
377            return Err(broken);
378        }
379        let request = self.describe_request();
380        let idempotence = idempotence(&self.method);
381        let deadline = Instant::now() + self.budget;
382        let inner = &self.client.inner;
383        let url = format!("{}{}", inner.base_url, self.path);
384
385        let mut attempt = 0;
386        let mut refreshed = false;
387        loop {
388            attempt += 1;
389            let outcome = self.attempt(&url, &request).await;
390            let error = match outcome {
391                Ok(answer) => return Ok(answer),
392                Err(error) => error,
393            };
394
395            // A refused token is its own kind of retry, and a short one: the
396            // attempt evicted it, so going round again mints another and sends
397            // that. Once per call, because a second 401 on a fresh token is
398            // the credential being wrong rather than the token being stale,
399            // and no method is at risk — a 401 means nothing was done.
400            if error.status() == Some(UNAUTHORIZED)
401                && inner.tokens.can_refresh()
402                && !refreshed
403                && attempt < MAX_ATTEMPTS
404            {
405                refreshed = true;
406                tracing::debug!(request = %request, "the token was refused; minting another");
407                continue;
408            }
409
410            // Two conditions, and both have to hold. `is_transient` is about
411            // the failure — would asking again plausibly work — and this is
412            // about the request: a `POST` that may have been acted on before
413            // the answer went missing must not be sent twice. A 429 is the
414            // exception, and only because it means the server declined to act.
415            let repeatable =
416                idempotence == Idempotence::Repeatable || error.status() == Some(TOO_MANY_REQUESTS);
417            if !error.is_transient() || !repeatable || attempt >= MAX_ATTEMPTS {
418                return Err(error);
419            }
420
421            let delay = backoff(attempt, &error);
422            if Instant::now() + delay >= deadline {
423                // Sleeping past the budget would turn a described failure into
424                // a bare timeout. The caller is better told what went wrong.
425                return Err(error);
426            }
427            tracing::debug!(
428                request = %request,
429                attempt,
430                delay_ms = delay.as_millis(),
431                because = %error,
432                "retrying a control-plane call"
433            );
434            tokio::time::sleep(delay).await;
435        }
436    }
437
438    /// One attempt: a permit, a token, a request, and an answer read under the
439    /// size cap.
440    async fn attempt(&self, url: &str, request: &str) -> Result<RawBody, ApiError> {
441        let inner = &self.client.inner;
442        let _permit = inner
443            .in_flight
444            .acquire()
445            .await
446            .map_err(|_| ApiError::Config("the client has been shut down".to_owned()))?;
447
448        let bearer = inner.tokens.bearer().await?;
449        let mut sending = inner
450            .http
451            .request(self.method.clone(), url)
452            .bearer_auth(bearer.value.expose())
453            .query(&self.query);
454        for (name, value) in &self.headers {
455            sending = sending.header(name, value);
456        }
457        match &self.body {
458            Some(Body::Json(value)) => sending = sending.json(value),
459            Some(Body::Text { content_type, text }) => {
460                sending = sending
461                    .header(reqwest::header::CONTENT_TYPE, content_type)
462                    .body(text.clone());
463            }
464            None => {}
465        }
466
467        let response = sending.send().await.map_err(|source| {
468            if source.is_timeout() {
469                ApiError::Timeout {
470                    request: request.to_owned(),
471                    budget: self.budget,
472                }
473            } else {
474                ApiError::Transport {
475                    request: request.to_owned(),
476                    source,
477                }
478            }
479        })?;
480
481        let status = response.status();
482        if status.is_success() {
483            return read_body(response, request, inner.max_response_bytes).await;
484        }
485
486        if status.as_u16() == UNAUTHORIZED
487            && let Some(generation) = bearer.generation
488        {
489            // The token was refused, so the next attempt must not send it
490            // again. Doing this by generation is what keeps one rejection from
491            // throwing away several freshly minted tokens in a row.
492            inner.tokens.evict(generation).await;
493        }
494
495        let retry_after = retry_after(&response);
496        let body = read_body(response, request, MAX_ERROR_BYTES)
497            .await
498            .map(|raw| String::from_utf8_lossy(&raw.bytes).into_owned())
499            .unwrap_or_default();
500        Err(ApiError::Status {
501            request: request.to_owned(),
502            status: status.as_u16(),
503            message: describe(status, &body),
504            retry_after,
505        })
506    }
507}
508
509/// A body, read whole.
510#[derive(Debug)]
511struct RawBody {
512    bytes: Vec<u8>,
513    etag: Option<String>,
514}
515
516/// A body, parsed. Empty means nothing was said, not that nothing parsed.
517///
518/// One function rather than three copies of the same two lines, so that the
519/// three `send` methods cannot come to disagree about what an empty body is.
520fn parse(bytes: &[u8], request: &str) -> Result<Value, ApiError> {
521    if bytes.iter().all(u8::is_ascii_whitespace) {
522        return Ok(Value::Null);
523    }
524    serde_json::from_slice(bytes).map_err(|source| ApiError::Malformed {
525        request: request.to_owned(),
526        source,
527    })
528}
529
530/// A parsed answer, and the body it was parsed from.
531///
532/// Both halves are kept because both are used: `raw` is what a tool hands
533/// back, so the caller sees whatever the control plane sent rather than the
534/// subset this build knows the names of, and `value` is what the server reads
535/// when it has to act on the answer.
536#[derive(Debug, Clone)]
537pub struct Answer<T> {
538    pub value: T,
539    /// The body, parsed as JSON and otherwise untouched.
540    pub raw: Value,
541    /// The `ETag` header, for the endpoints that version their document.
542    pub etag: Option<String>,
543}
544
545/// A body that was asked for as text, and the version it was read at.
546#[derive(Debug, Clone)]
547pub struct TextBody {
548    pub text: String,
549    /// The `ETag` header, which the policy file's writes quote back.
550    pub etag: Option<String>,
551}
552
553/// Read a body, refusing rather than truncating one that is too large.
554///
555/// Truncating would be worse than failing: half a JSON document does not parse,
556/// and half a device list that does parse is a wrong answer nobody can see is
557/// wrong. The cap is checked against `Content-Length` first so an enormous
558/// answer is refused before it is transferred, and again while reading, because
559/// a chunked response does not have one.
560async fn read_body(
561    mut response: reqwest::Response,
562    request: &str,
563    cap: usize,
564) -> Result<RawBody, ApiError> {
565    let etag = response
566        .headers()
567        .get(reqwest::header::ETAG)
568        .and_then(|value| value.to_str().ok())
569        .map(str::to_owned);
570
571    let too_large = || ApiError::TooLarge {
572        request: request.to_owned(),
573        cap,
574    };
575    if response
576        .content_length()
577        .is_some_and(|len| len > cap as u64)
578    {
579        return Err(too_large());
580    }
581
582    let mut bytes = Vec::new();
583    while let Some(chunk) = response
584        .chunk()
585        .await
586        .map_err(|source| ApiError::Transport {
587            request: request.to_owned(),
588            source,
589        })?
590    {
591        if bytes.len() + chunk.len() > cap {
592            return Err(too_large());
593        }
594        bytes.extend_from_slice(&chunk);
595    }
596    Ok(RawBody { bytes, etag })
597}
598
599/// HTTP's own answer to whether a request may be sent twice.
600///
601/// `GET`, `HEAD`, `PUT` and `DELETE` are defined to be idempotent and `POST`
602/// and `PATCH` are not, so this needs no table of its own: minting an auth key
603/// is a `POST` because minting twice is two keys.
604fn idempotence(method: &reqwest::Method) -> Idempotence {
605    match *method {
606        reqwest::Method::POST | reqwest::Method::PATCH => Idempotence::Once,
607        _ => Idempotence::Repeatable,
608    }
609}
610
611/// How long to wait before attempt number `attempt + 1`.
612///
613/// The server's own `Retry-After` wins where it sent one: it knows when its
614/// limit resets and this side is guessing. Everything else doubles from
615/// [`BASE_BACKOFF`]. There is no jitter, because there is one client here and
616/// nobody to collide with.
617fn backoff(attempt: u32, error: &ApiError) -> Duration {
618    if let ApiError::Status {
619        retry_after: Some(asked),
620        ..
621    } = error
622    {
623        return (*asked).min(MAX_BACKOFF);
624    }
625    (BASE_BACKOFF * 2u32.saturating_pow(attempt - 1)).min(MAX_BACKOFF)
626}
627
628/// The `Retry-After` header, in the seconds form the API sends.
629///
630/// The HTTP-date form is legal and Tailscale does not use it; reading a wrong
631/// number out of a date would be worse than falling back to the backoff.
632fn retry_after(response: &reqwest::Response) -> Option<Duration> {
633    response
634        .headers()
635        .get(reqwest::header::RETRY_AFTER)?
636        .to_str()
637        .ok()?
638        .trim()
639        .parse::<u64>()
640        .ok()
641        .map(Duration::from_secs)
642}
643
644/// Where a client may point.
645///
646/// A base URL is where every credential this server holds gets sent, so the
647/// three things asked of one are the three that keep it from being a way to
648/// send them somewhere in the clear: the transport is `https`, or the host is
649/// this machine, which is how the fake in this crate is reached; there is no
650/// path, because a base URL is a host and nothing more; and there is no
651/// userinfo, because a credential in a URL is a credential that gets printed.
652///
653/// What this does not do is name the host. `https://api.tailscale.com` is the
654/// default, and the guarantee here is about how a credential travels rather
655/// than about where it lands.
656pub fn checked_base_url(base_url: &str) -> Result<String, ApiError> {
657    let trimmed = base_url.trim().trim_end_matches('/');
658    let parsed = reqwest::Url::parse(trimmed)
659        .map_err(|source| ApiError::Config(format!("`{base_url}` is not a URL: {source}")))?;
660
661    let loopback = parsed.host_str().is_some_and(|host| {
662        // `host_str` keeps an IPv6 address in the brackets the URL form
663        // requires, and `IpAddr` does not parse those.
664        let host = host.trim_start_matches('[').trim_end_matches(']');
665        host.eq_ignore_ascii_case("localhost")
666            || host
667                .parse::<std::net::IpAddr>()
668                .is_ok_and(|address| address.is_loopback())
669    });
670    if parsed.scheme() != "https" && !loopback {
671        return Err(ApiError::Config(format!(
672            "`{base_url}` is neither https nor a loopback address, and a \
673             control-plane credential is not sent anywhere else"
674        )));
675    }
676    if !parsed.path().is_empty() && parsed.path() != "/" {
677        return Err(ApiError::Config(format!(
678            "`{base_url}` has a path; the base URL is a host and nothing more"
679        )));
680    }
681    // The URL is deliberately not echoed back here: the objection to userinfo
682    // is that it is a secret in a place secrets get printed, and this message
683    // is printed.
684    if !parsed.username().is_empty() || parsed.password().is_some() {
685        return Err(ApiError::Config(
686            "the base URL carries a username or password; a control-plane \
687             credential is sent as a header and never in a URL"
688                .to_owned(),
689        ));
690    }
691    Ok(trimmed.to_owned())
692}
693
694/// One path segment, with everything that is not plainly safe escaped.
695///
696/// A tailnet is named by a domain and a device by an opaque identifier, so in
697/// practice nothing here needs escaping. The point is the case where something
698/// does: a `/` in a name would otherwise be a segment boundary, and a name is
699/// not always chosen by the person the path is built for.
700fn escape(segment: &str) -> String {
701    let mut out = String::with_capacity(segment.len());
702    for byte in segment.bytes() {
703        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~' | b'@') {
704            out.push(char::from(byte));
705        } else {
706            out.push_str(&format!("%{byte:02X}"));
707        }
708    }
709    out
710}
711
712#[cfg(test)]
713mod tests {
714    use std::collections::BTreeSet;
715    use std::path::PathBuf;
716
717    use serde_json::json;
718
719    use super::*;
720    use crate::fake::{FakeControlPlane, Response};
721    use crate::secret::Secret;
722
723    /// A key that is obviously not one.
724    const KEY: &str = "tskey-api-redacted-example";
725    const DEVICES: &str = "/api/v2/tailnet/-/devices";
726
727    async fn fake() -> FakeControlPlane {
728        FakeControlPlane::start()
729            .await
730            .expect("a loopback socket is available")
731    }
732
733    fn client(fake: &FakeControlPlane, credentials: Credentials) -> Client {
734        client_with(fake, credentials, |_| {})
735    }
736
737    fn client_with(
738        fake: &FakeControlPlane,
739        credentials: Credentials,
740        adjust: impl FnOnce(&mut ClientConfig),
741    ) -> Client {
742        let mut config = ClientConfig::new(credentials);
743        config.base_url = fake.base_url().to_owned();
744        adjust(&mut config);
745        Client::new(config).expect("the fake answers on a loopback address")
746    }
747
748    fn api_key() -> Credentials {
749        Credentials::ApiKey(Secret::new(KEY))
750    }
751
752    fn oauth() -> Credentials {
753        Credentials::OauthClient {
754            client_id: "kExAmPlE1CNTRL".to_owned(),
755            client_secret: Secret::new("tskey-client-redacted-example"),
756            scopes: vec!["devices:read".to_owned(), "dns".to_owned()],
757        }
758    }
759
760    /// A token endpoint answer worth `seconds`.
761    fn token(value: &str, seconds: u64) -> Response {
762        Response::json(json!({
763            "access_token": value,
764            "token_type": "Bearer",
765            "expires_in": seconds,
766        }))
767    }
768
769    /// Every `Authorization` header that arrived, in order.
770    fn bearers(fake: &FakeControlPlane) -> Vec<String> {
771        fake.recorded()
772            .into_iter()
773            .filter_map(|r| r.authorization().map(str::to_owned))
774            .collect()
775    }
776
777    // ---- authentication -------------------------------------------------
778
779    #[tokio::test]
780    async fn an_api_key_is_the_bearer_token_itself() {
781        let fake = fake()
782            .await
783            .on("GET", DEVICES, Response::json(json!({"devices": []})));
784        let client = client(&fake, api_key());
785
786        let answer = client.get(DEVICES).send().await.expect("the fake answers");
787
788        assert_eq!(answer, json!({"devices": []}));
789        // One request, so nothing was exchanged: a key is already a token.
790        let request = fake.only_request();
791        assert_eq!(
792            request.authorization(),
793            Some(format!("Bearer {KEY}").as_str())
794        );
795    }
796
797    #[tokio::test]
798    async fn an_oauth_client_is_exchanged_for_a_token() {
799        let fake = fake()
800            .await
801            .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
802            .on("GET", DEVICES, Response::json(json!({"devices": []})));
803        let client = client(&fake, oauth());
804
805        client.get(DEVICES).send().await.expect("the fake answers");
806
807        let recorded = fake.recorded();
808        assert_eq!(
809            recorded.len(),
810            2,
811            "an exchange and then the call: {recorded:#?}"
812        );
813        let exchange = &recorded[0];
814        assert_eq!(exchange.path, crate::token::TOKEN_PATH);
815        for expected in [
816            "grant_type=client_credentials",
817            "client_id=kExAmPlE1CNTRL",
818            "client_secret=tskey-client-redacted-example",
819            // OAuth spells a scope list space-separated whatever separator the
820            // environment variable used.
821            "scope=devices%3Aread+dns",
822        ] {
823            assert!(
824                exchange.body.contains(expected),
825                "the exchange did not send `{expected}`: {}",
826                exchange.body
827            );
828        }
829        assert_eq!(recorded[1].authorization(), Some("Bearer minted-1"));
830    }
831
832    #[tokio::test]
833    async fn a_federated_identity_signs_with_the_jwt_on_disk() {
834        let directory = tempfile::tempdir().expect("a temporary directory");
835        let jwt_file = directory.path().join("token");
836        std::fs::write(&jwt_file, "header.payload.signature\n").expect("the file is written");
837
838        let fake = fake()
839            .await
840            .on(
841                "POST",
842                crate::token::TOKEN_PATH,
843                token("minted-federated", 3600),
844            )
845            .on("GET", DEVICES, Response::json(json!({"devices": []})));
846        let client = client(
847            &fake,
848            Credentials::Federated {
849                client_id: Some("kExAmPlE1CNTRL".to_owned()),
850                jwt_file,
851                scopes: Vec::new(),
852            },
853        );
854
855        client.get(DEVICES).send().await.expect("the fake answers");
856
857        let exchange = &fake.recorded()[0];
858        assert!(
859            exchange
860                .body
861                .contains("client_assertion=header.payload.signature"),
862            "the JWT was not sent, or was sent with its trailing newline: {}",
863            exchange.body
864        );
865        assert!(
866            exchange.body.contains("client_assertion_type=urn%3Aietf"),
867            "the assertion type was not sent: {}",
868            exchange.body
869        );
870    }
871
872    #[tokio::test]
873    async fn a_missing_jwt_file_says_which_file_it_was() {
874        let fake = fake().await;
875        let client = client(
876            &fake,
877            Credentials::Federated {
878                client_id: None,
879                jwt_file: PathBuf::from("/nonexistent/identity/token"),
880                scopes: Vec::new(),
881            },
882        );
883
884        let error = client
885            .get(DEVICES)
886            .send()
887            .await
888            .expect_err("there is no file");
889
890        assert!(
891            matches!(&error, ApiError::JwtFile { path, .. } if path.ends_with("token")),
892            "unexpected error: {error:?}"
893        );
894        assert_eq!(fake.request_count(), 0, "nothing should have been sent");
895    }
896
897    #[tokio::test]
898    async fn the_credential_with_precedence_is_the_one_that_is_used() {
899        // Both are set, which is what an operator with an old key in a shell
900        // profile looks like. The key wins, so no exchange happens at all.
901        let environment = |key: &str| match key {
902            crate::credentials::API_KEY_ENV => Some(KEY.to_owned()),
903            crate::credentials::OAUTH_CLIENT_ID_ENV => Some("kExAmPlE1CNTRL".to_owned()),
904            crate::credentials::OAUTH_CLIENT_SECRET_ENV => Some("unused".to_owned()),
905            _ => None,
906        };
907        let credentials = Credentials::from_source(environment).expect("both are set");
908
909        let fake = fake().await.on("GET", DEVICES, Response::json(json!({})));
910        let client = client(&fake, credentials);
911        client.get(DEVICES).send().await.expect("the fake answers");
912
913        let request = fake.only_request();
914        assert_eq!(
915            request.authorization(),
916            Some(format!("Bearer {KEY}").as_str())
917        );
918    }
919
920    // ---- the token's life ------------------------------------------------
921
922    #[tokio::test]
923    async fn a_token_is_minted_once_and_reused() {
924        let fake = fake()
925            .await
926            .on("POST", crate::token::TOKEN_PATH, token("minted-1", 3600))
927            .on("GET", DEVICES, Response::json(json!({})));
928        let client = client(&fake, oauth());
929
930        for _ in 0..3 {
931            client.get(DEVICES).send().await.expect("the fake answers");
932        }
933
934        let exchanges = fake
935            .recorded()
936            .iter()
937            .filter(|r| r.path == crate::token::TOKEN_PATH)
938            .count();
939        assert_eq!(exchanges, 1, "the token should have been minted once");
940        // The exchange itself carries no bearer — it is how one is obtained —
941        // so the three calls are the three headers.
942        assert_eq!(bearers(&fake), vec!["Bearer minted-1".to_owned(); 3]);
943    }
944
945    #[tokio::test]
946    async fn a_token_near_its_expiry_is_minted_again() {
947        // Half a minute of life left, which is a token the clock still calls
948        // valid: only the refresh skew makes this one spent. A token that had
949        // already expired would be re-minted with no skew at all, and so would
950        // prove nothing about the last minute this deliberately gives up.
951        let remaining = crate::token::REFRESH_SKEW.as_secs() / 2;
952        let fake = fake()
953            .await
954            .on(
955                "POST",
956                crate::token::TOKEN_PATH,
957                token("minted-1", remaining),
958            )
959            .on("GET", DEVICES, Response::json(json!({})));
960        let client = client(&fake, oauth());
961
962        for _ in 0..2 {
963            client.get(DEVICES).send().await.expect("the fake answers");
964        }
965
966        let exchanges = fake
967            .recorded()
968            .iter()
969            .filter(|r| r.path == crate::token::TOKEN_PATH)
970            .count();
971        assert_eq!(exchanges, 2, "a token inside the skew should not be reused");
972    }
973
974    #[tokio::test]
975    async fn a_refused_token_is_replaced_exactly_once() {
976        let fake = fake()
977            .await
978            .once("POST", crate::token::TOKEN_PATH, token("stale", 3600))
979            .on("POST", crate::token::TOKEN_PATH, token("fresh", 3600))
980            .once(
981                "GET",
982                DEVICES,
983                Response::status(401, json!({"message": "expired"})),
984            )
985            .on("GET", DEVICES, Response::json(json!({"devices": []})));
986        let client = client(&fake, oauth());
987
988        let answer = client
989            .get(DEVICES)
990            .send()
991            .await
992            .expect("the second try works");
993
994        assert_eq!(answer, json!({"devices": []}));
995        assert_eq!(
996            bearers(&fake),
997            vec!["Bearer stale".to_owned(), "Bearer fresh".to_owned()],
998            "the refused token should have been replaced, once"
999        );
1000    }
1001
1002    #[tokio::test]
1003    async fn a_token_refused_twice_is_the_credential_being_wrong() {
1004        let fake = fake()
1005            .await
1006            .on("POST", crate::token::TOKEN_PATH, token("minted", 3600))
1007            .on(
1008                "GET",
1009                DEVICES,
1010                Response::status(401, json!({"message": "no"})),
1011            );
1012        let client = client(&fake, oauth());
1013
1014        let error = client
1015            .get(DEVICES)
1016            .send()
1017            .await
1018            .expect_err("it is always refused");
1019
1020        assert_eq!(error.status(), Some(401));
1021        let calls = fake.recorded().iter().filter(|r| r.path == DEVICES).count();
1022        assert_eq!(calls, 2, "one retry with a fresh token, and then no more");
1023    }
1024
1025    #[tokio::test]
1026    async fn a_refused_api_key_is_not_replaced_because_there_is_nothing_to_mint() {
1027        let fake = fake().await.on(
1028            "GET",
1029            DEVICES,
1030            Response::status(401, json!({"message": "no"})),
1031        );
1032        let client = client(&fake, api_key());
1033
1034        let error = client.get(DEVICES).send().await.expect_err("it is refused");
1035
1036        assert_eq!(error.status(), Some(401));
1037        assert_eq!(fake.request_count(), 1);
1038    }
1039
1040    // ---- retry -----------------------------------------------------------
1041
1042    #[tokio::test]
1043    async fn a_transient_failure_on_a_repeatable_method_is_retried() {
1044        let fake = fake()
1045            .await
1046            .once(
1047                "GET",
1048                DEVICES,
1049                Response::status(503, json!({"message": "later"})),
1050            )
1051            .on("GET", DEVICES, Response::json(json!({"devices": []})));
1052        let client = client(&fake, api_key());
1053
1054        let answer = client
1055            .get(DEVICES)
1056            .send()
1057            .await
1058            .expect("the second try works");
1059
1060        assert_eq!(answer, json!({"devices": []}));
1061        assert_eq!(fake.request_count(), 2);
1062    }
1063
1064    #[tokio::test]
1065    async fn a_transient_failure_on_an_unsafe_method_is_not_retried() {
1066        // The point of the rule: this is the shape of minting an auth key,
1067        // where a retry is a second key nobody asked for and nobody sees.
1068        let keys = "/api/v2/tailnet/-/keys";
1069        let fake = fake().await.on(
1070            "POST",
1071            keys,
1072            Response::status(503, json!({"message": "later"})),
1073        );
1074        let client = client(&fake, api_key());
1075
1076        let error = client
1077            .post(keys)
1078            .json(&json!({"capabilities": {}}))
1079            .send()
1080            .await
1081            .expect_err("the fake never succeeds");
1082
1083        assert_eq!(error.status(), Some(503));
1084        assert_eq!(fake.request_count(), 1, "a POST must not be sent twice");
1085    }
1086
1087    #[tokio::test]
1088    async fn a_rate_limit_is_retried_even_on_an_unsafe_method() {
1089        // A 429 says the server declined to act, so nothing happened and the
1090        // reason not to repeat a POST does not apply.
1091        let keys = "/api/v2/tailnet/-/keys";
1092        let fake = fake()
1093            .await
1094            .once(
1095                "POST",
1096                keys,
1097                Response::status(429, json!({"message": "slow down"}))
1098                    .with_header("retry-after", "0"),
1099            )
1100            .on(
1101                "POST",
1102                keys,
1103                Response::json(json!({"key": "tskey-auth-redacted-example"})),
1104            );
1105        let client = client(&fake, api_key());
1106
1107        let answer = client
1108            .post(keys)
1109            .json(&json!({"capabilities": {}}))
1110            .send()
1111            .await
1112            .expect("the second try works");
1113
1114        assert_eq!(answer["key"], json!("tskey-auth-redacted-example"));
1115        assert_eq!(fake.request_count(), 2);
1116    }
1117
1118    #[tokio::test]
1119    async fn a_permanent_failure_is_not_retried() {
1120        let fake = fake().await.on(
1121            "GET",
1122            DEVICES,
1123            Response::status(404, json!({"message": "no such tailnet"})),
1124        );
1125        let client = client(&fake, api_key());
1126
1127        let error = client
1128            .get(DEVICES)
1129            .send()
1130            .await
1131            .expect_err("there is nothing there");
1132
1133        assert!(
1134            matches!(&error, ApiError::Status { message, .. } if message == "no such tailnet"),
1135            "the API's own message should be passed on: {error:?}"
1136        );
1137        assert_eq!(fake.request_count(), 1);
1138    }
1139
1140    #[tokio::test]
1141    async fn a_call_stops_after_a_bounded_number_of_attempts() {
1142        let fake = fake().await.on(
1143            "GET",
1144            DEVICES,
1145            Response::status(503, json!({"message": "later"})).with_header("retry-after", "0"),
1146        );
1147        let client = client(&fake, api_key());
1148
1149        let error = client
1150            .get(DEVICES)
1151            .send()
1152            .await
1153            .expect_err("it never works");
1154
1155        assert_eq!(error.status(), Some(503));
1156        assert_eq!(fake.request_count(), MAX_ATTEMPTS as usize);
1157    }
1158
1159    #[tokio::test]
1160    async fn retrying_stops_when_the_budget_would_not_cover_the_wait() {
1161        // The budget is the tool's timeout. Sleeping past it would turn a
1162        // failure that says what went wrong into a bare timeout.
1163        let fake = fake().await.on(
1164            "GET",
1165            DEVICES,
1166            Response::status(503, json!({"message": "later"})),
1167        );
1168        let client = client(&fake, api_key());
1169
1170        let error = client
1171            .get(DEVICES)
1172            .budget(Duration::from_millis(50))
1173            .send()
1174            .await
1175            .expect_err("it never works");
1176
1177        assert_eq!(error.status(), Some(503), "not a bare timeout: {error:?}");
1178        assert_eq!(
1179            fake.request_count(),
1180            1,
1181            "the first backoff is longer than the budget"
1182        );
1183    }
1184
1185    #[tokio::test]
1186    async fn the_wait_a_server_asks_for_is_read_off_the_wire() {
1187        // `the_server_is_believed_about_when_to_come_back` builds the header's
1188        // value by hand and so proves only what `backoff` does with it. This
1189        // is the other half: the same status and the same budget, differing in
1190        // nothing but the header, produce different numbers of requests, which
1191        // they can only do if the number was read from the response.
1192        //
1193        // Both halves finish in the time one request takes. Five minutes is
1194        // clamped to `MAX_BACKOFF`, which is still longer than the budget, so
1195        // the call gives up rather than sleeping; zero is a wait of nothing.
1196        let budget = Duration::from_secs(1);
1197        let refusal = |wait| {
1198            Response::status(503, json!({"message": "later"})).with_header("retry-after", wait)
1199        };
1200
1201        let patient = fake().await.on("GET", DEVICES, refusal("300"));
1202        let error = client(&patient, api_key())
1203            .get(DEVICES)
1204            .budget(budget)
1205            .send()
1206            .await
1207            .expect_err("it never works");
1208        assert_eq!(error.status(), Some(503), "not a bare timeout: {error:?}");
1209        assert_eq!(
1210            patient.request_count(),
1211            1,
1212            "the server asked for longer than the budget, so there was no second try; \
1213             ignoring the header would have waited {BASE_BACKOFF:?} and tried again"
1214        );
1215
1216        let impatient = fake().await.on("GET", DEVICES, refusal("0"));
1217        client(&impatient, api_key())
1218            .get(DEVICES)
1219            .budget(budget)
1220            .send()
1221            .await
1222            .expect_err("it never works");
1223        assert_eq!(
1224            impatient.request_count(),
1225            MAX_ATTEMPTS as usize,
1226            "a server asking for no wait at all should be believed too"
1227        );
1228    }
1229
1230    #[test]
1231    fn the_server_is_believed_about_when_to_come_back() {
1232        let asked = |seconds| ApiError::Status {
1233            request: "GET /x".to_owned(),
1234            status: 429,
1235            message: String::new(),
1236            retry_after: Some(Duration::from_secs(seconds)),
1237        };
1238        // A 503 with no header, which is the ordinary case.
1239        let guessed = ApiError::Status {
1240            request: "GET /x".to_owned(),
1241            status: 503,
1242            message: String::new(),
1243            retry_after: None,
1244        };
1245
1246        assert_eq!(backoff(1, &asked(5)), Duration::from_secs(5));
1247        // A server asking for longer than any call budget is asking for the
1248        // budget to be spent asleep.
1249        assert_eq!(backoff(1, &asked(600)), MAX_BACKOFF);
1250        // Without a header, the wait doubles and then stops growing.
1251        assert_eq!(backoff(1, &guessed), BASE_BACKOFF);
1252        assert_eq!(backoff(2, &guessed), BASE_BACKOFF * 2);
1253        assert_eq!(backoff(30, &guessed), MAX_BACKOFF);
1254    }
1255
1256    #[test]
1257    fn only_the_methods_http_calls_idempotent_may_be_repeated() {
1258        for method in [
1259            reqwest::Method::GET,
1260            reqwest::Method::HEAD,
1261            reqwest::Method::PUT,
1262            reqwest::Method::DELETE,
1263        ] {
1264            assert_eq!(idempotence(&method), Idempotence::Repeatable, "{method}");
1265        }
1266        for method in [reqwest::Method::POST, reqwest::Method::PATCH] {
1267            assert_eq!(idempotence(&method), Idempotence::Once, "{method}");
1268        }
1269    }
1270
1271    // ---- concurrency -----------------------------------------------------
1272
1273    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1274    async fn no_more_calls_are_in_flight_than_the_limit_allows() {
1275        const LIMIT: usize = 2;
1276        let fake = fake().await.on(
1277            "GET",
1278            DEVICES,
1279            Response::json(json!({})).slow(Duration::from_millis(80)),
1280        );
1281        let client = client_with(&fake, api_key(), |config| config.concurrency = LIMIT);
1282
1283        let calls: Vec<_> = (0..8)
1284            .map(|_| {
1285                let client = client.clone();
1286                tokio::spawn(async move { client.get(DEVICES).send().await })
1287            })
1288            .collect();
1289        for call in calls {
1290            call.await
1291                .expect("the task finished")
1292                .expect("the fake answers");
1293        }
1294
1295        assert_eq!(fake.request_count(), 8);
1296        let peak = fake.peak_concurrency();
1297        assert!(
1298            (1..=LIMIT).contains(&peak),
1299            "{peak} calls were in flight at once, and the limit is {LIMIT}"
1300        );
1301    }
1302
1303    // ---- the size cap ----------------------------------------------------
1304
1305    #[tokio::test]
1306    async fn an_answer_over_the_cap_is_refused_rather_than_truncated() {
1307        let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1308        let fake = fake().await.on("GET", DEVICES, Response::json(&big));
1309        let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1310
1311        let error = client
1312            .get(DEVICES)
1313            .send()
1314            .await
1315            .expect_err("it is too large");
1316
1317        assert!(
1318            matches!(error, ApiError::TooLarge { cap: 64, .. }),
1319            "a truncated body would have failed to parse instead: {error:?}"
1320        );
1321    }
1322
1323    #[tokio::test]
1324    async fn an_answer_with_no_stated_length_is_refused_while_it_is_read() {
1325        // Chunked, so the cap cannot be checked before the transfer and has to
1326        // hold while the body arrives.
1327        let big = json!({"devices": vec![json!({"name": "x".repeat(200)})]});
1328        let fake = fake()
1329            .await
1330            .on("GET", DEVICES, Response::json(&big).chunked());
1331        let client = client_with(&fake, api_key(), |config| config.max_response_bytes = 64);
1332
1333        let error = client
1334            .get(DEVICES)
1335            .send()
1336            .await
1337            .expect_err("it is too large");
1338
1339        assert!(
1340            matches!(error, ApiError::TooLarge { cap: 64, .. }),
1341            "unexpected error: {error:?}"
1342        );
1343    }
1344
1345    #[tokio::test]
1346    async fn an_answer_under_the_cap_arrives_whole_however_it_is_framed() {
1347        let body = json!({"devices": [{"name": "workstation"}]});
1348        let fake = fake()
1349            .await
1350            .on("GET", DEVICES, Response::json(&body).chunked());
1351        let client = client(&fake, api_key());
1352
1353        let answer = client.get(DEVICES).send().await.expect("the fake answers");
1354
1355        assert_eq!(answer, body);
1356    }
1357
1358    // ---- shapes of a call ------------------------------------------------
1359
1360    #[tokio::test]
1361    async fn an_empty_body_is_an_answer_rather_than_a_parse_failure() {
1362        let device = "/api/v2/device/n1111111CNTRL";
1363        let fake = fake().await.on("DELETE", device, Response::empty());
1364        let client = client(&fake, api_key());
1365
1366        let answer = client
1367            .delete(device)
1368            .send()
1369            .await
1370            .expect("the fake answers");
1371
1372        assert_eq!(answer, Value::Null, "a deletion answers with nothing");
1373    }
1374
1375    #[tokio::test]
1376    async fn an_answer_is_read_both_ways_from_one_parse() {
1377        // The reason `Answer` holds both halves (ADR-0003): a tool forwards
1378        // `raw` so the caller sees every field the control plane sent, and the
1379        // server reads `value` when it has to act. They come from one parse, so
1380        // a field in one is a field in the other.
1381        let body = json!({
1382            "id": "kExAmPlE",
1383            "description": "a key",
1384            "invented": {"by": "a later control plane"},
1385        });
1386        let keys = "/api/v2/tailnet/-/keys/kExAmPlE";
1387        let fake = fake().await.on("GET", keys, Response::json(&body));
1388        let client = client(&fake, api_key());
1389
1390        let answer = client
1391            .get(keys)
1392            .send_answer::<crate::models::key::Key>()
1393            .await
1394            .expect("the fake answers");
1395
1396        assert_eq!(answer.value.id.as_deref(), Some("kExAmPlE"));
1397        assert_eq!(
1398            answer.value.unknown.get("invented"),
1399            Some(&json!({"by": "a later control plane"})),
1400            "the typed half keeps what it had no field for"
1401        );
1402        assert_eq!(answer.raw, body, "and the raw half is the body, untouched");
1403    }
1404
1405    #[tokio::test]
1406    async fn an_answer_carries_the_etag_that_versions_it() {
1407        // The other thing ADR-0003 asks `Answer` to carry. The policy file is
1408        // the endpoint that needs it: a write has to quote the `ETag` it read.
1409        let acl = "/api/v2/tailnet/-/acl";
1410        let fake = fake().await.on(
1411            "GET",
1412            acl,
1413            Response::json(json!({"acls": []})).with_header("ETag", "\"abc123\""),
1414        );
1415        let client = client(&fake, api_key());
1416
1417        let answer = client
1418            .get(acl)
1419            .send_answer::<Value>()
1420            .await
1421            .expect("the fake answers");
1422
1423        assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1424    }
1425
1426    #[tokio::test]
1427    async fn an_empty_body_answers_as_nothing_rather_than_failing_to_parse() {
1428        // What a deletion sends, read through `send_answer` rather than
1429        // `send`. `Value` reads null; a model could not, which is documented
1430        // on `send_answer` and is why deletions ask for `Value`.
1431        let device = "/api/v2/device/n1111111CNTRL";
1432        let fake = fake().await.on("DELETE", device, Response::empty());
1433        let client = client(&fake, api_key());
1434
1435        let answer = client
1436            .delete(device)
1437            .send_answer::<Value>()
1438            .await
1439            .expect("the fake answers");
1440
1441        assert_eq!(answer.value, Value::Null);
1442        assert_eq!(answer.raw, Value::Null, "both halves agree about nothing");
1443    }
1444
1445    #[tokio::test]
1446    async fn the_query_and_the_body_reach_the_control_plane_as_written() {
1447        let fake = fake().await.on("POST", DEVICES, Response::json(json!({})));
1448        let client = client(&fake, api_key());
1449
1450        client
1451            .post(DEVICES)
1452            .query("fields", "all")
1453            .maybe_query("since", Some(7))
1454            .maybe_query("until", Option::<u8>::None)
1455            .header("If-Match", "\"v1\"")
1456            .json(&json!({"name": "workstation"}))
1457            .send()
1458            .await
1459            .expect("the fake answers");
1460
1461        let request = fake.only_request();
1462        assert_eq!(
1463            request
1464                .query
1465                .keys()
1466                .map(String::as_str)
1467                .collect::<BTreeSet<_>>(),
1468            BTreeSet::from(["fields", "since"]),
1469            "an absent parameter should not be sent"
1470        );
1471        assert_eq!(request.query["fields"], "all");
1472        assert_eq!(
1473            request.headers.get("if-match").map(String::as_str),
1474            Some("\"v1\"")
1475        );
1476        assert_eq!(request.json(), json!({"name": "workstation"}));
1477    }
1478
1479    #[tokio::test]
1480    async fn text_comes_back_with_the_version_it_was_read_at() {
1481        // The policy file is HuJSON, which is not JSON to parse, and its ETag
1482        // is what a later write has to quote.
1483        let policy = "/api/v2/tailnet/-/acl";
1484        let hujson = "{\n  // a comment, which JSON does not have\n  \"acls\": [],\n}";
1485        let fake = fake().await.on(
1486            "GET",
1487            policy,
1488            Response {
1489                status: 200,
1490                headers: vec![("content-type".to_owned(), "application/hujson".to_owned())],
1491                body: hujson.to_owned(),
1492                delay: Duration::ZERO,
1493                chunked: false,
1494            }
1495            .with_header("etag", "\"abc123\""),
1496        );
1497        let client = client(&fake, api_key());
1498
1499        let answer = client
1500            .get(policy)
1501            .send_text()
1502            .await
1503            .expect("the fake answers");
1504
1505        assert_eq!(answer.text, hujson);
1506        assert_eq!(answer.etag.as_deref(), Some("\"abc123\""));
1507    }
1508
1509    #[tokio::test]
1510    async fn a_body_that_is_not_what_was_asked_for_says_so() {
1511        #[derive(Debug, serde::Deserialize)]
1512        struct Devices {
1513            #[allow(dead_code)]
1514            devices: Vec<String>,
1515        }
1516        let fake = fake()
1517            .await
1518            .on("GET", DEVICES, Response::json(json!({"devices": 7})));
1519        let client = client(&fake, api_key());
1520
1521        let error = client
1522            .get(DEVICES)
1523            .send_as::<Devices>()
1524            .await
1525            .expect_err("seven is not a list");
1526
1527        assert!(
1528            matches!(&error, ApiError::Malformed { request, .. } if request == "GET /api/v2/tailnet/-/devices"),
1529            "unexpected error: {error:?}"
1530        );
1531    }
1532
1533    // ---- where a client may point ----------------------------------------
1534
1535    #[test]
1536    fn a_base_url_is_an_encrypted_host_and_nothing_more() {
1537        // Deliberately not "…is the control plane": the host is not pinned,
1538        // and a name that said so would be describing a guarantee this does
1539        // not make. What is checked is how the credential travels, which is
1540        // the three things below.
1541        for allowed in [
1542            DEFAULT_BASE_URL,
1543            "https://api.example.com",
1544            "https://example.com",
1545            "http://127.0.0.1:8080",
1546            "http://localhost:9999",
1547            "http://[::1]:1234",
1548        ] {
1549            assert!(
1550                checked_base_url(allowed).is_ok(),
1551                "{allowed} should have been accepted"
1552            );
1553        }
1554        for refused in [
1555            // Plaintext to anywhere but this machine sends the credential in
1556            // the clear.
1557            "http://api.tailscale.com",
1558            "http://evil.example.com",
1559            // Not a URL, and a scheme that is not HTTP at all.
1560            "api.tailscale.com",
1561            "ftp://api.tailscale.com",
1562            // A base URL is a host; a path here would silently prefix every
1563            // call, which is a different server wearing the same name.
1564            "https://api.tailscale.com/api/v2",
1565            // Userinfo is a secret written where secrets get printed, and
1566            // this server sends its credential as a header regardless. The
1567            // host is `example.com`, which is accepted bare just above, so
1568            // userinfo is the only thing these two differ by.
1569            "https://user:pass@example.com",
1570            "https://token@example.com",
1571        ] {
1572            assert!(
1573                checked_base_url(refused).is_err(),
1574                "{refused} should have been refused"
1575            );
1576        }
1577        // A trailing slash is how a URL is usually written down, and joining
1578        // it to a path that starts with one would double it.
1579        assert_eq!(
1580            checked_base_url("https://api.tailscale.com/").expect("a valid URL"),
1581            DEFAULT_BASE_URL
1582        );
1583    }
1584
1585    #[test]
1586    fn a_name_in_a_path_cannot_reach_into_the_path_around_it() {
1587        let fake_config = ClientConfig::new(api_key());
1588        let client = Client::new(fake_config).expect("the default base URL is valid");
1589
1590        assert_eq!(
1591            client.tailnet_path(None, "/devices"),
1592            "/api/v2/tailnet/-/devices"
1593        );
1594        assert_eq!(
1595            client.tailnet_path(Some("example.com"), "/dns/nameservers"),
1596            "/api/v2/tailnet/example.com/dns/nameservers"
1597        );
1598        // An empty name means "not given" rather than an empty segment.
1599        assert_eq!(
1600            client.tailnet_path(Some("  "), "/devices"),
1601            "/api/v2/tailnet/-/devices"
1602        );
1603        // The point: a slash in a name is a character, not a boundary.
1604        assert_eq!(
1605            client.tailnet_path(Some("../../device/n1111111CNTRL"), "/devices"),
1606            "/api/v2/tailnet/..%2F..%2Fdevice%2Fn1111111CNTRL/devices"
1607        );
1608    }
1609
1610    #[test]
1611    fn a_client_that_could_not_work_is_refused_at_the_start() {
1612        for (what, adjust) in [
1613            (
1614                "no calls in flight",
1615                Box::new(|c: &mut ClientConfig| c.concurrency = 0) as Box<dyn FnOnce(&mut _)>,
1616            ),
1617            (
1618                "no bytes allowed back",
1619                Box::new(|c: &mut ClientConfig| c.max_response_bytes = 0),
1620            ),
1621        ] {
1622            let mut config = ClientConfig::new(api_key());
1623            adjust(&mut config);
1624            assert!(
1625                matches!(Client::new(config), Err(ApiError::Config(_))),
1626                "{what} should have been refused"
1627            );
1628        }
1629    }
1630}