vti_common/idempotency/
store.rs1use std::net::IpAddr;
4
5use axum::extract::ConnectInfo;
6use axum::http::request::Parts;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use super::class::IdempotencyClass;
12use crate::error::AppError;
13use crate::store::KeyspaceHandle;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum Principal {
21 AuthToken(Vec<u8>),
26 Ip(IpAddr),
31 Anonymous,
36}
37
38impl Principal {
39 pub fn hash(&self) -> [u8; 32] {
42 let mut hasher = Sha256::new();
43 match self {
44 Principal::AuthToken(bytes) => {
45 hasher.update(b"auth-token:");
46 hasher.update(bytes);
47 }
48 Principal::Ip(ip) => {
49 hasher.update(b"ip:");
50 hasher.update(ip.to_string().as_bytes());
51 }
52 Principal::Anonymous => {
53 hasher.update(b"anonymous");
54 }
55 }
56 hasher.finalize().into()
57 }
58}
59
60pub fn principal_from_request(parts: &Parts) -> Principal {
70 if let Some(auth) = parts.headers.get(axum::http::header::AUTHORIZATION) {
71 return Principal::AuthToken(auth.as_bytes().to_vec());
72 }
73 if let Some(ConnectInfo(addr)) = parts.extensions.get::<ConnectInfo<std::net::SocketAddr>>() {
74 return Principal::Ip(addr.ip());
75 }
76 Principal::Anonymous
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct CacheEntry {
87 pub idempotency_key: String,
88 pub request_hash: [u8; 32],
91 pub response_status: u16,
92 pub response_headers: Vec<(String, String)>,
93 pub response_body: Vec<u8>,
94 pub class: IdempotencyClass,
95 pub created_at: DateTime<Utc>,
96 pub expires_at: DateTime<Utc>,
97}
98
99impl CacheEntry {
100 pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
101 self.expires_at <= now
102 }
103}
104
105#[derive(Clone)]
112pub struct IdempotencyStore {
113 ks: KeyspaceHandle,
114}
115
116impl IdempotencyStore {
117 pub fn new(ks: KeyspaceHandle) -> Self {
118 Self { ks }
119 }
120
121 pub async fn get(
125 &self,
126 principal_hash: &[u8; 32],
127 key: &str,
128 ) -> Result<Option<CacheEntry>, AppError> {
129 let storage_key = storage_key(principal_hash, key);
130 let entry: Option<CacheEntry> = self.ks.get(storage_key).await?;
131 let now = Utc::now();
132 Ok(entry.filter(|e| !e.is_expired(now)))
133 }
134
135 pub async fn put(&self, principal_hash: &[u8; 32], entry: &CacheEntry) -> Result<(), AppError> {
138 let storage_key = storage_key(principal_hash, &entry.idempotency_key);
139 self.ks.insert(storage_key, entry).await
140 }
141}
142
143fn storage_key(principal_hash: &[u8; 32], key: &str) -> Vec<u8> {
144 let mut out = Vec::with_capacity(64 + key.len() + 5);
150 out.extend_from_slice(b"idem:");
151 out.extend_from_slice(hex::encode(principal_hash).as_bytes());
152 out.push(b':');
153 out.extend_from_slice(key.as_bytes());
154 out
155}
156
157#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::config::StoreConfig;
165 use crate::store::Store;
166 use chrono::Duration;
167
168 fn temp_store() -> (IdempotencyStore, tempfile::TempDir) {
169 let dir = tempfile::tempdir().expect("tempdir");
170 let cfg = StoreConfig {
171 data_dir: dir.path().to_path_buf(),
172 };
173 let store = Store::open(&cfg).expect("store");
174 let ks = store.keyspace("idempotency-test").expect("ks");
175 (IdempotencyStore::new(ks), dir)
176 }
177
178 fn sample_entry() -> CacheEntry {
179 let now = Utc::now();
180 CacheEntry {
181 idempotency_key: "key-1".into(),
182 request_hash: [0xAB; 32],
183 response_status: 201,
184 response_headers: vec![("content-type".into(), "application/json".into())],
185 response_body: br#"{"ok":true}"#.to_vec(),
186 class: IdempotencyClass::NonDestructive,
187 created_at: now,
188 expires_at: now
189 + Duration::seconds(IdempotencyClass::NonDestructive.ttl_seconds() as i64),
190 }
191 }
192
193 #[test]
194 fn principal_hash_is_stable_and_distinct_across_kinds() {
195 let a = Principal::AuthToken(b"Bearer abc".to_vec());
196 let a_again = Principal::AuthToken(b"Bearer abc".to_vec());
197 let b = Principal::AuthToken(b"Bearer xyz".to_vec());
198 let ip = Principal::Ip(IpAddr::V4("127.0.0.1".parse().unwrap()));
199 let anon = Principal::Anonymous;
200
201 assert_eq!(a.hash(), a_again.hash());
202 assert_ne!(a.hash(), b.hash());
203 assert_ne!(a.hash(), ip.hash());
204 assert_ne!(a.hash(), anon.hash());
205 assert_ne!(ip.hash(), anon.hash());
206 }
207
208 #[tokio::test]
209 async fn put_then_get_returns_entry() {
210 let (store, _dir) = temp_store();
211 let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
212 let entry = sample_entry();
213
214 store.put(&principal, &entry).await.unwrap();
215 let got = store.get(&principal, &entry.idempotency_key).await.unwrap();
216 assert_eq!(got.as_ref(), Some(&entry));
217 }
218
219 #[tokio::test]
220 async fn entries_are_scoped_by_principal() {
221 let (store, _dir) = temp_store();
222 let a = Principal::AuthToken(b"alice".to_vec()).hash();
223 let b = Principal::AuthToken(b"bob".to_vec()).hash();
224 let entry = sample_entry();
225
226 store.put(&a, &entry).await.unwrap();
227 let got_a = store.get(&a, &entry.idempotency_key).await.unwrap();
228 let got_b = store.get(&b, &entry.idempotency_key).await.unwrap();
229 assert!(got_a.is_some());
230 assert!(got_b.is_none(), "principal scoping leaked");
231 }
232
233 #[tokio::test]
234 async fn expired_entries_are_filtered_at_read_time() {
235 let (store, _dir) = temp_store();
236 let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
237 let mut entry = sample_entry();
238 entry.expires_at = Utc::now() - Duration::seconds(1);
239 store.put(&principal, &entry).await.unwrap();
240
241 let got = store.get(&principal, &entry.idempotency_key).await.unwrap();
242 assert!(got.is_none(), "stale entry served");
243 }
244
245 #[tokio::test]
246 async fn put_overwrites_existing_entry_under_same_key() {
247 let (store, _dir) = temp_store();
248 let principal = Principal::AuthToken(b"Bearer t".to_vec()).hash();
249 let first = sample_entry();
250 store.put(&principal, &first).await.unwrap();
251
252 let mut second = first.clone();
253 second.response_status = 204;
254 second.response_body = b"updated".to_vec();
255 store.put(&principal, &second).await.unwrap();
256
257 let got = store.get(&principal, &first.idempotency_key).await.unwrap();
258 assert_eq!(got.unwrap().response_status, 204);
259 }
260}