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