use std::path::PathBuf;
use aes_gcm::aead::OsRng;
use aes_gcm::aead::rand_core::RngCore;
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use uuid::Uuid;
use zeroize::ZeroizeOnDrop;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
const TOKEN_RAW_LEN: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BundleKind {
Export,
Import,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BundleState {
ExportReady,
ExportDownloaded,
ExportAcked,
ImportPending,
ImportReceived,
ImportPreviewed,
ImportCommitted,
Aborted,
Expired,
}
impl BundleState {
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::ExportDownloaded
| Self::ExportAcked
| Self::ImportCommitted
| Self::Aborted
| Self::Expired
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleRecord {
pub bundle_id: Uuid,
pub kind: BundleKind,
pub state: BundleState,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub created_by: String,
pub algorithm: String,
pub expected_sha256: String,
pub expected_size_bytes: u64,
pub token_hash: [u8; 32],
pub blob_path: Option<PathBuf>,
}
#[derive(Clone, Serialize, Deserialize, ZeroizeOnDrop)]
pub struct BundleToken(pub String);
impl std::fmt::Debug for BundleToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("BundleToken").field(&"<redacted>").finish()
}
}
impl BundleToken {
pub fn as_str(&self) -> &str {
&self.0
}
}
fn bundle_key(id: &Uuid) -> String {
format!("bundle:{id}")
}
pub async fn get_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<Option<BundleRecord>, AppError> {
ks.get(bundle_key(id)).await
}
pub async fn store_bundle(ks: &KeyspaceHandle, record: &BundleRecord) -> Result<(), AppError> {
ks.insert(bundle_key(&record.bundle_id), record).await
}
pub async fn delete_bundle(ks: &KeyspaceHandle, id: &Uuid) -> Result<(), AppError> {
ks.remove(bundle_key(id)).await
}
pub async fn list_bundles(ks: &KeyspaceHandle) -> Result<Vec<BundleRecord>, AppError> {
let raw = ks.prefix_iter_raw("bundle:").await?;
let mut out = Vec::with_capacity(raw.len());
for (_, v) in raw {
let record: BundleRecord = serde_json::from_slice(&v)
.map_err(|e| AppError::Internal(format!("bundle record decode: {e}")))?;
out.push(record);
}
Ok(out)
}
pub fn mint_token() -> Result<(BundleToken, [u8; 32]), AppError> {
let mut raw = [0u8; TOKEN_RAW_LEN];
OsRng.fill_bytes(&mut raw);
let token_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let hash = hash_token(&token_b64);
Ok((BundleToken(token_b64), hash))
}
pub fn hash_token(token_b64: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(token_b64.as_bytes());
hasher.finalize().into()
}
pub fn verify_token(provided: &str, expected_hash: &[u8; 32]) -> bool {
let computed = hash_token(provided);
computed.ct_eq(expected_hash).into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::Store;
use vti_common::config::StoreConfig as VtiStoreConfig;
async fn setup_ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&VtiStoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let ks = store
.keyspace(crate::keyspaces::BACKUP_BUNDLES_TEST)
.unwrap();
(dir, ks)
}
#[tokio::test]
async fn bundle_round_trips_through_keyspace() {
let (_dir, ks) = setup_ks().await;
let id = Uuid::new_v4();
let record = BundleRecord {
bundle_id: id,
kind: BundleKind::Export,
state: BundleState::ExportReady,
created_at: Utc::now(),
expires_at: Utc::now(),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: "deadbeef".into(),
expected_size_bytes: 42,
token_hash: [7u8; 32],
blob_path: Some(PathBuf::from("/var/lib/vta/backups/a.vtabak")),
};
store_bundle(&ks, &record).await.unwrap();
let restored = get_bundle(&ks, &id).await.unwrap().unwrap();
assert_eq!(restored.bundle_id, id);
assert_eq!(restored.state, BundleState::ExportReady);
assert_eq!(restored.token_hash, [7u8; 32]);
}
#[tokio::test]
async fn delete_removes_record() {
let (_dir, ks) = setup_ks().await;
let id = Uuid::new_v4();
let record = BundleRecord {
bundle_id: id,
kind: BundleKind::Import,
state: BundleState::ImportPending,
created_at: Utc::now(),
expires_at: Utc::now(),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: "feedface".into(),
expected_size_bytes: 0,
token_hash: [0u8; 32],
blob_path: None,
};
store_bundle(&ks, &record).await.unwrap();
delete_bundle(&ks, &id).await.unwrap();
assert!(get_bundle(&ks, &id).await.unwrap().is_none());
}
#[tokio::test]
async fn list_returns_all_bundles_via_prefix_scan() {
let (_dir, ks) = setup_ks().await;
let make = |kind: BundleKind, state: BundleState| BundleRecord {
bundle_id: Uuid::new_v4(),
kind,
state,
created_at: Utc::now(),
expires_at: Utc::now(),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: "0".into(),
expected_size_bytes: 0,
token_hash: [0u8; 32],
blob_path: None,
};
let a = make(BundleKind::Export, BundleState::ExportReady);
let b = make(BundleKind::Import, BundleState::ImportPending);
store_bundle(&ks, &a).await.unwrap();
store_bundle(&ks, &b).await.unwrap();
let all = list_bundles(&ks).await.unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn is_terminal_pins_the_state_machine_taxonomy() {
assert!(!BundleState::ExportReady.is_terminal());
assert!(!BundleState::ImportPending.is_terminal());
assert!(!BundleState::ImportReceived.is_terminal());
assert!(!BundleState::ImportPreviewed.is_terminal());
assert!(BundleState::ExportDownloaded.is_terminal());
assert!(BundleState::ExportAcked.is_terminal());
assert!(BundleState::ImportCommitted.is_terminal());
assert!(BundleState::Aborted.is_terminal());
assert!(BundleState::Expired.is_terminal());
}
#[test]
fn bundle_token_debug_redacts_secret() {
let token = BundleToken("super-secret-token-AAA".into());
let dbg = format!("{token:?}");
assert!(
dbg.contains("<redacted>"),
"BundleToken debug must redact secret material: {dbg}"
);
assert!(!dbg.contains("super-secret-token"));
}
#[test]
fn mint_token_produces_distinct_tokens_per_call() {
let (a, _) = mint_token().expect("mint a");
let (b, _) = mint_token().expect("mint b");
assert_ne!(
a.as_str(),
b.as_str(),
"two mint_token calls must produce different tokens \
(OsRng output collision is effectively impossible)"
);
}
#[test]
fn mint_token_emits_url_safe_base64() {
let (token, _) = mint_token().expect("mint");
for ch in token.as_str().chars() {
assert!(
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-',
"token contains non-base64url char: {ch:?} ({})",
token.as_str()
);
}
assert!(!token.as_str().contains('='));
}
#[test]
fn hash_is_deterministic_across_calls() {
let h1 = hash_token("AAAA-BBBB-CCCC");
let h2 = hash_token("AAAA-BBBB-CCCC");
assert_eq!(h1, h2, "SHA-256 of the same input must match");
}
#[test]
fn verify_token_accepts_matching_token() {
let (token, hash) = mint_token().expect("mint");
assert!(
verify_token(token.as_str(), &hash),
"freshly-minted token must verify against its own hash"
);
}
#[test]
fn verify_token_rejects_mismatched_token() {
let (_token, hash) = mint_token().expect("mint");
assert!(
!verify_token("not-the-right-token", &hash),
"arbitrary string must not validate as a freshly-minted token"
);
}
#[test]
fn verify_token_rejects_token_with_one_byte_flipped() {
let (token, hash) = mint_token().expect("mint");
let mut tampered = String::from(token.as_str());
let first = tampered.chars().next().expect("non-empty");
let replacement = if first == 'A' { 'B' } else { 'A' };
tampered.replace_range(0..1, &replacement.to_string());
assert!(
!verify_token(&tampered, &hash),
"single-bit-flipped token must fail verification"
);
}
#[test]
fn verify_token_rejects_empty_string() {
let (_token, hash) = mint_token().expect("mint");
assert!(!verify_token("", &hash), "empty string must not validate");
}
#[test]
fn mint_token_paired_hash_matches_re_hashed_token() {
let (token, hash) = mint_token().expect("mint");
let recomputed = hash_token(token.as_str());
assert_eq!(hash, recomputed);
}
}