Skip to main content

vta_backup/ops/
descriptors.rs

1//! Op-layer for the backup-descriptor pattern (`spec/vta/backup/*`).
2//!
3//! Five functions, one per trust-task URI. Each one:
4//!
5//! 1. Verifies super-admin auth.
6//! 2. (For non-`initiate-*` ops) verifies the caller-DID matches
7//!    `BundleRecord.created_by` — without this a second super-admin
8//!    could complete or abort the first's in-flight backup.
9//! 3. Reads / mutates the [`BundleRecord`] in `backup_bundles_ks`.
10//! 4. For export: writes the staged `.vtabak` bytes to disk under
11//!    `backup_blob_dir`. For import: reads them back at finalize.
12//! 5. Delegates the actual encrypt/decrypt to the existing
13//!    `export_backup` / `preview_import` / `apply_import` helpers
14//!    in the parent module.
15//!
16//! See `docs/05-design-notes/backup-descriptor-pattern.md` for the
17//! full state machine and rationale.
18
19use std::path::Path;
20use std::sync::Arc;
21
22use chrono::{Duration, Utc};
23use tracing::{info, warn};
24use uuid::Uuid;
25
26use vta_sdk::protocols::backup_management::descriptors::{
27    AbortBundleBody, AbortBundleResultBody, BundleDescriptor, CompleteExportBody,
28    CompleteExportResultBody, FinalizeImportBody, FinalizeImportResultBody, InitiateExportBody,
29    InitiateExportResultBody, InitiateImportBody, InitiateImportResultBody,
30};
31use vta_sdk::protocols::backup_management::types::BackupEnvelope;
32
33use crate::backup_bundle_store::{
34    self, BundleKind, BundleRecord, BundleState, BundleToken, mint_token,
35};
36use vta_config::AppConfig;
37use vta_keys::seed_store::SeedStore;
38use vti_common::auth::AuthClaims;
39use vti_common::error::AppError;
40use vti_common::store::{KeyspaceHandle, Store};
41
42/// Default bundle TTL — 5 minutes per the design doc. Operators
43/// can override via the (future) `VTA_BACKUP_BUNDLE_TTL_SECS` env
44/// var; cap at 1 hour to prevent operator footguns.
45pub const DEFAULT_BUNDLE_TTL_SECS: u64 = 300;
46
47/// Hard ceiling on bundle TTL. 1 hour. A descriptor sitting around
48/// for hours invites token-replay attacks once the operator has
49/// closed their session.
50pub const MAX_BUNDLE_TTL_SECS: u64 = 3600;
51
52/// Per-DID cap on simultaneously-open (non-terminal) bundles.
53/// Prevents one operator from tying up disk by spamming
54/// `initiate-*` without ever finalizing. v1: 3. Future:
55/// config-driven.
56pub const MAX_OPEN_BUNDLES_PER_DID: usize = 3;
57
58/// Borrowed deps for the descriptor ops. Avoids dragging the full
59/// `AppState` into the op layer (it's a server-runtime type)
60/// while keeping the call surface tractable.
61///
62/// Lifetime: `'a` ties to the calling `AppState` (or `VtaState`)
63/// since these are short-lived per-request references.
64pub struct DescriptorDeps<'a> {
65    pub bundles_ks: &'a KeyspaceHandle,
66    pub blob_dir: &'a Path,
67    pub keyspaces: vta_keyspaces::Keyspaces<'a>,
68    pub seed_store: &'a Arc<dyn SeedStore>,
69    pub config: &'a tokio::sync::RwLock<AppConfig>,
70    pub store: Option<&'a Store>,
71    /// TEE-only injected KMS re-encryption hook for the import commit path
72    /// (see [`crate::BootstrapReEncryptor`]). `None` outside Mode-B; supplied
73    /// by `vta-service`'s `operations::descriptor_deps_from_app_state`.
74    #[cfg(feature = "tee")]
75    pub re_encryptor: Option<&'a dyn crate::BootstrapReEncryptor>,
76}
77
78// ─── initiate-export ──────────────────────────────────────────────────
79
80/// Stage the bytes for an export bundle and return the descriptor.
81/// Caller path: `spec/vta/backup/initiate-export/1.0` trust-task
82/// handler.
83///
84/// Steps:
85/// 1. Super-admin auth check.
86/// 2. Validate `algorithm` (only `"stream"` ships v1).
87/// 3. Enforce per-DID open-bundle cap.
88/// 4. Encrypt the backup via the existing `export_backup` op.
89/// 5. Mint bundle_id + bearer token.
90/// 6. Persist bytes to `${blob_dir}/{bundle_id}.vtabak` (0600).
91/// 7. Persist [`BundleRecord`] with state=ExportReady.
92/// 8. Return descriptor + completion hint.
93///
94/// Failure modes:
95/// - Non-`stream` algorithm → `Validation`.
96/// - DID has too many open bundles → `Conflict`.
97/// - `export_backup` failures (password too short, KMS unavailable,
98///   etc.) propagate as their original `AppError`.
99pub async fn initiate_export(
100    deps: &DescriptorDeps<'_>,
101    auth: &AuthClaims,
102    body: InitiateExportBody,
103) -> Result<InitiateExportResultBody, AppError> {
104    auth.require_super_admin()?;
105    validate_algorithm(&body.algorithm)?;
106    enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
107
108    // 1. Run the existing encrypt path to get the envelope.
109    let envelope = {
110        let config_guard = deps.config.read().await;
111        super::export_backup(
112            &deps.keyspaces,
113            deps.seed_store.as_ref(),
114            &config_guard,
115            auth,
116            &body.password,
117            body.include_audit,
118        )
119        .await?
120    };
121
122    // 2. Serialize the envelope as JSON bytes. The blob endpoint
123    //    streams these verbatim; the operator's CLI inflates back
124    //    to BackupEnvelope at import time. SHA-256 over the JSON
125    //    bytes is the wire integrity check.
126    let bytes = serde_json::to_vec(&envelope)
127        .map_err(|e| AppError::Internal(format!("serialize backup envelope: {e}")))?;
128    let sha256_hex = sha256_hex(&bytes);
129    let size = bytes.len() as u64;
130
131    // 3. Mint bundle + token; pre-stage on disk before storing the
132    //    record so a crash leaves no record pointing at missing
133    //    bytes.
134    let bundle_id = Uuid::new_v4();
135    let (token, token_hash) = mint_token()?;
136
137    tokio::fs::create_dir_all(deps.blob_dir)
138        .await
139        .map_err(AppError::Io)?;
140    #[cfg(unix)]
141    set_dir_mode_700(deps.blob_dir).await?;
142    let blob_path = deps.blob_dir.join(format!("{bundle_id}.vtabak"));
143    tokio::fs::write(&blob_path, &bytes)
144        .await
145        .map_err(AppError::Io)?;
146    #[cfg(unix)]
147    set_file_mode_600(&blob_path).await?;
148
149    let now = Utc::now();
150    let record = BundleRecord {
151        bundle_id,
152        kind: BundleKind::Export,
153        state: BundleState::ExportReady,
154        created_at: now,
155        expires_at: now + bundle_ttl(),
156        created_by: auth.did.clone(),
157        algorithm: body.algorithm,
158        expected_sha256: sha256_hex.clone(),
159        expected_size_bytes: size,
160        token_hash,
161        blob_path: Some(blob_path),
162    };
163    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
164
165    info!(bundle_id = %bundle_id, size, "initiate-export: bundle ready");
166
167    Ok(InitiateExportResultBody {
168        descriptor: build_descriptor(&record, token, deps.config).await?,
169        completion_hint: format!(
170            "Download with: pnm backup save --bundle-id {bundle_id} --output backup.vtabak"
171        ),
172    })
173}
174
175// ─── complete-export ─────────────────────────────────────────────────
176
177/// Optional ack from the client after a successful download. Idempotent
178/// on terminal states (returns `downloaded` reflecting whether the
179/// transfer actually happened).
180///
181/// Caller path: `spec/vta/backup/complete-export/1.0` trust-task handler.
182pub async fn complete_export(
183    deps: &DescriptorDeps<'_>,
184    auth: &AuthClaims,
185    body: CompleteExportBody,
186) -> Result<CompleteExportResultBody, AppError> {
187    auth.require_super_admin()?;
188    let bundle_id = parse_bundle_id(&body.bundle_id)?;
189
190    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
191    enforce_kind(&record, BundleKind::Export)?;
192
193    let downloaded = match record.state {
194        BundleState::ExportDownloaded => {
195            record.state = BundleState::ExportAcked;
196            backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
197            true
198        }
199        BundleState::ExportAcked => true,  // already acked
200        BundleState::ExportReady => false, // download never happened
201        BundleState::Aborted | BundleState::Expired => {
202            return Err(AppError::Conflict(format!(
203                "bundle {bundle_id} is in terminal state {:?}; cannot ack",
204                record.state
205            )));
206        }
207        // Import states would have failed the kind check above; this
208        // arm is unreachable in practice, but keep it exhaustive.
209        _ => {
210            return Err(AppError::Internal(format!(
211                "unexpected state for export bundle {bundle_id}: {:?}",
212                record.state
213            )));
214        }
215    };
216
217    info!(bundle_id = %bundle_id, downloaded, "complete-export: acked");
218    Ok(CompleteExportResultBody {
219        bundle_id: bundle_id.to_string(),
220        downloaded,
221    })
222}
223
224// ─── initiate-import ─────────────────────────────────────────────────
225
226/// Mint an upload slot for an import bundle. Returns the descriptor
227/// the client uses to POST bytes to the blob endpoint. Bytes aren't
228/// validated until the subsequent `finalize-import` (since the upload
229/// happens out-of-band).
230pub async fn initiate_import(
231    deps: &DescriptorDeps<'_>,
232    auth: &AuthClaims,
233    body: InitiateImportBody,
234) -> Result<InitiateImportResultBody, AppError> {
235    auth.require_super_admin()?;
236    validate_algorithm(&body.algorithm)?;
237    enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
238
239    // Sanity-check the pre-committed hash and size — empty string
240    // or zero-length blobs almost certainly indicate a CLI bug.
241    if body.expected_sha256.len() != 64
242        || !body.expected_sha256.chars().all(|c| c.is_ascii_hexdigit())
243    {
244        return Err(AppError::Validation(format!(
245            "expected_sha256 must be 64 lowercase hex chars; got `{}`",
246            body.expected_sha256
247        )));
248    }
249    if body.expected_size_bytes == 0 {
250        return Err(AppError::Validation(
251            "expected_size_bytes must be > 0".into(),
252        ));
253    }
254
255    let bundle_id = Uuid::new_v4();
256    let (token, token_hash) = mint_token()?;
257    let now = Utc::now();
258    let record = BundleRecord {
259        bundle_id,
260        kind: BundleKind::Import,
261        state: BundleState::ImportPending,
262        created_at: now,
263        expires_at: now + bundle_ttl(),
264        created_by: auth.did.clone(),
265        algorithm: body.algorithm,
266        expected_sha256: body.expected_sha256,
267        expected_size_bytes: body.expected_size_bytes,
268        token_hash,
269        // Populated by the blob POST handler.
270        blob_path: None,
271    };
272    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
273
274    info!(bundle_id = %bundle_id, "initiate-import: slot ready");
275    Ok(InitiateImportResultBody {
276        descriptor: build_descriptor(&record, token, deps.config).await?,
277        completion_hint: format!(
278            "Upload with: pnm backup restore --bundle-id {bundle_id} --input <path> --password <pw>"
279        ),
280    })
281}
282
283// ─── finalize-import ─────────────────────────────────────────────────
284
285/// Apply (or preview) the uploaded bytes for an import bundle. The
286/// state machine allows multiple preview calls but exactly one
287/// commit (the second commit attempt finds the bundle in
288/// `ImportCommitted`, which is terminal).
289pub async fn finalize_import(
290    deps: &DescriptorDeps<'_>,
291    auth: &AuthClaims,
292    body: FinalizeImportBody,
293) -> Result<FinalizeImportResultBody, AppError> {
294    auth.require_super_admin()?;
295    let bundle_id = parse_bundle_id(&body.bundle_id)?;
296
297    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
298    enforce_kind(&record, BundleKind::Import)?;
299
300    // State must be ImportReceived (upload done) OR ImportPreviewed
301    // (re-running preview after first preview). Anything else is
302    // an error — the client must POST to /backup/blob/{id} first.
303    match record.state {
304        BundleState::ImportReceived | BundleState::ImportPreviewed => {}
305        BundleState::ImportPending => {
306            return Err(AppError::Conflict(format!(
307                "bundle {bundle_id} has no uploaded bytes yet; \
308                 POST to /backup/blob/{bundle_id} first"
309            )));
310        }
311        BundleState::ImportCommitted => {
312            return Err(AppError::Conflict(format!(
313                "bundle {bundle_id} already committed"
314            )));
315        }
316        BundleState::Aborted | BundleState::Expired => {
317            return Err(AppError::Conflict(format!(
318                "bundle {bundle_id} in terminal state {:?}",
319                record.state
320            )));
321        }
322        _ => {
323            return Err(AppError::Internal(format!(
324                "unexpected state for import bundle {bundle_id}: {:?}",
325                record.state
326            )));
327        }
328    }
329
330    let blob_path = record.blob_path.clone().ok_or_else(|| {
331        AppError::Internal(format!("bundle {bundle_id} has no blob_path on disk"))
332    })?;
333    let bytes = tokio::fs::read(&blob_path).await.map_err(AppError::Io)?;
334
335    let envelope: BackupEnvelope = serde_json::from_slice(&bytes).map_err(|e| {
336        AppError::Validation(format!("uploaded bytes are not a BackupEnvelope: {e}"))
337    })?;
338
339    if body.confirm {
340        // Commit path — call existing apply_import.
341        let result = super::apply_import(
342            &super::preview_import(&envelope, &body.password).await?.0,
343            &deps.keyspaces,
344            deps.seed_store,
345            deps.config,
346            deps.store,
347            #[cfg(feature = "tee")]
348            deps.re_encryptor,
349        )
350        .await?;
351
352        // Best-effort delete; sweeper retries.
353        if let Err(e) = tokio::fs::remove_file(&blob_path).await {
354            warn!(
355                bundle_id = %bundle_id,
356                path = %blob_path.display(),
357                error = %e,
358                "finalize-import: failed to delete blob after commit"
359            );
360        }
361        record.state = BundleState::ImportCommitted;
362        record.blob_path = None;
363        backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
364
365        info!(bundle_id = %bundle_id, "finalize-import: committed");
366        Ok(FinalizeImportResultBody {
367            bundle_id: bundle_id.to_string(),
368            status: "committed".into(),
369            source_did: result.source_did,
370            key_count: result.key_count,
371            acl_count: result.acl_count,
372            context_count: result.context_count,
373            audit_count: result.audit_count,
374            imported_secret_count: result.imported_secret_count,
375            message: result.message,
376        })
377    } else {
378        // Preview path — decrypt + validate but don't mutate state.
379        let (_payload, result) = super::preview_import(&envelope, &body.password).await?;
380        record.state = BundleState::ImportPreviewed;
381        backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
382
383        info!(bundle_id = %bundle_id, "finalize-import: preview");
384        Ok(FinalizeImportResultBody {
385            bundle_id: bundle_id.to_string(),
386            status: "preview".into(),
387            source_did: result.source_did,
388            key_count: result.key_count,
389            acl_count: result.acl_count,
390            context_count: result.context_count,
391            audit_count: result.audit_count,
392            imported_secret_count: result.imported_secret_count,
393            message: result.message,
394        })
395    }
396}
397
398// ─── abort ─────────────────────────────────────────────────────────────
399
400/// Cancel an in-flight bundle in any non-terminal state. Idempotent
401/// on terminal — returns `aborted: false` instead of erroring so
402/// re-tries from the operator are safe.
403pub async fn abort_bundle(
404    deps: &DescriptorDeps<'_>,
405    auth: &AuthClaims,
406    body: AbortBundleBody,
407) -> Result<AbortBundleResultBody, AppError> {
408    auth.require_super_admin()?;
409    let bundle_id = parse_bundle_id(&body.bundle_id)?;
410
411    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
412
413    if record.state.is_terminal() {
414        info!(bundle_id = %bundle_id, state = ?record.state, "abort: bundle already terminal");
415        return Ok(AbortBundleResultBody {
416            bundle_id: bundle_id.to_string(),
417            aborted: false,
418        });
419    }
420
421    // Best-effort delete of any staged bytes (export-side: bytes
422    // are on disk; import-side: only if upload already happened).
423    if let Some(path) = record.blob_path.clone()
424        && let Err(e) = tokio::fs::remove_file(&path).await
425    {
426        // NotFound is fine — already gone. Anything else: log
427        // but proceed; the sweeper will retry.
428        if e.kind() != std::io::ErrorKind::NotFound {
429            warn!(
430                bundle_id = %bundle_id,
431                path = %path.display(),
432                error = %e,
433                "abort: failed to delete staged bytes; sweeper will retry"
434            );
435        }
436    }
437
438    record.state = BundleState::Aborted;
439    record.blob_path = None;
440    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
441
442    info!(bundle_id = %bundle_id, "abort: bundle cancelled");
443    Ok(AbortBundleResultBody {
444        bundle_id: bundle_id.to_string(),
445        aborted: true,
446    })
447}
448
449// ─── Internal helpers ────────────────────────────────────────────────
450
451fn bundle_ttl() -> Duration {
452    Duration::seconds(DEFAULT_BUNDLE_TTL_SECS as i64)
453}
454
455fn validate_algorithm(algorithm: &str) -> Result<(), AppError> {
456    if algorithm != "stream" {
457        return Err(AppError::Validation(format!(
458            "unsupported transport algorithm: `{algorithm}`; this VTA supports: stream"
459        )));
460    }
461    Ok(())
462}
463
464async fn enforce_open_bundle_cap(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
465    let all = backup_bundle_store::list_bundles(ks).await?;
466    let open = all
467        .iter()
468        .filter(|r| r.created_by == did && !r.state.is_terminal())
469        .count();
470    if open >= MAX_OPEN_BUNDLES_PER_DID {
471        return Err(AppError::Conflict(format!(
472            "operator `{did}` has {open} open backup bundles; \
473             abort or wait for expiry before initiating another \
474             (cap: {MAX_OPEN_BUNDLES_PER_DID})"
475        )));
476    }
477    Ok(())
478}
479
480fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
481    Uuid::parse_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
482}
483
484/// Look up a bundle and verify the caller owns it. Returns `NotFound`
485/// for both "no such record" and "exists but wrong DID" so the API
486/// doesn't leak the existence of a peer super-admin's bundle.
487async fn require_owned(
488    ks: &KeyspaceHandle,
489    id: &Uuid,
490    caller_did: &str,
491) -> Result<BundleRecord, AppError> {
492    let record = backup_bundle_store::get_bundle(ks, id)
493        .await?
494        .ok_or_else(|| AppError::NotFound(format!("bundle not found: {id}")))?;
495    if record.created_by != caller_did {
496        // Don't leak the bundle's existence.
497        warn!(
498            bundle_id = %id,
499            caller = %caller_did,
500            owner = %record.created_by,
501            "bundle owned by a different super-admin; treating as not-found"
502        );
503        return Err(AppError::NotFound(format!("bundle not found: {id}")));
504    }
505    Ok(record)
506}
507
508fn enforce_kind(record: &BundleRecord, expected: BundleKind) -> Result<(), AppError> {
509    if record.kind != expected {
510        // Treat as not-found — don't leak the existence of a bundle
511        // of the other kind with the same id.
512        return Err(AppError::NotFound(format!(
513            "bundle not found: {}",
514            record.bundle_id
515        )));
516    }
517    Ok(())
518}
519
520async fn build_descriptor(
521    record: &BundleRecord,
522    token: BundleToken,
523    config: &tokio::sync::RwLock<AppConfig>,
524) -> Result<BundleDescriptor, AppError> {
525    let public_url = config.read().await.public_url.clone().ok_or_else(|| {
526        AppError::Internal(
527            "VTA `public_url` is not configured; cannot build backup bundle URL. \
528                 Set `public_url` in config (or VTA_PUBLIC_URL env var) and restart."
529                .into(),
530        )
531    })?;
532    let transport_url = build_blob_url(&public_url, &record.bundle_id);
533    Ok(BundleDescriptor {
534        bundle_id: record.bundle_id.to_string(),
535        algorithm: record.algorithm.clone(),
536        transport_url,
537        transport_token: token.as_str().to_string(),
538        expected_sha256: record.expected_sha256.clone(),
539        expected_size_bytes: record.expected_size_bytes,
540        expires_at: record.expires_at,
541    })
542}
543
544fn build_blob_url(public_url: &str, bundle_id: &Uuid) -> String {
545    let base = public_url.trim_end_matches('/');
546    format!("{base}/backup/blob/{bundle_id}")
547}
548
549fn sha256_hex(bytes: &[u8]) -> String {
550    use sha2::{Digest, Sha256};
551    let mut hasher = Sha256::new();
552    hasher.update(bytes);
553    let out = hasher.finalize();
554    let mut s = String::with_capacity(out.len() * 2);
555    for b in out {
556        s.push_str(&format!("{b:02x}"));
557    }
558    s
559}
560
561#[cfg(unix)]
562async fn set_dir_mode_700(path: &Path) -> Result<(), AppError> {
563    use std::os::unix::fs::PermissionsExt;
564    let perms = std::fs::Permissions::from_mode(0o700);
565    tokio::fs::set_permissions(path, perms)
566        .await
567        .map_err(AppError::Io)
568}
569
570#[cfg(unix)]
571async fn set_file_mode_600(path: &Path) -> Result<(), AppError> {
572    use std::os::unix::fs::PermissionsExt;
573    let perms = std::fs::Permissions::from_mode(0o600);
574    tokio::fs::set_permissions(path, perms)
575        .await
576        .map_err(AppError::Io)
577}
578
579#[cfg(test)]
580mod tests {
581    //! Focused unit tests for the descriptor ops. The full
582    //! `initiate_export → blob GET → complete_export` lifecycle
583    //! lands once the trust-task slice + integration harness are
584    //! in (Stage 5/6); these tests cover the surface that doesn't
585    //! depend on the full `export_backup` keyspace plumbing —
586    //! mostly state-machine transitions, auth gates, owner
587    //! checks, and validation helpers.
588    //!
589    //! Lifecycle tests against the real router will land in the
590    //! Phase-6 integration suite.
591
592    use super::*;
593    use crate::backup_bundle_store::{BundleKind, BundleRecord, BundleState};
594    use chrono::Duration;
595    use std::sync::Arc;
596    use tokio::sync::RwLock;
597    use vti_common::acl::Role;
598    use vti_common::config::StoreConfig as VtiStoreConfig;
599
600    fn super_admin(did: &str) -> AuthClaims {
601        AuthClaims {
602            did: did.into(),
603            role: Role::Admin,
604            allowed_contexts: Vec::new(),
605            session_id: "test-session".into(),
606            access_expires_at: 0,
607            amr: Vec::new(),
608            acr: String::new(),
609        }
610    }
611
612    fn context_admin(did: &str) -> AuthClaims {
613        AuthClaims {
614            did: did.into(),
615            role: Role::Admin,
616            allowed_contexts: vec!["ctx1".into()],
617            session_id: "test-session".into(),
618            access_expires_at: 0,
619            amr: Vec::new(),
620            acr: String::new(),
621        }
622    }
623
624    async fn open_bundles_ks() -> (tempfile::TempDir, KeyspaceHandle) {
625        let dir = tempfile::tempdir().unwrap();
626        let store = Store::open(&VtiStoreConfig {
627            data_dir: dir.path().into(),
628        })
629        .unwrap();
630        let ks = store.keyspace(vta_keyspaces::BACKUP_BUNDLES).unwrap();
631        (dir, ks)
632    }
633
634    fn config_with_public_url(url: &str) -> Arc<RwLock<AppConfig>> {
635        let mut config: AppConfig = toml::from_str(&format!(
636            r#"
637            vta_did = "did:key:zTestVTA"
638            public_url = "{url}"
639            [store]
640            data_dir = "/tmp/does-not-matter-for-this-test"
641            [auth]
642            "#
643        ))
644        .expect("parse config");
645        // `from_str` doesn't populate config_path; ops that read it
646        // would need this set, but the descriptor builder only
647        // reads `public_url`.
648        config.config_path = std::path::PathBuf::from("/tmp/does-not-matter");
649        Arc::new(RwLock::new(config))
650    }
651
652    fn seed_export_ready(bundle_id: Uuid, owner: &str, token_hash: [u8; 32]) -> BundleRecord {
653        let now = Utc::now();
654        BundleRecord {
655            bundle_id,
656            kind: BundleKind::Export,
657            state: BundleState::ExportReady,
658            created_at: now,
659            expires_at: now + Duration::minutes(5),
660            created_by: owner.into(),
661            algorithm: "stream".into(),
662            expected_sha256: "deadbeef".into(),
663            expected_size_bytes: 1024,
664            token_hash,
665            blob_path: None,
666        }
667    }
668
669    #[test]
670    fn validate_algorithm_accepts_stream_only() {
671        assert!(validate_algorithm("stream").is_ok());
672        let err = validate_algorithm("s3-presigned").unwrap_err();
673        assert!(
674            matches!(err, AppError::Validation(_)),
675            "unknown algorithm must surface as Validation: {err:?}"
676        );
677        // Empty also rejected.
678        assert!(validate_algorithm("").is_err());
679        // Case-sensitive.
680        assert!(validate_algorithm("Stream").is_err());
681    }
682
683    #[test]
684    fn parse_bundle_id_rejects_malformed() {
685        assert!(parse_bundle_id("00000000-0000-0000-0000-000000000000").is_ok());
686        assert!(parse_bundle_id("not-a-uuid").is_err());
687        assert!(parse_bundle_id("").is_err());
688    }
689
690    #[test]
691    fn build_blob_url_strips_trailing_slash() {
692        let id = Uuid::nil();
693        // With trailing slash.
694        let url = build_blob_url("https://vta.example/", &id);
695        assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
696        // Without.
697        let url = build_blob_url("https://vta.example", &id);
698        assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
699    }
700
701    #[tokio::test]
702    async fn require_owned_returns_record_for_owner() {
703        let (_dir, ks) = open_bundles_ks().await;
704        let id = Uuid::new_v4();
705        let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
706        backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
707        let restored = require_owned(&ks, &id, "did:example:alice").await.unwrap();
708        assert_eq!(restored.bundle_id, id);
709    }
710
711    #[tokio::test]
712    async fn require_owned_treats_cross_did_as_not_found() {
713        // Critical security invariant: super-admin Bob can't see
714        // super-admin Alice's bundle. The response is `NotFound`,
715        // not `Forbidden`, to avoid leaking the bundle's existence.
716        let (_dir, ks) = open_bundles_ks().await;
717        let id = Uuid::new_v4();
718        let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
719        backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
720        let err = require_owned(&ks, &id, "did:example:bob")
721            .await
722            .unwrap_err();
723        assert!(
724            matches!(err, AppError::NotFound(_)),
725            "cross-DID lookup must report NotFound (don't leak existence): {err:?}"
726        );
727    }
728
729    #[tokio::test]
730    async fn require_owned_404_for_unknown_bundle() {
731        let (_dir, ks) = open_bundles_ks().await;
732        let err = require_owned(&ks, &Uuid::new_v4(), "did:example:alice")
733            .await
734            .unwrap_err();
735        assert!(matches!(err, AppError::NotFound(_)));
736    }
737
738    #[tokio::test]
739    async fn enforce_kind_rejects_wrong_kind_as_not_found() {
740        let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
741        let err = enforce_kind(&r, BundleKind::Import).unwrap_err();
742        assert!(
743            matches!(err, AppError::NotFound(_)),
744            "wrong-kind must report NotFound (don't leak the kind): {err:?}"
745        );
746        assert!(enforce_kind(&r, BundleKind::Export).is_ok());
747    }
748
749    #[tokio::test]
750    async fn enforce_open_bundle_cap_allows_under_limit() {
751        let (_dir, ks) = open_bundles_ks().await;
752        // Two open bundles; cap is 3 → ok.
753        for _ in 0..2 {
754            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
755            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
756        }
757        assert!(
758            enforce_open_bundle_cap(&ks, "did:example:alice")
759                .await
760                .is_ok()
761        );
762    }
763
764    #[tokio::test]
765    async fn enforce_open_bundle_cap_rejects_at_limit() {
766        let (_dir, ks) = open_bundles_ks().await;
767        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
768            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
769            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
770        }
771        let err = enforce_open_bundle_cap(&ks, "did:example:alice")
772            .await
773            .unwrap_err();
774        assert!(matches!(err, AppError::Conflict(_)));
775    }
776
777    #[tokio::test]
778    async fn enforce_open_bundle_cap_ignores_terminal_states() {
779        // Three Aborted bundles — terminal — must not count.
780        let (_dir, ks) = open_bundles_ks().await;
781        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
782            let mut r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
783            r.state = BundleState::Aborted;
784            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
785        }
786        assert!(
787            enforce_open_bundle_cap(&ks, "did:example:alice")
788                .await
789                .is_ok()
790        );
791    }
792
793    #[tokio::test]
794    async fn enforce_open_bundle_cap_scopes_to_did() {
795        // Alice has the cap full; Bob is fresh — still allowed.
796        let (_dir, ks) = open_bundles_ks().await;
797        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
798            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
799            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
800        }
801        assert!(
802            enforce_open_bundle_cap(&ks, "did:example:bob")
803                .await
804                .is_ok()
805        );
806    }
807
808    /// Smoke test: initiate_import + abort, exercising the
809    /// lightweight path that doesn't need the full keyspaces.
810    #[tokio::test]
811    async fn initiate_import_then_abort_round_trip() {
812        let (dir, bundles_ks) = open_bundles_ks().await;
813        let config = config_with_public_url("https://vta.example");
814        let blob_dir = dir.path().join("backups");
815
816        // The full DescriptorDeps requires all the other keyspaces.
817        // For tests targeting just initiate_import + abort, we
818        // construct it manually because Keyspaces holds &-refs we
819        // can't easily fabricate without an AppState. Instead, exercise
820        // the underlying call surface — `initiate_import` doesn't
821        // actually touch the other keyspaces, so re-implement its
822        // public flow here using only public helpers:
823        let _ = config; // referenced for future test additions
824
825        let auth = super_admin("did:example:alice");
826        validate_algorithm("stream").unwrap();
827        enforce_open_bundle_cap(&bundles_ks, &auth.did)
828            .await
829            .unwrap();
830        let (token, token_hash) = mint_token().unwrap();
831        let id = Uuid::new_v4();
832        let now = Utc::now();
833        let record = BundleRecord {
834            bundle_id: id,
835            kind: BundleKind::Import,
836            state: BundleState::ImportPending,
837            created_at: now,
838            expires_at: now + Duration::minutes(5),
839            created_by: auth.did.clone(),
840            algorithm: "stream".into(),
841            expected_sha256: "a".repeat(64),
842            expected_size_bytes: 100,
843            token_hash,
844            blob_path: None,
845        };
846        backup_bundle_store::store_bundle(&bundles_ks, &record)
847            .await
848            .unwrap();
849        // Token plaintext is what would go into the descriptor.
850        assert!(!token.as_str().is_empty());
851
852        // Now run the public `abort_bundle` against this seeded record.
853        // We need a DescriptorDeps; the only fields it reads are
854        // `bundles_ks` and `blob_dir` (for cleanup). Pass dummies
855        // for the rest via a focused alternative path: call
856        // `require_owned` directly + transition state.
857        let mut r = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
858        assert_eq!(r.state, BundleState::ImportPending);
859        r.state = BundleState::Aborted;
860        backup_bundle_store::store_bundle(&bundles_ks, &r)
861            .await
862            .unwrap();
863
864        // Aborted is terminal — subsequent abort is idempotent.
865        let r2 = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
866        assert!(r2.state.is_terminal());
867        let _ = blob_dir;
868    }
869
870    #[test]
871    fn context_admin_is_not_super_admin() {
872        // Pin the invariant the op layer relies on: a context-admin
873        // (Role::Admin with non-empty allowed_contexts) must NOT
874        // satisfy `require_super_admin`. If the role model ever
875        // changes, the descriptor ops' auth gate silently weakens
876        // — this catches that.
877        let auth = context_admin("did:example:ctx-admin");
878        assert!(
879            auth.require_super_admin().is_err(),
880            "context-admin must NOT pass require_super_admin"
881        );
882    }
883}