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