Skip to main content

desec/
client.rs

1//! The HTTP client: construction, authentication, retries and request execution.
2
3use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{self, HeaderMap, HeaderValue};
8use reqwest::{Method, StatusCode};
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use tracing::Instrument;
12use url::Url;
13
14use crate::error::{ApiError, Error, InvalidValue, Result, truncate};
15use crate::ratelimit::{Limiter, RateLimits, ScopeSet};
16
17/// The public deSEC API.
18pub const DEFAULT_BASE_URL: &str = "https://desec.io/api/v1";
19
20/// `User-Agent` sent unless the builder overrides it.
21pub const DEFAULT_USER_AGENT: &str = concat!("desec-rs/", env!("CARGO_PKG_VERSION"));
22
23/// Ceiling applied to a `Retry-After` header.
24///
25/// No real throttle asks for longer than a day, and the value is used in `Instant`
26/// arithmetic that panics on overflow, so an absurd header is clamped rather than
27/// trusted.
28pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(86_400);
29
30/// A credential that must not appear in logs.
31///
32/// `Debug` and `Display` both render a placeholder, which is what stops a token from
33/// riding along in a `{:?}` of a [`Token`](crate::api::tokens::Token) or an error. Reach
34/// for [`expose`](Secret::expose) at the point of use, so every place a secret escapes is
35/// greppable.
36///
37/// `Serialize` is *not* redacted — request bodies for login and registration need the real
38/// value — so do not put a `Secret` in a structure that gets serialized to a log sink.
39#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40#[serde(transparent)]
41pub struct Secret(String);
42
43impl Secret {
44    /// Wraps a secret value.
45    pub fn new(secret: impl Into<String>) -> Self {
46        Self(secret.into())
47    }
48
49    /// The underlying value.
50    pub fn expose(&self) -> &str {
51        &self.0
52    }
53}
54
55impl<T: Into<String>> From<T> for Secret {
56    fn from(value: T) -> Self {
57        Self::new(value)
58    }
59}
60
61impl fmt::Debug for Secret {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str("Secret(<redacted>)")
64    }
65}
66
67impl fmt::Display for Secret {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str("<redacted>")
70    }
71}
72
73/// How a client authenticates.
74#[derive(Debug, Clone, Default)]
75pub(crate) enum Auth {
76    /// Unauthenticated, which only the registration and captcha endpoints allow.
77    #[default]
78    None,
79    /// `Authorization: Token <secret>`, the scheme for the whole REST API.
80    Token(Secret),
81    /// HTTP Basic, which only the dynDNS update endpoint accepts.
82    Basic { username: String, password: Secret },
83}
84
85impl Auth {
86    fn header(&self) -> Option<Result<HeaderValue, InvalidValue>> {
87        let value = match self {
88            Self::None => return None,
89            Self::Token(secret) => format!("Token {}", secret.expose()),
90            Self::Basic { username, password } => {
91                format!(
92                    "Basic {}",
93                    base64_standard(format!("{username}:{}", password.expose()).as_bytes())
94                )
95            }
96        };
97        Some(HeaderValue::from_str(&value).map_err(|_| {
98            // A token holding a control character would otherwise fail deep inside
99            // reqwest with no indication of which value was at fault.
100            InvalidValue::new(
101                "credential",
102                "contains characters that cannot go in an HTTP header",
103                "<redacted>",
104            )
105        }))
106    }
107}
108
109/// Whether a request may be sent again after an unknown outcome.
110///
111/// This gates retries of `5xx` responses and mid-flight transport failures, where the
112/// server may have processed the request before the failure. It deliberately does not
113/// gate `429` retries: a throttled request was rejected before processing, so replaying
114/// any method is safe.
115///
116/// `PATCH` is excluded because the API allows it to create RRsets, so it is not
117/// idempotent in general even though most uses of it are.
118fn is_replayable(method: &Method) -> bool {
119    matches!(
120        *method,
121        Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS
122    )
123}
124
125/// Minimal base64, so HTTP Basic for dynDNS does not need a dependency.
126fn base64_standard(input: &[u8]) -> String {
127    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
128    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
129    for chunk in input.chunks(3) {
130        let b = [
131            chunk[0],
132            chunk.get(1).copied().unwrap_or(0),
133            chunk.get(2).copied().unwrap_or(0),
134        ];
135        let bits = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
136        for i in 0..4 {
137            if i <= chunk.len() {
138                let index = (bits >> (18 - 6 * i)) & 0x3f;
139                out.push(char::from(ALPHABET[index as usize]));
140            } else {
141                out.push('=');
142            }
143        }
144    }
145    out
146}
147
148/// Retry behaviour for throttled and transiently failed requests.
149#[derive(Debug, Clone)]
150pub(crate) struct RetryConfig {
151    /// Retries after the first attempt. Zero disables retrying.
152    pub(crate) max_retries: u32,
153    /// Longest single sleep the client will accept, whether from `Retry-After` or
154    /// backoff. A longer wait fails instead.
155    pub(crate) max_delay: Duration,
156    /// First backoff step for server errors; doubles per attempt.
157    pub(crate) initial_backoff: Duration,
158}
159
160impl Default for RetryConfig {
161    fn default() -> Self {
162        Self {
163            max_retries: 3,
164            max_delay: Duration::from_secs(60),
165            initial_backoff: Duration::from_millis(500),
166        }
167    }
168}
169
170#[derive(Debug)]
171struct Inner {
172    http: reqwest::Client,
173    base: Url,
174    auth: Auth,
175    /// Shared so that a client derived by [`Client::with_token`] paces itself against the
176    /// same buckets: the account and the source address are what the server throttles, not
177    /// the individual credential.
178    limiter: Arc<Limiter>,
179    retry: RetryConfig,
180}
181
182/// An asynchronous deSEC API client.
183///
184/// Cheap to clone: clones share one connection pool and one rate-limiter state, which is
185/// what makes the client-side limits meaningful across concurrent tasks.
186///
187/// ```no_run
188/// # async fn run() -> Result<(), desec::Error> {
189/// let client = desec::Client::builder()
190///     .token("i-T3b1h_OI-H9ab8tRS98stGtURe")
191///     .build()?;
192///
193/// let domains = client.domains().list().all().await?;
194/// # Ok(())
195/// # }
196/// ```
197#[derive(Debug, Clone)]
198pub struct Client {
199    inner: Arc<Inner>,
200}
201
202impl Client {
203    /// Starts building a client.
204    pub fn builder() -> ClientBuilder {
205        ClientBuilder::default()
206    }
207
208    /// A client authenticated with an API token, with every default in place.
209    pub fn new(token: impl Into<Secret>) -> Result<Self> {
210        Self::builder().token(token).build()
211    }
212
213    /// The base URL requests are built against.
214    pub fn base_url(&self) -> &Url {
215        &self.inner.base
216    }
217
218    /// A client identical to this one but authenticating with a different token.
219    ///
220    /// Shares the connection pool and the rate-limiter state, so switching from an
221    /// unauthenticated client to a login token does not reset the buckets:
222    ///
223    /// ```no_run
224    /// # async fn run() -> Result<(), desec::Error> {
225    /// let anonymous = desec::Client::builder().build()?;
226    /// let login = anonymous
227    ///     .account()
228    ///     .log_in("you@example.com", &desec::Secret::new("hunter2"))
229    ///     .await?;
230    /// let secret = login.token.expect("a login response carries the secret");
231    /// let client = anonymous.with_token(secret);
232    /// # Ok(())
233    /// # }
234    /// ```
235    pub fn with_token(&self, token: impl Into<Secret>) -> Self {
236        Self {
237            inner: Arc::new(Inner {
238                http: self.inner.http.clone(),
239                base: self.inner.base.clone(),
240                auth: Auth::Token(token.into()),
241                limiter: Arc::clone(&self.inner.limiter),
242                retry: self.inner.retry.clone(),
243            }),
244        }
245    }
246
247    /// Builds a request URL by appending percent-encoded path segments to the base,
248    /// always with the trailing slash the API requires.
249    pub(crate) fn url(&self, segments: &[&str]) -> Url {
250        let mut url = self.inner.base.clone();
251        {
252            // The builder rejects any base URL that cannot be a base, so this holds.
253            #[expect(clippy::expect_used)]
254            let mut path = url
255                .path_segments_mut()
256                .expect("base URL was validated as a base");
257            for segment in segments {
258                path.push(segment);
259            }
260            path.push("");
261        }
262        url
263    }
264
265    pub(crate) fn request(&self, method: Method, url: Url, scopes: ScopeSet) -> Req {
266        Req {
267            method,
268            url,
269            body: None,
270            scopes,
271        }
272    }
273
274    /// Sends a request, applying rate limits and retries, and maps an error status onto
275    /// [`Error::Api`].
276    pub(crate) async fn send(&self, req: Req) -> Result<Res> {
277        let res = self.execute(req).await?;
278        if res.status.is_client_error() || res.status.is_server_error() {
279            let body = ApiError::parse(&res.text_lossy());
280            return Err(Error::Api {
281                status: res.status,
282                method: res.method,
283                path: res.path,
284                detail: body.to_string(),
285                body,
286            });
287        }
288        Ok(res)
289    }
290
291    /// Sends a request and decodes a JSON body.
292    pub(crate) async fn send_json<T: DeserializeOwned>(&self, req: Req) -> Result<T> {
293        let res = self.send(req).await?;
294        res.json()
295    }
296
297    /// Sends a request and decodes a JSON body, mapping `404` onto `None`.
298    pub(crate) async fn send_json_opt<T: DeserializeOwned>(&self, req: Req) -> Result<Option<T>> {
299        match self.send(req).await {
300            Ok(res) => res.json().map(Some),
301            Err(err) if err.is_not_found() => Ok(None),
302            Err(err) => Err(err),
303        }
304    }
305
306    /// Sends a request and discards the body.
307    pub(crate) async fn send_empty(&self, req: Req) -> Result<()> {
308        self.send(req).await.map(drop)
309    }
310
311    /// Sends a request and returns the body as text, for the zonefile endpoint.
312    pub(crate) async fn send_text(&self, req: Req) -> Result<String> {
313        Ok(self.send(req).await?.text_lossy())
314    }
315
316    /// One request, including rate limiting and retries. Status is not interpreted here.
317    async fn execute(&self, req: Req) -> Result<Res> {
318        let Req {
319            method,
320            url,
321            body,
322            scopes,
323        } = req;
324        let path = url.path().to_owned();
325
326        let span = tracing::debug_span!(
327            "desec.request",
328            http.method = %method,
329            url.path = %path,
330        );
331
332        async move {
333            // Built before the first slot is claimed: a credential that cannot go in a
334            // header fails locally, and there is no reason for that to cost quota.
335            let auth = self.inner.auth.header().transpose()?;
336
337            let mut attempt = 0u32;
338            loop {
339                attempt += 1;
340                self.inner.limiter.acquire(&scopes).await?;
341
342                let mut builder = self.inner.http.request(method.clone(), url.clone());
343                if let Some(body) = &body {
344                    builder = builder
345                        .header(header::CONTENT_TYPE, "application/json")
346                        .body(body.clone());
347                }
348                if let Some(auth) = &auth {
349                    builder = builder.header(header::AUTHORIZATION, auth.clone());
350                }
351
352                let outcome = match builder.send().await {
353                    Ok(response) => {
354                        let status = response.status();
355                        let headers = response.headers().clone();
356                        match response.bytes().await {
357                            Ok(bytes) => Ok(Res {
358                                status,
359                                headers,
360                                body: bytes.to_vec(),
361                                method: method.clone(),
362                                path: path.clone(),
363                                url: url.clone(),
364                            }),
365                            Err(err) => Err(err),
366                        }
367                    }
368                    Err(err) => Err(err),
369                };
370
371                let res = match outcome {
372                    Ok(res) => res,
373                    Err(err) => {
374                        // A connect or timeout failure may still have been processed by
375                        // the server, so replaying it is only safe for an idempotent
376                        // method. A malformed URL or a decode failure is never worth a
377                        // second attempt.
378                        let transient = err.is_timeout() || err.is_connect() || err.is_request();
379                        let retryable = transient && is_replayable(&method);
380                        // Scrubbed before logging, because the reqwest error's own
381                        // rendering would otherwise carry the query string.
382                        let err = Error::transport(err);
383                        if retryable && attempt <= self.inner.retry.max_retries {
384                            let delay = self.backoff(attempt);
385                            tracing::warn!(
386                                attempt,
387                                delay_ms = delay.as_millis(),
388                                error = %err,
389                                "request failed, retrying"
390                            );
391                            tokio::time::sleep(delay).await;
392                            continue;
393                        }
394                        return Err(err);
395                    }
396                };
397
398                tracing::debug!(
399                    attempt,
400                    http.status = res.status.as_u16(),
401                    body_bytes = res.body.len(),
402                    "response"
403                );
404
405                if res.status == StatusCode::TOO_MANY_REQUESTS {
406                    let retry_after = res.retry_after();
407                    self.inner.limiter.record_throttled(&scopes, retry_after);
408
409                    let delay = retry_after.unwrap_or_else(|| self.backoff(attempt));
410                    if attempt > self.inner.retry.max_retries || delay > self.inner.retry.max_delay
411                    {
412                        tracing::warn!(
413                            attempt,
414                            retry_after_s = retry_after.map(|d| d.as_secs()),
415                            "giving up on a throttled request"
416                        );
417                        return Err(Error::RateLimited {
418                            attempts: attempt,
419                            retry_after,
420                            body: ApiError::parse(&res.text_lossy()),
421                        });
422                    }
423                    tracing::info!(
424                        attempt,
425                        delay_ms = delay.as_millis(),
426                        "throttled by the server, waiting"
427                    );
428                    tokio::time::sleep(delay).await;
429                    continue;
430                }
431
432                // 5xx is worth a retry, but only where replaying is safe: the server may
433                // have processed the request before failing, and re-POSTing would mint a
434                // second token or send a second confirmation email. 4xx other than 429
435                // will not change on its own.
436                if res.status.is_server_error()
437                    && is_replayable(&method)
438                    && attempt <= self.inner.retry.max_retries
439                {
440                    let delay = self.backoff(attempt);
441                    tracing::warn!(
442                        attempt,
443                        http.status = res.status.as_u16(),
444                        delay_ms = delay.as_millis(),
445                        "server error, retrying"
446                    );
447                    tokio::time::sleep(delay).await;
448                    continue;
449                }
450
451                return Ok(res);
452            }
453        }
454        .instrument(span)
455        .await
456    }
457
458    /// Exponential backoff, capped at the configured ceiling.
459    fn backoff(&self, attempt: u32) -> Duration {
460        let factor = 1u32 << attempt.min(16).saturating_sub(1);
461        self.inner
462            .retry
463            .initial_backoff
464            .saturating_mul(factor)
465            .min(self.inner.retry.max_delay)
466    }
467}
468
469/// A request under construction.
470pub(crate) struct Req {
471    method: Method,
472    url: Url,
473    body: Option<Vec<u8>>,
474    scopes: ScopeSet,
475}
476
477impl Req {
478    /// Appends a query parameter, percent-encoding the value.
479    pub(crate) fn query(mut self, key: &str, value: &str) -> Self {
480        self.url.query_pairs_mut().append_pair(key, value);
481        self
482    }
483
484    /// Serializes `body` as the JSON request body.
485    pub(crate) fn json<T: Serialize + ?Sized>(mut self, body: &T) -> Result<Self> {
486        self.body = Some(serde_json::to_vec(body).map_err(Error::Encode)?);
487        Ok(self)
488    }
489
490    pub(crate) fn url_mut(&mut self) -> &mut Url {
491        &mut self.url
492    }
493}
494
495/// A response whose body has been read into memory.
496pub(crate) struct Res {
497    pub(crate) status: StatusCode,
498    pub(crate) headers: HeaderMap,
499    pub(crate) body: Vec<u8>,
500    pub(crate) method: Method,
501    pub(crate) path: String,
502    /// The URL that was requested, so `Link` headers can be resolved against it.
503    pub(crate) url: Url,
504}
505
506impl Res {
507    pub(crate) fn json<T: DeserializeOwned>(&self) -> Result<T> {
508        serde_json::from_slice(&self.body).map_err(|source| Error::Decode {
509            expected: std::any::type_name::<T>(),
510            body: truncate(&self.text_lossy(), 2048),
511            source,
512        })
513    }
514
515    pub(crate) fn text_lossy(&self) -> String {
516        String::from_utf8_lossy(&self.body).into_owned()
517    }
518
519    pub(crate) fn header(&self, name: header::HeaderName) -> Option<&str> {
520        self.headers.get(name)?.to_str().ok()
521    }
522
523    /// `Retry-After` as a duration, accepting both forms the HTTP spec allows.
524    ///
525    /// Clamped to [`MAX_RETRY_AFTER`]. The header is attacker- or proxy-controlled and
526    /// otherwise unbounded, and the value reaches `Instant` arithmetic, which panics on
527    /// overflow.
528    fn retry_after(&self) -> Option<Duration> {
529        let raw = self.header(header::RETRY_AFTER)?.trim().to_owned();
530        if let Ok(secs) = raw.parse::<u64>() {
531            return Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER));
532        }
533        // An HTTP-date, which deSEC does not currently send but the spec permits.
534        let deadline = chrono::DateTime::parse_from_rfc2822(&raw).ok()?;
535        let delta = deadline.signed_duration_since(chrono::Utc::now());
536        Some(delta.to_std().ok()?.min(MAX_RETRY_AFTER))
537    }
538}
539
540/// Builds a [`Client`].
541#[derive(Debug, Default)]
542pub struct ClientBuilder {
543    base: Option<String>,
544    auth: Auth,
545    user_agent: Option<String>,
546    timeout: Option<Duration>,
547    rate_limits: Option<RateLimits>,
548    max_rate_limit_wait: Option<Duration>,
549    retry: RetryConfig,
550    http: Option<reqwest::Client>,
551}
552
553impl ClientBuilder {
554    /// Authenticates with an API token, or a login token from
555    /// [`log_in`](crate::api::AccountApi::log_in).
556    pub fn token(mut self, token: impl Into<Secret>) -> Self {
557        self.auth = Auth::Token(token.into());
558        self
559    }
560
561    /// Authenticates with HTTP Basic.
562    ///
563    /// Only the dynDNS update endpoint accepts this; the REST API needs
564    /// [`token`](Self::token).
565    pub fn basic_auth(mut self, username: impl Into<String>, password: impl Into<Secret>) -> Self {
566        self.auth = Auth::Basic {
567            username: username.into(),
568            password: password.into(),
569        };
570        self
571    }
572
573    /// Overrides the API root. Defaults to [`DEFAULT_BASE_URL`].
574    ///
575    /// Point this at a mock server in tests, or at a self-hosted desec-stack.
576    pub fn base_url(mut self, base: impl Into<String>) -> Self {
577        self.base = Some(base.into());
578        self
579    }
580
581    /// Overrides the `User-Agent`.
582    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
583        self.user_agent = Some(user_agent.into());
584        self
585    }
586
587    /// Total timeout per attempt.
588    pub fn timeout(mut self, timeout: Duration) -> Self {
589        self.timeout = Some(timeout);
590        self
591    }
592
593    /// Replaces the client-side rate limits.
594    ///
595    /// Defaults to [`RateLimits::desec_defaults`]. Pass [`RateLimits::unlimited`] to send
596    /// requests as fast as the caller asks and deal with `429`s reactively.
597    pub fn rate_limits(mut self, limits: RateLimits) -> Self {
598        self.rate_limits = Some(limits);
599        self
600    }
601
602    /// Longest the client-side limiter may sleep before giving up with
603    /// [`Error::RateLimitWouldBlock`].
604    ///
605    /// Defaults to 60 seconds, which admits the per-second and per-minute buckets but
606    /// fails fast when an hourly or daily bucket is exhausted, rather than parking a task
607    /// for hours.
608    pub fn max_rate_limit_wait(mut self, max_wait: Duration) -> Self {
609        self.max_rate_limit_wait = Some(max_wait);
610        self
611    }
612
613    /// Retries after the first attempt, for `429`s, `5xx`s and connection failures.
614    /// Defaults to 3; zero disables retrying.
615    pub fn max_retries(mut self, retries: u32) -> Self {
616        self.retry.max_retries = retries;
617        self
618    }
619
620    /// Longest single retry sleep to accept. Defaults to 60 seconds.
621    ///
622    /// A `Retry-After` longer than this fails with [`Error::RateLimited`] instead of
623    /// blocking.
624    pub fn max_retry_delay(mut self, delay: Duration) -> Self {
625        self.retry.max_delay = delay;
626        self
627    }
628
629    /// Supplies a preconfigured [`reqwest::Client`], for proxy or TLS settings this
630    /// builder does not expose. Overrides [`timeout`](Self::timeout) and
631    /// [`user_agent`](Self::user_agent).
632    pub fn http_client(mut self, http: reqwest::Client) -> Self {
633        self.http = Some(http);
634        self
635    }
636
637    /// Finishes the client.
638    pub fn build(self) -> Result<Client> {
639        let raw = self.base.as_deref().unwrap_or(DEFAULT_BASE_URL);
640        let mut base = Url::parse(raw)?;
641        if !matches!(base.scheme(), "http" | "https") {
642            return Err(InvalidValue::new("base_url", "must be http or https", raw).into());
643        }
644        {
645            // Normalize away a trailing slash so appending segments cannot produce `//`.
646            let mut segments = base
647                .path_segments_mut()
648                .map_err(|()| InvalidValue::new("base_url", "cannot be a base URL", raw))?;
649            segments.pop_if_empty();
650        }
651        base.set_query(None);
652        base.set_fragment(None);
653
654        let http = match self.http {
655            Some(http) => http,
656            None => {
657                let mut headers = HeaderMap::new();
658                headers.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
659                let mut builder = reqwest::Client::builder()
660                    .user_agent(self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT))
661                    .default_headers(headers);
662                if let Some(timeout) = self.timeout {
663                    builder = builder.timeout(timeout);
664                }
665                builder.build().map_err(Error::transport)?
666            }
667        };
668
669        let limits = self.rate_limits.unwrap_or_default();
670        let max_wait = self
671            .max_rate_limit_wait
672            .unwrap_or_else(|| Duration::from_secs(60));
673
674        Ok(Client {
675            inner: Arc::new(Inner {
676                http,
677                base,
678                auth: self.auth,
679                limiter: Arc::new(Limiter::new(limits, max_wait)),
680                retry: self.retry,
681            }),
682        })
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    #![allow(clippy::expect_used)]
689
690    use super::*;
691
692    fn client() -> Client {
693        Client::builder()
694            .base_url("https://desec.example/api/v1")
695            .token("secret")
696            .build()
697            .expect("valid configuration")
698    }
699
700    #[test]
701    fn secrets_do_not_leak_through_debug_or_display() {
702        let secret = Secret::new("i-T3b1h_OI-H9ab8tRS98stGtURe");
703        assert_eq!(format!("{secret:?}"), "Secret(<redacted>)");
704        assert_eq!(secret.to_string(), "<redacted>");
705        assert!(!format!("{secret:?} {secret}").contains("T3b1h"));
706    }
707
708    #[test]
709    fn client_debug_does_not_leak_the_token() {
710        let rendered = format!("{:?}", client());
711        assert!(!rendered.contains("secret"), "{rendered}");
712    }
713
714    #[test]
715    fn urls_get_a_trailing_slash_and_no_double_slash() {
716        let client = client();
717        assert_eq!(
718            client.url(&["domains"]).as_str(),
719            "https://desec.example/api/v1/domains/"
720        );
721        assert_eq!(
722            client.url(&["domains", "example.com", "rrsets"]).as_str(),
723            "https://desec.example/api/v1/domains/example.com/rrsets/"
724        );
725    }
726
727    #[test]
728    fn a_trailing_slash_on_the_base_is_normalized_away() {
729        let client = Client::builder()
730            .base_url("https://desec.example/api/v1/")
731            .build()
732            .expect("valid");
733        assert_eq!(
734            client.url(&["domains"]).as_str(),
735            "https://desec.example/api/v1/domains/"
736        );
737    }
738
739    /// The apex path segment must survive as `@`, and a wildcard must not be mangled.
740    #[test]
741    fn path_segments_are_encoded_without_breaking_dns_syntax() {
742        let client = client();
743        assert_eq!(
744            client
745                .url(&["domains", "example.com", "rrsets", "@", "A"])
746                .as_str(),
747            "https://desec.example/api/v1/domains/example.com/rrsets/@/A/"
748        );
749        assert_eq!(
750            client
751                .url(&["domains", "example.com", "rrsets", "*.wild", "A"])
752                .as_str(),
753            "https://desec.example/api/v1/domains/example.com/rrsets/*.wild/A/"
754        );
755    }
756
757    #[test]
758    fn query_values_are_percent_encoded() {
759        let client = client();
760        let req = client
761            .request(
762                Method::GET,
763                client.url(&["domains", "example.com", "rrsets"]),
764                ScopeSet::default(),
765            )
766            .query("subname", "a b&c=d");
767        assert_eq!(req.url.query(), Some("subname=a+b%26c%3Dd"));
768    }
769
770    #[test]
771    fn rejects_a_non_http_base_url() {
772        let err = Client::builder()
773            .base_url("mailto:someone@example.com")
774            .build()
775            .expect_err("not an http URL");
776        assert!(err.is_validation(), "{err:?}");
777    }
778
779    #[test]
780    fn base64_matches_the_reference_vectors() {
781        // RFC 4648 test vectors, which pin the padding cases.
782        assert_eq!(base64_standard(b""), "");
783        assert_eq!(base64_standard(b"f"), "Zg==");
784        assert_eq!(base64_standard(b"fo"), "Zm8=");
785        assert_eq!(base64_standard(b"foo"), "Zm9v");
786        assert_eq!(base64_standard(b"foob"), "Zm9vYg==");
787        assert_eq!(base64_standard(b"fooba"), "Zm9vYmE=");
788        assert_eq!(base64_standard(b"foobar"), "Zm9vYmFy");
789        assert_eq!(base64_standard(b"user:pass"), "dXNlcjpwYXNz");
790    }
791
792    #[test]
793    fn token_auth_uses_the_desec_scheme() {
794        let auth = Auth::Token(Secret::new("abc"));
795        let header = auth
796            .header()
797            .expect("token auth sends a header")
798            .expect("valid header value");
799        assert_eq!(header.to_str().expect("ascii"), "Token abc");
800    }
801
802    #[test]
803    fn basic_auth_is_encoded() {
804        let auth = Auth::Basic {
805            username: "user".into(),
806            password: Secret::new("pass"),
807        };
808        let header = auth
809            .header()
810            .expect("basic auth sends a header")
811            .expect("valid header value");
812        assert_eq!(header.to_str().expect("ascii"), "Basic dXNlcjpwYXNz");
813    }
814
815    #[test]
816    fn backoff_doubles_and_saturates_at_the_ceiling() {
817        let client = Client::builder()
818            .base_url("https://desec.example/api/v1")
819            .max_retry_delay(Duration::from_secs(4))
820            .build()
821            .expect("valid");
822        assert_eq!(client.backoff(1), Duration::from_millis(500));
823        assert_eq!(client.backoff(2), Duration::from_secs(1));
824        assert_eq!(client.backoff(3), Duration::from_secs(2));
825        assert_eq!(client.backoff(4), Duration::from_secs(4));
826        assert_eq!(client.backoff(40), Duration::from_secs(4));
827    }
828}