Skip to main content

cachekit/backend/
cachekitio_lock.rs

1//! [`LockableBackend`] implementation for the cachekit.io HTTP backend.
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use super::cachekitio::{reqwest_err_sanitized, CachekitIO};
7use super::LockableBackend;
8use crate::error::BackendError;
9
10/// Lock capability token travels in this request header, never the query string
11/// (CWE-532): a `?lock_id=` query leaks the token into access/proxy logs and
12/// OpenTelemetry `http.url` spans. SaaS dual-reads header + legacy query during
13/// rollout, preferring the header. See protocol `spec/saas-api.md`.
14const LOCK_ID_HEADER: &str = "X-CacheKit-Lock-Id";
15
16impl CachekitIO {
17    /// Build the unlock request. Extracted so tests can assert the lock_id rides the
18    /// `X-CacheKit-Lock-Id` header and never appears in the URL (CWE-532).
19    fn release_request(
20        &self,
21        key: &str,
22        lock_id: &str,
23    ) -> Result<reqwest::RequestBuilder, BackendError> {
24        let url = self.lock_url(key)?;
25        Ok(self.with_standard_headers(self.client().delete(&url).header(LOCK_ID_HEADER, lock_id)))
26    }
27}
28
29// Wire contract is snake_case (`spec/saas-api.md` Lock Endpoints): `timeout_ms` in,
30// `lock_id` out. No serde rename — the Rust field names ARE the wire names.
31#[derive(Serialize)]
32struct LockAcquireRequest {
33    timeout_ms: u64,
34}
35
36#[derive(Deserialize)]
37#[serde(deny_unknown_fields)]
38struct LockAcquireResponse {
39    lock_id: Option<String>,
40}
41
42#[cfg(not(target_arch = "wasm32"))]
43#[cfg_attr(not(feature = "unsync"), async_trait)]
44#[cfg_attr(feature = "unsync", async_trait(?Send))]
45impl LockableBackend for CachekitIO {
46    async fn acquire_lock(
47        &self,
48        key: &str,
49        timeout_ms: u64,
50    ) -> Result<Option<String>, BackendError> {
51        let url = self.lock_url(key)?;
52
53        let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
54            BackendError::permanent(format!("failed to serialize lock request: {e}"))
55        })?;
56
57        let req = self.with_standard_headers(
58            self.client()
59                .post(&url)
60                .header("Content-Type", "application/json")
61                .body(body),
62        );
63
64        let resp = req
65            .send()
66            .await
67            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
68
69        if !resp.status().is_success() {
70            return Err(self.error_from_response(resp).await);
71        }
72
73        let response: LockAcquireResponse = resp
74            .json()
75            .await
76            .map_err(|e| BackendError::transient(format!("failed to parse lock response: {e}")))?;
77
78        Ok(response.lock_id)
79    }
80
81    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
82        // lock_id is a capability token → X-CacheKit-Lock-Id header, not the query string
83        // (CWE-532). See `release_request`.
84        let resp = self
85            .release_request(key, lock_id)?
86            .send()
87            .await
88            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
89
90        match resp.status().as_u16() {
91            200 | 204 => Ok(true),
92            404 => Ok(false),
93            _ => Err(self.error_from_response(resp).await),
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    /// Compile-time proof that CachekitIO implements LockableBackend.
103    fn _assert_lockable(_b: &dyn LockableBackend) {}
104
105    #[test]
106    fn cachekitio_is_lockable() {
107        fn _check(backend: &CachekitIO) {
108            _assert_lockable(backend);
109        }
110    }
111
112    #[test]
113    #[allow(clippy::expect_used)] // test-only: a failed build/missing header should panic loudly
114    fn release_lock_sends_token_in_header_not_url() {
115        let backend = CachekitIO::builder()
116            .api_url("https://api.cachekit.io")
117            .api_key("ck_test_key")
118            .build()
119            .expect("builder should succeed for the canonical host");
120
121        let req = backend
122            .release_request("my-key", "lock-secret-123")
123            .expect("release_request should build for a normal key")
124            .build()
125            .expect("request should build");
126
127        // CWE-532: the capability token must never appear in the URL.
128        let url = req.url();
129        assert!(url.query().is_none(), "unexpected query string: {url}");
130        assert!(
131            !url.as_str().contains("lock_id"),
132            "lock_id leaked into URL: {url}"
133        );
134        assert!(
135            !url.as_str().contains("lock-secret-123"),
136            "token leaked into URL: {url}"
137        );
138
139        // ...it rides the X-CacheKit-Lock-Id header under the exact wire name.
140        let header = req
141            .headers()
142            .get("X-CacheKit-Lock-Id")
143            .expect("X-CacheKit-Lock-Id header must be set");
144        assert_eq!(header, "lock-secret-123");
145    }
146
147    // Wire contract is snake_case (`spec/saas-api.md` Lock Endpoints): the server reads
148    // `timeout_ms` and writes `lock_id`. These tests pin the literal JSON so a serde
149    // rename can't silently break interop again.
150
151    #[test]
152    #[allow(clippy::expect_used)]
153    fn acquire_request_serializes_snake_case() {
154        let body = serde_json::to_string(&LockAcquireRequest { timeout_ms: 5000 })
155            .expect("request must serialize");
156        assert_eq!(body, r#"{"timeout_ms":5000}"#);
157    }
158
159    #[test]
160    #[allow(clippy::expect_used)]
161    fn acquire_response_parses_acquired_lock() {
162        let resp: LockAcquireResponse =
163            serde_json::from_str(r#"{"lock_id":"a1b2c3d4-0000-0000-0000-000000000000"}"#)
164                .expect("acquired response must parse");
165        assert_eq!(
166            resp.lock_id.as_deref(),
167            Some("a1b2c3d4-0000-0000-0000-000000000000")
168        );
169    }
170
171    #[test]
172    #[allow(clippy::expect_used)]
173    fn acquire_response_parses_contested_lock() {
174        // Contested lock: server returns 200 {"lock_id": null} (protocol#22).
175        let resp: LockAcquireResponse =
176            serde_json::from_str(r#"{"lock_id":null}"#).expect("contested response must parse");
177        assert_eq!(resp.lock_id, None);
178    }
179}