1use std::path::PathBuf;
15
16use aes_gcm::aead::OsRng;
17use aes_gcm::aead::rand_core::RngCore;
18use base64::Engine;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use subtle::ConstantTimeEq;
23use uuid::Uuid;
24use zeroize::ZeroizeOnDrop;
25
26use vti_common::error::AppError;
27use vti_common::store::KeyspaceHandle;
28
29const TOKEN_RAW_LEN: usize = 32;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum BundleKind {
40 Export,
41 Import,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum BundleState {
49 ExportReady,
51 ExportDownloaded,
54 ExportAcked,
56 ImportPending,
58 ImportReceived,
60 ImportPreviewed,
63 ImportCommitted,
65 Aborted,
67 Expired,
69}
70
71impl BundleState {
72 pub fn is_terminal(self) -> bool {
76 matches!(
77 self,
78 Self::ExportDownloaded
79 | Self::ExportAcked
80 | Self::ImportCommitted
81 | Self::Aborted
82 | Self::Expired
83 )
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct BundleRecord {
98 pub bundle_id: Uuid,
99 pub kind: BundleKind,
100 pub state: BundleState,
101 pub created_at: DateTime<Utc>,
102 pub expires_at: DateTime<Utc>,
103 pub created_by: String,
106 pub algorithm: String,
108 pub expected_sha256: String,
109 pub expected_size_bytes: u64,
110 pub token_hash: [u8; 32],
113 pub blob_path: Option<PathBuf>,
117}
118
119#[derive(Clone, Serialize, Deserialize, ZeroizeOnDrop)]
126pub struct BundleToken(pub String);
127
128impl std::fmt::Debug for BundleToken {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_tuple("BundleToken").field(&"<redacted>").finish()
131 }
132}
133
134impl BundleToken {
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138}
139
140fn bundle_key(id: &Uuid) -> String {
141 format!("bundle:{id}")
142}
143
144pub async fn get_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<Option<BundleRecord>, AppError> {
146 ks.get(bundle_key(id)).await
147}
148
149pub async fn store_bundle(ks: &KeyspaceHandle, record: &BundleRecord) -> Result<(), AppError> {
152 ks.insert(bundle_key(&record.bundle_id), record).await
153}
154
155pub async fn delete_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<(), AppError> {
158 ks.remove(bundle_key(id)).await
159}
160
161pub async fn list_bundles(ks: &KeyspaceHandle) -> Result<Vec<BundleRecord>, AppError> {
166 let raw = ks.prefix_iter_raw("bundle:").await?;
167 let mut out = Vec::with_capacity(raw.len());
168 for (_, v) in raw {
169 let record: BundleRecord = serde_json::from_slice(&v)
170 .map_err(|e| AppError::Internal(format!("bundle record decode: {e}")))?;
171 out.push(record);
172 }
173 Ok(out)
174}
175
176pub fn mint_token() -> Result<(BundleToken, [u8; 32]), AppError> {
190 let mut raw = [0u8; TOKEN_RAW_LEN];
191 OsRng.fill_bytes(&mut raw);
197 let token_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
198 let hash = hash_token(&token_b64);
199 Ok((BundleToken(token_b64), hash))
200}
201
202pub fn hash_token(token_b64: &str) -> [u8; 32] {
208 let mut hasher = Sha256::new();
209 hasher.update(token_b64.as_bytes());
210 hasher.finalize().into()
211}
212
213pub fn verify_token(provided: &str, expected_hash: &[u8; 32]) -> bool {
223 let computed = hash_token(provided);
224 computed.ct_eq(expected_hash).into()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use vti_common::config::StoreConfig as VtiStoreConfig;
231 use vti_common::store::Store;
232
233 async fn setup_ks() -> (tempfile::TempDir, KeyspaceHandle) {
234 let dir = tempfile::tempdir().unwrap();
235 let store = Store::open(&VtiStoreConfig {
236 data_dir: dir.path().into(),
237 })
238 .unwrap();
239 let ks = store.keyspace(crate::BACKUP_BUNDLES_TEST).unwrap();
240 (dir, ks)
241 }
242
243 #[tokio::test]
244 async fn bundle_round_trips_through_keyspace() {
245 let (_dir, ks) = setup_ks().await;
246 let id = Uuid::new_v4();
247 let record = BundleRecord {
248 bundle_id: id,
249 kind: BundleKind::Export,
250 state: BundleState::ExportReady,
251 created_at: Utc::now(),
252 expires_at: Utc::now(),
253 created_by: "did:example:admin".into(),
254 algorithm: "stream".into(),
255 expected_sha256: "deadbeef".into(),
256 expected_size_bytes: 42,
257 token_hash: [7u8; 32],
258 blob_path: Some(PathBuf::from("/var/lib/vta/backups/a.vtabak")),
259 };
260 store_bundle(&ks, &record).await.unwrap();
261 let restored = get_bundle(&ks, &id).await.unwrap().unwrap();
262 assert_eq!(restored.bundle_id, id);
263 assert_eq!(restored.state, BundleState::ExportReady);
264 assert_eq!(restored.token_hash, [7u8; 32]);
265 }
266
267 #[tokio::test]
268 async fn delete_removes_record() {
269 let (_dir, ks) = setup_ks().await;
270 let id = Uuid::new_v4();
271 let record = BundleRecord {
272 bundle_id: id,
273 kind: BundleKind::Import,
274 state: BundleState::ImportPending,
275 created_at: Utc::now(),
276 expires_at: Utc::now(),
277 created_by: "did:example:admin".into(),
278 algorithm: "stream".into(),
279 expected_sha256: "feedface".into(),
280 expected_size_bytes: 0,
281 token_hash: [0u8; 32],
282 blob_path: None,
283 };
284 store_bundle(&ks, &record).await.unwrap();
285 delete_bundle(&ks, &id).await.unwrap();
286 assert!(get_bundle(&ks, &id).await.unwrap().is_none());
287 }
288
289 #[tokio::test]
290 async fn list_returns_all_bundles_via_prefix_scan() {
291 let (_dir, ks) = setup_ks().await;
292 let make = |kind: BundleKind, state: BundleState| BundleRecord {
293 bundle_id: Uuid::new_v4(),
294 kind,
295 state,
296 created_at: Utc::now(),
297 expires_at: Utc::now(),
298 created_by: "did:example:admin".into(),
299 algorithm: "stream".into(),
300 expected_sha256: "0".into(),
301 expected_size_bytes: 0,
302 token_hash: [0u8; 32],
303 blob_path: None,
304 };
305 let a = make(BundleKind::Export, BundleState::ExportReady);
306 let b = make(BundleKind::Import, BundleState::ImportPending);
307 store_bundle(&ks, &a).await.unwrap();
308 store_bundle(&ks, &b).await.unwrap();
309 let all = list_bundles(&ks).await.unwrap();
310 assert_eq!(all.len(), 2);
311 }
312
313 #[test]
314 fn is_terminal_pins_the_state_machine_taxonomy() {
315 assert!(!BundleState::ExportReady.is_terminal());
318 assert!(!BundleState::ImportPending.is_terminal());
319 assert!(!BundleState::ImportReceived.is_terminal());
320 assert!(!BundleState::ImportPreviewed.is_terminal());
321
322 assert!(BundleState::ExportDownloaded.is_terminal());
324 assert!(BundleState::ExportAcked.is_terminal());
325 assert!(BundleState::ImportCommitted.is_terminal());
326 assert!(BundleState::Aborted.is_terminal());
327 assert!(BundleState::Expired.is_terminal());
328 }
329
330 #[test]
331 fn bundle_token_debug_redacts_secret() {
332 let token = BundleToken("super-secret-token-AAA".into());
333 let dbg = format!("{token:?}");
334 assert!(
335 dbg.contains("<redacted>"),
336 "BundleToken debug must redact secret material: {dbg}"
337 );
338 assert!(!dbg.contains("super-secret-token"));
339 }
340
341 #[test]
342 fn mint_token_produces_distinct_tokens_per_call() {
343 let (a, _) = mint_token().expect("mint a");
344 let (b, _) = mint_token().expect("mint b");
345 assert_ne!(
346 a.as_str(),
347 b.as_str(),
348 "two mint_token calls must produce different tokens \
349 (OsRng output collision is effectively impossible)"
350 );
351 }
352
353 #[test]
354 fn mint_token_emits_url_safe_base64() {
355 let (token, _) = mint_token().expect("mint");
356 for ch in token.as_str().chars() {
358 assert!(
359 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-',
360 "token contains non-base64url char: {ch:?} ({})",
361 token.as_str()
362 );
363 }
364 assert!(!token.as_str().contains('='));
365 }
366
367 #[test]
368 fn hash_is_deterministic_across_calls() {
369 let h1 = hash_token("AAAA-BBBB-CCCC");
370 let h2 = hash_token("AAAA-BBBB-CCCC");
371 assert_eq!(h1, h2, "SHA-256 of the same input must match");
372 }
373
374 #[test]
375 fn verify_token_accepts_matching_token() {
376 let (token, hash) = mint_token().expect("mint");
377 assert!(
378 verify_token(token.as_str(), &hash),
379 "freshly-minted token must verify against its own hash"
380 );
381 }
382
383 #[test]
384 fn verify_token_rejects_mismatched_token() {
385 let (_token, hash) = mint_token().expect("mint");
386 assert!(
387 !verify_token("not-the-right-token", &hash),
388 "arbitrary string must not validate as a freshly-minted token"
389 );
390 }
391
392 #[test]
393 fn verify_token_rejects_token_with_one_byte_flipped() {
394 let (token, hash) = mint_token().expect("mint");
395 let mut tampered = String::from(token.as_str());
397 let first = tampered.chars().next().expect("non-empty");
398 let replacement = if first == 'A' { 'B' } else { 'A' };
400 tampered.replace_range(0..1, &replacement.to_string());
401 assert!(
402 !verify_token(&tampered, &hash),
403 "single-bit-flipped token must fail verification"
404 );
405 }
406
407 #[test]
408 fn verify_token_rejects_empty_string() {
409 let (_token, hash) = mint_token().expect("mint");
410 assert!(!verify_token("", &hash), "empty string must not validate");
411 }
412
413 #[test]
414 fn mint_token_paired_hash_matches_re_hashed_token() {
415 let (token, hash) = mint_token().expect("mint");
420 let recomputed = hash_token(token.as_str());
421 assert_eq!(hash, recomputed);
422 }
423}