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#[derive(Serialize)]
35#[serde(rename_all = "camelCase")]
36struct LockAcquireRequest {
37    timeout_ms: u64,
38}
39
40#[derive(Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42struct LockAcquireResponse {
43    lock_id: Option<String>,
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47#[cfg_attr(not(feature = "unsync"), async_trait)]
48#[cfg_attr(feature = "unsync", async_trait(?Send))]
49impl LockableBackend for CachekitIO {
50    async fn acquire_lock(
51        &self,
52        key: &str,
53        timeout_ms: u64,
54    ) -> Result<Option<String>, BackendError> {
55        let url = format!(
56            "{}/v1/cache/{}/lock",
57            self.api_url(),
58            urlencoding::encode(key)
59        );
60
61        let body = serde_json::to_vec(&LockAcquireRequest { timeout_ms }).map_err(|e| {
62            BackendError::permanent(format!("failed to serialize lock request: {e}"))
63        })?;
64
65        let req = self.with_standard_headers(
66            self.client()
67                .post(&url)
68                .bearer_auth(self.api_key_str())
69                .header("Content-Type", "application/json")
70                .body(body),
71        );
72
73        let resp = req
74            .send()
75            .await
76            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
77
78        if !resp.status().is_success() {
79            return Err(self.error_from_response(resp).await);
80        }
81
82        let response: LockAcquireResponse = resp
83            .json()
84            .await
85            .map_err(|e| BackendError::transient(format!("failed to parse lock response: {e}")))?;
86
87        Ok(response.lock_id)
88    }
89
90    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError> {
91        // lock_id is a capability token → X-CacheKit-Lock-Id header, not the query string
92        // (CWE-532). See `release_request`.
93        let resp = self
94            .release_request(key, lock_id)
95            .send()
96            .await
97            .map_err(|e| reqwest_err_sanitized(e, self.api_key_str()))?;
98
99        match resp.status().as_u16() {
100            200 | 204 => Ok(true),
101            404 => Ok(false),
102            _ => Err(self.error_from_response(resp).await),
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    /// Compile-time proof that CachekitIO implements LockableBackend.
112    fn _assert_lockable(_b: &dyn LockableBackend) {}
113
114    #[test]
115    fn cachekitio_is_lockable() {
116        fn _check(backend: &CachekitIO) {
117            _assert_lockable(backend);
118        }
119    }
120
121    #[test]
122    #[allow(clippy::expect_used)] // test-only: a failed build/missing header should panic loudly
123    fn release_lock_sends_token_in_header_not_url() {
124        let backend = CachekitIO::builder()
125            .api_url("https://api.cachekit.io")
126            .api_key("ck_test_key")
127            .build()
128            .expect("builder should succeed for the canonical host");
129
130        let req = backend
131            .release_request("my-key", "lock-secret-123")
132            .build()
133            .expect("request should build");
134
135        // CWE-532: the capability token must never appear in the URL.
136        let url = req.url();
137        assert!(url.query().is_none(), "unexpected query string: {url}");
138        assert!(
139            !url.as_str().contains("lock_id"),
140            "lock_id leaked into URL: {url}"
141        );
142        assert!(
143            !url.as_str().contains("lock-secret-123"),
144            "token leaked into URL: {url}"
145        );
146
147        // ...it rides the X-CacheKit-Lock-Id header under the exact wire name.
148        let header = req
149            .headers()
150            .get("X-CacheKit-Lock-Id")
151            .expect("X-CacheKit-Lock-Id header must be set");
152        assert_eq!(header, "lock-secret-123");
153    }
154}