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, 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. A key whose
62    /// encoded form is a reserved segment (`.`, `..`, `health`, `ttl`, `lock`) is
63    /// **rejected** (fallible return) rather than sent: the dot segments are
64    /// stripped by `reqwest`'s WHATWG URL parser and the route tokens collide
65    /// with the health/sub-resource routes, both escaping `/v1/cache/{key}`
66    /// (CWE-22, spec rule 2) — see [`encode_key`](crate::backend::encode_key).
67    fn url(&self, key: &str) -> Result<String, BackendError> {
68        Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
69    }
70
71    /// Build the TTL URL for a cache key (`…/ttl`). Composes on [`Self::url`], so
72    /// it inherits the same [`encode_key`](crate::backend::encode_key) guard and
73    /// the `/v1/cache/` prefix lives in one place (matching the wasm `workers`
74    /// backend). `pub(crate)` so the [`TtlInspectable`](super::TtlInspectable)
75    /// impl in the sibling `cachekitio_ttl` module builds its path through it.
76    pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
77        Ok(format!("{}/ttl", self.url(key)?))
78    }
79
80    /// Build the lock URL for a cache key (`…/lock`). Composes on [`Self::url`]
81    /// (same guard, same single prefix). `pub(crate)` so the
82    /// [`LockableBackend`](super::LockableBackend) impl in the sibling
83    /// `cachekitio_lock` module builds its path through it.
84    pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
85        Ok(format!("{}/lock", self.url(key)?))
86    }
87
88    /// Build the health-check URL.
89    fn health_url(&self) -> String {
90        format!("{}/v1/cache/health", self.api_url)
91    }
92
93    /// Apply standard session and metrics headers to a request builder.
94    pub(crate) fn with_standard_headers(
95        &self,
96        mut req: reqwest::RequestBuilder,
97    ) -> reqwest::RequestBuilder {
98        for (name, value) in session_headers() {
99            req = req.header(name, value);
100        }
101        for (name, value) in metrics_headers(self.metrics_provider.get()) {
102            req = req.header(name, value);
103        }
104        req
105    }
106
107    /// Read error body from response and build a sanitized BackendError.
108    pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
109        let status = resp.status().as_u16();
110        let body = resp.bytes().await.unwrap_or_default();
111        from_http_status_sanitized(status, &body, self.api_key.as_str())
112    }
113}
114
115// ── Error helpers ────────────────────────────────────────────────────────────
116
117/// Convert a reqwest error into a BackendError, sanitizing the API key from the message.
118pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
119    let kind = if e.is_timeout() {
120        BackendErrorKind::Timeout
121    } else {
122        BackendErrorKind::Transient
123    };
124    BackendError {
125        kind,
126        message: BackendError::sanitize_message(&e.to_string(), api_key),
127        source: Some(Box::new(e)),
128    }
129}
130
131/// Build a [`BackendError`] from an HTTP status + body, sanitizing the API key from output.
132pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
133    let sanitized =
134        BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
135    BackendError::from_http_status(status, sanitized.as_bytes())
136}
137
138// ── Backend impl ──────────────────────────────────────────────────────────────
139
140#[cfg(not(target_arch = "wasm32"))]
141#[cfg_attr(not(feature = "unsync"), async_trait)]
142#[cfg_attr(feature = "unsync", async_trait(?Send))]
143impl Backend for CachekitIO {
144    fn attach_metrics(&self, provider: MetricsProvider) {
145        // A provider supplied on the builder is already set and wins.
146        self.metrics_provider.get_or_init(|| provider);
147    }
148
149    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
150        let req = self.with_standard_headers(
151            self.client
152                .get(self.url(key)?)
153                .bearer_auth(self.api_key.as_str()),
154        );
155
156        let resp = req
157            .send()
158            .await
159            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
160
161        match resp.status().as_u16() {
162            200 => {
163                let bytes = resp
164                    .bytes()
165                    .await
166                    .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
167                Ok(Some(bytes.to_vec()))
168            }
169            404 => Ok(None),
170            _ => Err(self.error_from_response(resp).await),
171        }
172    }
173
174    async fn set(
175        &self,
176        key: &str,
177        value: Vec<u8>,
178        ttl: Option<Duration>,
179    ) -> Result<(), BackendError> {
180        let mut req = self
181            .client
182            .put(self.url(key)?)
183            .bearer_auth(self.api_key.as_str())
184            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
185            .body(value);
186
187        if let Some(ttl) = ttl {
188            req = req.header("X-TTL", ttl.as_secs().to_string());
189        }
190
191        let req = self.with_standard_headers(req);
192
193        let resp = req
194            .send()
195            .await
196            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
197
198        let status = resp.status().as_u16();
199        if (200..300).contains(&status) {
200            Ok(())
201        } else {
202            Err(self.error_from_response(resp).await)
203        }
204    }
205
206    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
207        let req = self.with_standard_headers(
208            self.client
209                .delete(self.url(key)?)
210                .bearer_auth(self.api_key.as_str()),
211        );
212
213        let resp = req
214            .send()
215            .await
216            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
217
218        match resp.status().as_u16() {
219            200 | 204 => Ok(true),
220            404 => Ok(false),
221            _ => Err(self.error_from_response(resp).await),
222        }
223    }
224
225    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
226        let req = self.with_standard_headers(
227            self.client
228                .head(self.url(key)?)
229                .bearer_auth(self.api_key.as_str()),
230        );
231
232        let resp = req
233            .send()
234            .await
235            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
236
237        match resp.status().as_u16() {
238            200 => Ok(true),
239            404 => Ok(false),
240            status => Err(BackendError::from_http_status(status, &[])),
241        }
242    }
243
244    async fn health(&self) -> Result<HealthStatus, BackendError> {
245        let start = std::time::Instant::now();
246
247        let req = self.with_standard_headers(
248            self.client
249                .get(self.health_url())
250                .bearer_auth(self.api_key.as_str()),
251        );
252
253        let resp = req
254            .send()
255            .await
256            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
257
258        let latency = start.elapsed();
259        let status = resp.status().as_u16();
260
261        if (200..300).contains(&status) {
262            let mut details = HashMap::new();
263            details.insert("http_status".to_string(), status.to_string());
264            Ok(HealthStatus {
265                is_healthy: true,
266                latency_ms: latency.as_secs_f64() * 1000.0,
267                backend_type: "cachekitio".to_string(),
268                details,
269            })
270        } else {
271            Err(self.error_from_response(resp).await)
272        }
273    }
274
275    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
276        Some(self)
277    }
278}
279
280// ── Builder ───────────────────────────────────────────────────────────────────
281
282/// Builder for [`CachekitIO`].
283#[derive(Default)]
284#[must_use]
285pub struct CachekitIOBuilder {
286    api_key: Option<Zeroizing<String>>,
287    api_url: Option<String>,
288    allow_custom_host: bool,
289    metrics_provider: Option<MetricsProvider>,
290}
291
292impl CachekitIOBuilder {
293    /// Set the API key (required).
294    pub fn api_key(mut self, key: impl Into<String>) -> Self {
295        self.api_key = Some(Zeroizing::new(key.into()));
296        self
297    }
298
299    /// Override the API base URL (default: `https://api.cachekit.io`).
300    pub fn api_url(mut self, url: impl Into<String>) -> Self {
301        self.api_url = Some(url.into());
302        self
303    }
304
305    /// Allow non-standard hostnames (e.g. custom proxies). Private IPs are still blocked.
306    pub fn allow_custom_host(mut self, allow: bool) -> Self {
307        self.allow_custom_host = allow;
308        self
309    }
310
311    /// Override the source of the `X-CacheKit-*` telemetry headers.
312    ///
313    /// Not needed for normal use: `CacheKitBuilder::build` attaches the
314    /// client's own live counters to any backend without one. Set this only
315    /// to report numbers from somewhere else; it takes precedence.
316    pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
317        self.metrics_provider = Some(provider);
318        self
319    }
320
321    /// Consume the builder and construct a [`CachekitIO`].
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if:
326    /// - `api_key` was not set.
327    /// - the resolved URL scheme is not `https`.
328    /// - the URL hostname is not permitted (see [`validate_cachekitio_url`]).
329    pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
330        use crate::error::CachekitError;
331
332        let api_key = self
333            .api_key
334            .filter(|k| !k.is_empty())
335            .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
336
337        let api_url = self
338            .api_url
339            .unwrap_or_else(|| "https://api.cachekit.io".to_string());
340
341        // Validate URL: HTTPS, allowed host, no private IPs.
342        validate_cachekitio_url(&api_url, self.allow_custom_host)?;
343
344        // Trim trailing slash once so url()/health_url() don't repeat it per-request.
345        let api_url = api_url.trim_end_matches('/').to_string();
346
347        let client = reqwest::Client::builder();
348
349        #[cfg(not(target_arch = "wasm32"))]
350        let client = client
351            .use_rustls_tls()
352            .redirect(reqwest::redirect::Policy::none())
353            .timeout(Duration::from_secs(30))
354            .connect_timeout(Duration::from_secs(10));
355
356        let client = client
357            .build()
358            .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
359
360        Ok(CachekitIO {
361            client,
362            api_key,
363            api_url,
364            metrics_provider: self
365                .metrics_provider
366                .map_or_else(OnceLock::new, OnceLock::from),
367        })
368    }
369}
370
371// ── Telemetry-header wiring tests ─────────────────────────────────────────────
372
373#[cfg(test)]
374#[allow(clippy::expect_used)] // test-only: a builder failure on a fixture should panic loudly
375mod metrics_wiring_tests {
376    use std::sync::Arc;
377
378    use super::CachekitIO;
379    use crate::backend::Backend;
380    use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
381
382    fn provider(l1_hits: u64) -> MetricsProvider {
383        Arc::new(move || {
384            Some(L1Stats {
385                l1_hits,
386                l2_hits: 0,
387                misses: 0,
388                l1_enabled: true,
389            })
390        })
391    }
392
393    fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
394        metrics_headers(backend.metrics_provider.get())
395            .into_iter()
396            .find(|h| h.0 == "X-CacheKit-L1-Hits")
397            .map(|h| h.1)
398    }
399
400    #[test]
401    fn attach_fills_an_unset_provider() {
402        let backend = CachekitIO::builder()
403            .api_key("ck_test_key")
404            .build()
405            .expect("builder succeeds");
406        assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
407
408        backend.attach_metrics(provider(7));
409        assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
410    }
411
412    #[test]
413    fn builder_provider_is_retained_over_attach() {
414        let backend = CachekitIO::builder()
415            .api_key("ck_test_key")
416            .metrics_provider(provider(1))
417            .build()
418            .expect("builder succeeds");
419
420        backend.attach_metrics(provider(99));
421        assert_eq!(
422            l1_hits_header(&backend).as_deref(),
423            Some("1"),
424            "user-supplied provider wins"
425        );
426    }
427}
428
429// ── Cache-key path encoding tests (CWE-22) ────────────────────────────────────
430
431#[cfg(test)]
432#[allow(clippy::expect_used)] // test-only: a builder/parse failure on a fixture should panic loudly
433mod path_encoding_tests {
434    use super::CachekitIO;
435    use url::Url;
436
437    const API: &str = "https://api.cachekit.io";
438
439    fn backend() -> CachekitIO {
440        CachekitIO::builder()
441            .api_url(API)
442            .api_key("ck_test_key")
443            .build()
444            .expect("builder should succeed for the canonical host")
445    }
446
447    /// AC-0 — Repro. Before any guard, a raw-encoded `.`/`..` key collapses in
448    /// rust-url (the parser `reqwest` uses) *before* the request leaves the
449    /// process: the segment is stripped and the path escapes `/v1/cache/`.
450    /// `%2E%2E` collapses identically, which is why the fix rejects rather than
451    /// re-encodes (WHATWG treats `%2e%2e` as a dot-segment too).
452    #[test]
453    fn repro_raw_dot_key_escapes_the_cache_prefix() {
454        let cases = [
455            ("..", "", "/v1/"),
456            ("..", "/ttl", "/v1/ttl"),
457            ("..", "/lock", "/v1/lock"),
458            (".", "", "/v1/cache/"),
459        ];
460        for (key, suffix, escaped) in cases {
461            let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
462            let parsed = Url::parse(&raw).expect("parses");
463            assert_eq!(
464                parsed.path(),
465                escaped,
466                "raw {key:?}{suffix} should collapse to {escaped}"
467            );
468            // The %2E form the Python SDK emits collapses just the same in rust-url.
469            let pct = key.replace('.', "%2E");
470            let enc = format!("{API}/v1/cache/{pct}{suffix}");
471            assert_eq!(
472                Url::parse(&enc).expect("parses").path(),
473                escaped,
474                "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
475            );
476        }
477    }
478
479    /// AC-2 / spec rule 2 — all five reserved segments (`.`, `..`, `health`,
480    /// `ttl`, `lock`) are rejected by every builder (base, ttl, lock): no URL is
481    /// produced, so no rewritten or mis-routed request can ever be sent.
482    #[test]
483    fn reserved_segments_rejected_by_every_builder() {
484        let b = backend();
485        for key in [".", "..", "health", "ttl", "lock"] {
486            assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
487            assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
488            assert!(
489                b.lock_url(key).is_err(),
490                "lock_url({key:?}) must be rejected"
491            );
492        }
493    }
494
495    /// AC-2 — every non-dot vector builds a URL whose *parsed* path (the real
496    /// wire path, post-normalisation) stays inside `/v1/cache/`. Asserting on the
497    /// unparsed `format!` output would pass while still shipping a traversal, so
498    /// we parse with the same `url` crate `reqwest` uses.
499    #[test]
500    fn safe_keys_never_escape_the_cache_prefix() {
501        let b = backend();
502        let vectors = [
503            "a:..",
504            "default:../../admin",
505            "k?x=1#f",
506            "a b",
507            "healthy",        // route-token near-miss: not reserved, must build fine
508            "x/../../health", // embedded route token, `/`→`%2F` keeps it one segment
509            "ns:default:func:m.f:args:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:",
510        ];
511        for key in vectors {
512            let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
513            let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
514            let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
515
516            assert!(
517                base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
518                "base path {} escaped prefix for {key:?}",
519                base.path()
520            );
521            assert_eq!(
522                base.path(),
523                format!("/v1/cache/{}", urlencoding::encode(key)),
524                "base wire path mismatch for {key:?}"
525            );
526            assert!(
527                ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
528                "ttl path {} escaped prefix for {key:?}",
529                ttl.path()
530            );
531            assert!(
532                lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
533                "lock path {} escaped prefix for {key:?}",
534                lock.path()
535            );
536        }
537    }
538}