Skip to main content

cachekit/backend/
cachekitio.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use zeroize::Zeroizing;
7
8use crate::backend::{encode_key, Backend, Freshness, HealthStatus, LockableBackend};
9use crate::error::{BackendError, BackendErrorKind};
10use crate::metrics::{metrics_headers, MetricsProvider};
11use crate::session::session_headers;
12use crate::url_validator::validate_cachekitio_url;
13
14// ── CachekitIO ────────────────────────────────────────────────────────────────
15
16/// HTTP backend that talks to the cachekit.io SaaS API.
17pub struct CachekitIO {
18    client: reqwest::Client,
19    api_key: Zeroizing<String>,
20    api_url: String,
21    /// Source of the `X-CacheKit-*` telemetry headers. Set once: by the
22    /// builder when the user supplies one, otherwise by the client at build
23    /// time via [`Backend::attach_metrics`] (first writer wins).
24    metrics_provider: OnceLock<MetricsProvider>,
25}
26
27/// Redact `api_key` from debug output.
28impl std::fmt::Debug for CachekitIO {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("CachekitIO")
31            .field("api_url", &self.api_url)
32            .field("api_key", &"<redacted>")
33            .finish()
34    }
35}
36
37impl CachekitIO {
38    /// Start building a [`CachekitIO`] instance.
39    pub fn builder() -> CachekitIOBuilder {
40        CachekitIOBuilder::default()
41    }
42
43    /// Return the configured API URL (useful in tests / introspection).
44    pub fn api_url(&self) -> &str {
45        &self.api_url
46    }
47
48    /// Return a reference to the underlying HTTP client.
49    pub(crate) fn client(&self) -> &reqwest::Client {
50        &self.client
51    }
52
53    /// Return the API key as a string slice (for bearer auth in sibling modules).
54    pub(crate) fn api_key_str(&self) -> &str {
55        self.api_key.as_str()
56    }
57
58    /// Build the full URL for a cache key path segment.
59    ///
60    /// Keys are percent-encoded via [`encode_key`](crate::backend::encode_key) so
61    /// slashes or special characters do not break the URL structure. Fallible: it
62    /// rejects exactly the keys `encode_key` rejects (CWE-22, spec rule 2).
63    fn url(&self, key: &str) -> Result<String, BackendError> {
64        Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
65    }
66
67    /// Build the TTL URL for a cache key (`…/ttl`). Composes on [`Self::url`], so
68    /// it inherits the same [`encode_key`](crate::backend::encode_key) guard and
69    /// the `/v1/cache/` prefix lives in one place (matching the wasm `workers`
70    /// backend). `pub(crate)` so the [`TtlInspectable`](super::TtlInspectable)
71    /// impl in the sibling `cachekitio_ttl` module builds its path through it.
72    pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
73        Ok(format!("{}/ttl", self.url(key)?))
74    }
75
76    /// Build the lock URL for a cache key (`…/lock`). Composes on [`Self::url`]
77    /// (same guard, same single prefix). `pub(crate)` so the
78    /// [`LockableBackend`](super::LockableBackend) impl in the sibling
79    /// `cachekitio_lock` module builds its path through it.
80    pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
81        Ok(format!("{}/lock", self.url(key)?))
82    }
83
84    /// Build the health-check URL.
85    fn health_url(&self) -> String {
86        format!("{}/v1/cache/health", self.api_url)
87    }
88
89    /// Apply standard session and metrics headers to a request builder.
90    pub(crate) fn with_standard_headers(
91        &self,
92        mut req: reqwest::RequestBuilder,
93    ) -> reqwest::RequestBuilder {
94        for (name, value) in session_headers() {
95            req = req.header(name, value);
96        }
97        for (name, value) in metrics_headers(self.metrics_provider.get()) {
98            req = req.header(name, value);
99        }
100        req
101    }
102
103    /// Read error body from response and build a sanitized BackendError.
104    pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
105        let status = resp.status().as_u16();
106        let body = resp.bytes().await.unwrap_or_default();
107        from_http_status_sanitized(status, &body, self.api_key.as_str())
108    }
109}
110
111// ── Error helpers ────────────────────────────────────────────────────────────
112
113/// Convert a reqwest error into a BackendError, sanitizing the API key from the message.
114pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
115    let kind = if e.is_timeout() {
116        BackendErrorKind::Timeout
117    } else {
118        BackendErrorKind::Transient
119    };
120    BackendError {
121        kind,
122        message: BackendError::sanitize_message(&e.to_string(), api_key),
123        source: Some(Box::new(e)),
124    }
125}
126
127/// Build a [`BackendError`] from an HTTP status + body, sanitizing the API key from output.
128pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
129    let sanitized =
130        BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
131    BackendError::from_http_status(status, sanitized.as_bytes())
132}
133
134// ── Freshness headers ────────────────────────────────────────────────────────
135
136/// Read a `GET 200`'s [`Freshness`] from its headers (`spec/saas-api.md`
137/// § Stale-While-Revalidate, § Remaining Freshness).
138///
139/// An absent `X-CacheKit-Freshness` means fresh (a pre-SWR server). Any value
140/// other than exactly `fresh` means stale, because revalidating is the safe
141/// reading of a token the client does not know; every copy of a repeated
142/// header must say `fresh`. An absent `X-CacheKit-Fresh-For` means no server
143/// bound. A repeated one has no single bound, so it counts as `0`.
144#[cfg(not(target_arch = "wasm32"))]
145pub(crate) fn freshness_from_headers(headers: &reqwest::header::HeaderMap) -> Freshness {
146    let is_stale = headers
147        .get_all("x-cachekit-freshness")
148        .iter()
149        .any(|value| value != "fresh");
150    let mut fresh_for = headers.get_all("x-cachekit-fresh-for").iter();
151    let fresh_for = match (fresh_for.next(), fresh_for.next()) {
152        (None, _) => None,
153        (Some(value), None) => Some(parse_fresh_for(value.as_bytes())),
154        (Some(_), Some(_)) => Some(Duration::ZERO),
155    };
156    Freshness {
157        is_stale,
158        fresh_for,
159    }
160}
161
162/// `X-CacheKit-Fresh-For` must be 1–7 ASCII digits and at most 2,592,000 (the
163/// 30-day TTL cap); anything else counts as `0`. The length check runs first,
164/// so the range check never sees a value that a fixed-width parse could wrap
165/// back into range (`4297559296` as a `u32` is `2592000`).
166#[cfg(not(target_arch = "wasm32"))]
167fn parse_fresh_for(value: &[u8]) -> Duration {
168    const MAX_FRESH_FOR_SECS: u64 = 2_592_000;
169    if !(1..=7).contains(&value.len()) || !value.iter().all(u8::is_ascii_digit) {
170        return Duration::ZERO;
171    }
172    match std::str::from_utf8(value).map(str::parse::<u64>) {
173        Ok(Ok(secs)) if secs <= MAX_FRESH_FOR_SECS => Duration::from_secs(secs),
174        _ => Duration::ZERO,
175    }
176}
177
178// ── Backend impl ──────────────────────────────────────────────────────────────
179
180#[cfg(not(target_arch = "wasm32"))]
181#[cfg_attr(not(feature = "unsync"), async_trait)]
182#[cfg_attr(feature = "unsync", async_trait(?Send))]
183impl Backend for CachekitIO {
184    fn attach_metrics(&self, provider: MetricsProvider) {
185        // A provider supplied on the builder is already set and wins.
186        self.metrics_provider.get_or_init(|| provider);
187    }
188
189    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
190        Ok(self.get_with_freshness(key).await?.map(|(bytes, _)| bytes))
191    }
192
193    async fn get_with_freshness(
194        &self,
195        key: &str,
196    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
197        let req = self.with_standard_headers(
198            self.client
199                .get(self.url(key)?)
200                .bearer_auth(self.api_key.as_str()),
201        );
202
203        let resp = req
204            .send()
205            .await
206            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
207
208        match resp.status().as_u16() {
209            200 => {
210                let freshness = freshness_from_headers(resp.headers());
211                let bytes = resp
212                    .bytes()
213                    .await
214                    .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
215                Ok(Some((bytes.to_vec(), freshness)))
216            }
217            404 => Ok(None),
218            _ => Err(self.error_from_response(resp).await),
219        }
220    }
221
222    async fn set(
223        &self,
224        key: &str,
225        value: Vec<u8>,
226        ttl: Option<Duration>,
227    ) -> Result<(), BackendError> {
228        let mut req = self
229            .client
230            .put(self.url(key)?)
231            .bearer_auth(self.api_key.as_str())
232            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
233            .body(value);
234
235        if let Some(ttl) = ttl {
236            req = req.header("X-TTL", ttl.as_secs().to_string());
237        }
238
239        let req = self.with_standard_headers(req);
240
241        let resp = req
242            .send()
243            .await
244            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
245
246        let status = resp.status().as_u16();
247        if (200..300).contains(&status) {
248            Ok(())
249        } else {
250            Err(self.error_from_response(resp).await)
251        }
252    }
253
254    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
255        let req = self.with_standard_headers(
256            self.client
257                .delete(self.url(key)?)
258                .bearer_auth(self.api_key.as_str()),
259        );
260
261        let resp = req
262            .send()
263            .await
264            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
265
266        match resp.status().as_u16() {
267            200 | 204 => Ok(true),
268            404 => Ok(false),
269            _ => Err(self.error_from_response(resp).await),
270        }
271    }
272
273    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
274        let req = self.with_standard_headers(
275            self.client
276                .head(self.url(key)?)
277                .bearer_auth(self.api_key.as_str()),
278        );
279
280        let resp = req
281            .send()
282            .await
283            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
284
285        match resp.status().as_u16() {
286            200 => Ok(true),
287            404 => Ok(false),
288            status => Err(BackendError::from_http_status(status, &[])),
289        }
290    }
291
292    async fn health(&self) -> Result<HealthStatus, BackendError> {
293        let start = std::time::Instant::now();
294
295        let req = self.with_standard_headers(
296            self.client
297                .get(self.health_url())
298                .bearer_auth(self.api_key.as_str()),
299        );
300
301        let resp = req
302            .send()
303            .await
304            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
305
306        let latency = start.elapsed();
307        let status = resp.status().as_u16();
308
309        if (200..300).contains(&status) {
310            let mut details = HashMap::new();
311            details.insert("http_status".to_string(), status.to_string());
312            Ok(HealthStatus {
313                is_healthy: true,
314                latency_ms: latency.as_secs_f64() * 1000.0,
315                backend_type: "cachekitio".to_string(),
316                details,
317            })
318        } else {
319            Err(self.error_from_response(resp).await)
320        }
321    }
322
323    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
324        Some(self)
325    }
326}
327
328// ── Builder ───────────────────────────────────────────────────────────────────
329
330/// Builder for [`CachekitIO`].
331#[derive(Default)]
332#[must_use]
333pub struct CachekitIOBuilder {
334    api_key: Option<Zeroizing<String>>,
335    api_url: Option<String>,
336    allow_custom_host: bool,
337    metrics_provider: Option<MetricsProvider>,
338}
339
340impl CachekitIOBuilder {
341    /// Set the API key (required).
342    pub fn api_key(mut self, key: impl Into<String>) -> Self {
343        self.api_key = Some(Zeroizing::new(key.into()));
344        self
345    }
346
347    /// Override the API base URL (default: `https://api.cachekit.io`).
348    pub fn api_url(mut self, url: impl Into<String>) -> Self {
349        self.api_url = Some(url.into());
350        self
351    }
352
353    /// Allow non-standard hostnames (e.g. custom proxies). Private IPs are still blocked.
354    pub fn allow_custom_host(mut self, allow: bool) -> Self {
355        self.allow_custom_host = allow;
356        self
357    }
358
359    /// Override the source of the `X-CacheKit-*` telemetry headers.
360    ///
361    /// Not needed for normal use: `CacheKitBuilder::build` attaches the
362    /// client's own live counters to any backend without one. Set this only
363    /// to report numbers from somewhere else; it takes precedence.
364    pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
365        self.metrics_provider = Some(provider);
366        self
367    }
368
369    /// Consume the builder and construct a [`CachekitIO`].
370    ///
371    /// # Errors
372    ///
373    /// Returns an error if:
374    /// - `api_key` was not set.
375    /// - the resolved URL scheme is not `https`.
376    /// - the URL hostname is not permitted (see [`validate_cachekitio_url`]).
377    pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
378        use crate::error::CachekitError;
379
380        let api_key = self
381            .api_key
382            .filter(|k| !k.is_empty())
383            .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
384
385        let api_url = self
386            .api_url
387            .unwrap_or_else(|| "https://api.cachekit.io".to_string());
388
389        // Validate URL: HTTPS, allowed host, no private IPs.
390        validate_cachekitio_url(&api_url, self.allow_custom_host)?;
391
392        // Trim trailing slash once so url()/health_url() don't repeat it per-request.
393        let api_url = api_url.trim_end_matches('/').to_string();
394
395        let client = reqwest::Client::builder();
396
397        #[cfg(not(target_arch = "wasm32"))]
398        let client = client
399            .use_rustls_tls()
400            .redirect(reqwest::redirect::Policy::none())
401            .timeout(Duration::from_secs(30))
402            .connect_timeout(Duration::from_secs(10));
403
404        let client = client
405            .build()
406            .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
407
408        Ok(CachekitIO {
409            client,
410            api_key,
411            api_url,
412            metrics_provider: self
413                .metrics_provider
414                .map_or_else(OnceLock::new, OnceLock::from),
415        })
416    }
417}
418
419// ── Freshness-header parsing tests ───────────────────────────────────────────
420
421#[cfg(all(test, not(target_arch = "wasm32")))]
422#[allow(clippy::expect_used)] // test-only: a malformed fixture header should panic loudly
423mod freshness_header_tests {
424    use std::time::Duration;
425
426    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
427
428    use super::freshness_from_headers;
429    use crate::backend::Freshness;
430
431    /// Build a header map with the wire's mixed-case names; `append` keeps
432    /// repeats, as a proxy that duplicates a header would.
433    fn headers(pairs: &[(&str, &[u8])]) -> HeaderMap {
434        let mut map = HeaderMap::new();
435        for (name, value) in pairs {
436            map.append(
437                HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
438                HeaderValue::from_bytes(value).expect("valid header value"),
439            );
440        }
441        map
442    }
443
444    fn label(values: &[&[u8]]) -> bool {
445        let pairs: Vec<_> = values
446            .iter()
447            .map(|v| ("X-CacheKit-Freshness", *v))
448            .collect();
449        freshness_from_headers(&headers(&pairs)).is_stale
450    }
451
452    fn fresh_for(values: &[&[u8]]) -> Option<Duration> {
453        let pairs: Vec<_> = values
454            .iter()
455            .map(|v| ("X-CacheKit-Fresh-For", *v))
456            .collect();
457        freshness_from_headers(&headers(&pairs)).fresh_for
458    }
459
460    #[test]
461    fn absent_headers_are_a_fresh_unbounded_read() {
462        assert_eq!(
463            freshness_from_headers(&HeaderMap::new()),
464            Freshness::default()
465        );
466    }
467
468    #[test]
469    fn only_an_exact_fresh_label_is_fresh() {
470        assert!(!label(&[b"fresh"]));
471        assert!(!label(&[b"fresh", b"fresh"]));
472        // (d) `stale`, an unknown token, a case variant, an empty value, or any
473        // non-`fresh` copy of a repeated header all read as stale.
474        for stale in [&b"stale"[..], b"revalidating", b"Fresh", b"", b"fresh "] {
475            assert!(
476                label(&[stale]),
477                "{:?} must read as stale",
478                String::from_utf8_lossy(stale)
479            );
480        }
481        assert!(label(&[b"fresh", b"stale"]));
482    }
483
484    #[test]
485    fn fresh_for_in_grammar_parses() {
486        for (value, secs) in [
487            (&b"0"[..], 0),
488            (b"1", 1),
489            (b"0000005", 5),
490            (b"2592000", 2_592_000),
491        ] {
492            assert_eq!(fresh_for(&[value]), Some(Duration::from_secs(secs)));
493        }
494    }
495
496    /// (d) Outside 1–7 ASCII digits, or over 2,592,000, is `0`. `4297559296` is
497    /// the value a wrapping `u32` parse would turn into `2592000`.
498    #[test]
499    fn fresh_for_outside_grammar_is_zero() {
500        let invalid: [&[u8]; 11] = [
501            b"",
502            b"-1",
503            b"+5",
504            b" 5",
505            b"5s",
506            b"1.5",
507            "\u{0663}".as_bytes(), // ARABIC-INDIC DIGIT THREE: a digit, not ASCII
508            b"12345678",
509            b"2592001",
510            b"9999999",
511            b"4297559296",
512        ];
513        for value in invalid {
514            assert_eq!(
515                fresh_for(&[value]),
516                Some(Duration::ZERO),
517                "{:?} must count as 0",
518                String::from_utf8_lossy(value)
519            );
520        }
521        assert_eq!(
522            fresh_for(&[b"10", b"10"]),
523            Some(Duration::ZERO),
524            "a repeated bound counts as 0"
525        );
526    }
527}
528
529// ── Telemetry-header wiring tests ─────────────────────────────────────────────
530
531#[cfg(test)]
532#[allow(clippy::expect_used)] // test-only: a builder failure on a fixture should panic loudly
533mod metrics_wiring_tests {
534    use std::sync::Arc;
535
536    use super::CachekitIO;
537    use crate::backend::Backend;
538    use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
539
540    fn provider(l1_hits: u64) -> MetricsProvider {
541        Arc::new(move || {
542            Some(L1Stats {
543                l1_hits,
544                l2_hits: 0,
545                misses: 0,
546                l1_enabled: true,
547            })
548        })
549    }
550
551    fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
552        metrics_headers(backend.metrics_provider.get())
553            .into_iter()
554            .find(|h| h.0 == "X-CacheKit-L1-Hits")
555            .map(|h| h.1)
556    }
557
558    #[test]
559    fn attach_fills_an_unset_provider() {
560        let backend = CachekitIO::builder()
561            .api_key("ck_test_key")
562            .build()
563            .expect("builder succeeds");
564        assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
565
566        backend.attach_metrics(provider(7));
567        assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
568    }
569
570    #[test]
571    fn builder_provider_is_retained_over_attach() {
572        let backend = CachekitIO::builder()
573            .api_key("ck_test_key")
574            .metrics_provider(provider(1))
575            .build()
576            .expect("builder succeeds");
577
578        backend.attach_metrics(provider(99));
579        assert_eq!(
580            l1_hits_header(&backend).as_deref(),
581            Some("1"),
582            "user-supplied provider wins"
583        );
584    }
585}
586
587// ── Cache-key path encoding tests (CWE-22) ────────────────────────────────────
588
589#[cfg(test)]
590#[allow(clippy::expect_used)] // test-only: a builder/parse failure on a fixture should panic loudly
591mod path_encoding_tests {
592    use super::CachekitIO;
593    use crate::backend::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
594    use url::Url;
595
596    const API: &str = "https://api.cachekit.io";
597
598    fn backend() -> CachekitIO {
599        CachekitIO::builder()
600            .api_url(API)
601            .api_key("ck_test_key")
602            .build()
603            .expect("builder should succeed for the canonical host")
604    }
605
606    /// Repro. Before any guard, a raw-encoded `.`/`..` key collapses in
607    /// rust-url (the parser `reqwest` uses) *before* the request leaves the
608    /// process: the segment is stripped and the path escapes `/v1/cache/`.
609    /// `%2E%2E` collapses identically, which is why the fix rejects rather than
610    /// re-encodes (WHATWG treats `%2e%2e` as a dot-segment too).
611    #[test]
612    fn repro_raw_dot_key_escapes_the_cache_prefix() {
613        let cases = [
614            ("..", "", "/v1/"),
615            ("..", "/ttl", "/v1/ttl"),
616            ("..", "/lock", "/v1/lock"),
617            (".", "", "/v1/cache/"),
618        ];
619        for (key, suffix, escaped) in cases {
620            let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
621            let parsed = Url::parse(&raw).expect("parses");
622            assert_eq!(
623                parsed.path(),
624                escaped,
625                "raw {key:?}{suffix} should collapse to {escaped}"
626            );
627            // The %2E form the Python SDK emits collapses just the same in rust-url.
628            let pct = key.replace('.', "%2E");
629            let enc = format!("{API}/v1/cache/{pct}{suffix}");
630            assert_eq!(
631                Url::parse(&enc).expect("parses").path(),
632                escaped,
633                "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
634            );
635        }
636    }
637
638    /// Spec rule 2 — every fixture reject row (the empty key, `.`, `..`,
639    /// `health`, `ttl`, `lock`) is rejected by every builder (base, ttl, lock):
640    /// no URL is produced, so no rewritten or mis-routed request can be sent.
641    #[test]
642    fn reserved_segments_rejected_by_every_builder() {
643        let b = backend();
644        let keys = reject_keys();
645        assert!(!keys.is_empty(), "fixture has no reject rows");
646        for key in &keys {
647            assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
648            assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
649            assert!(
650                b.lock_url(key).is_err(),
651                "lock_url({key:?}) must be rejected"
652            );
653        }
654    }
655
656    /// Every transmittable fixture row, plus the rs near-misses, builds a URL
657    /// whose *parsed* path (the real wire path, post-normalisation) stays inside
658    /// `/v1/cache/`. Asserting on the unparsed `format!` output would pass while
659    /// still shipping a traversal, so we parse with the same `url` crate
660    /// `reqwest` uses.
661    #[test]
662    fn safe_keys_never_escape_the_cache_prefix() {
663        let b = backend();
664        let fixture_keys: Vec<String> = transmittable().into_iter().map(|v| v.key).collect();
665        let vectors = fixture_keys
666            .iter()
667            .map(String::as_str)
668            .chain(RS_NEAR_MISSES.iter().copied());
669        for key in vectors {
670            let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
671            let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
672            let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
673
674            assert!(
675                base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
676                "base path {} escaped prefix for {key:?}",
677                base.path()
678            );
679            assert_eq!(
680                base.path(),
681                format!("/v1/cache/{}", urlencoding::encode(key)),
682                "base wire path mismatch for {key:?}"
683            );
684            assert!(
685                ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
686                "ttl path {} escaped prefix for {key:?}",
687                ttl.path()
688            );
689            assert!(
690                lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
691                "lock path {} escaped prefix for {key:?}",
692                lock.path()
693            );
694        }
695    }
696}