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    // Before any state is serialized: a descriptor with no fetchable URL is
106    // useless, and discovering that after staging leaves an orphan bundle.
107    if blob_transport_base_url(deps.config).await.is_none() {
108        return Err(transport_unavailable_internal());
109    }
110    validate_algorithm(&body.algorithm)?;
111    enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
112
113    // 1. Run the existing encrypt path to get the envelope.
114    let envelope = {
115        let config_guard = deps.config.read().await;
116        super::export_backup(
117            &deps.keyspaces,
118            deps.seed_store.as_ref(),
119            &config_guard,
120            auth,
121            &body.password,
122            body.include_audit,
123        )
124        .await?
125    };
126
127    // 2. Serialize the envelope as JSON bytes. The blob endpoint
128    //    streams these verbatim; the operator's CLI inflates back
129    //    to BackupEnvelope at import time. SHA-256 over the JSON
130    //    bytes is the wire integrity check.
131    let bytes = serde_json::to_vec(&envelope)
132        .map_err(|e| AppError::Internal(format!("serialize backup envelope: {e}")))?;
133    let sha256_hex = sha256_hex(&bytes);
134    let size = bytes.len() as u64;
135
136    // 3. Mint bundle + token; pre-stage on disk before storing the
137    //    record so a crash leaves no record pointing at missing
138    //    bytes.
139    let bundle_id = Uuid::new_v4();
140    let (token, token_hash) = mint_token()?;
141
142    tokio::fs::create_dir_all(deps.blob_dir)
143        .await
144        .map_err(AppError::Io)?;
145    #[cfg(unix)]
146    set_dir_mode_700(deps.blob_dir).await?;
147    let blob_path = deps.blob_dir.join(format!("{bundle_id}.vtabak"));
148    tokio::fs::write(&blob_path, &bytes)
149        .await
150        .map_err(AppError::Io)?;
151    #[cfg(unix)]
152    set_file_mode_600(&blob_path).await?;
153
154    let now = Utc::now();
155    let record = BundleRecord {
156        bundle_id,
157        kind: BundleKind::Export,
158        state: BundleState::ExportReady,
159        created_at: now,
160        expires_at: now + bundle_ttl(),
161        created_by: auth.did.clone(),
162        algorithm: body.algorithm,
163        expected_sha256: sha256_hex.clone(),
164        expected_size_bytes: size,
165        token_hash,
166        blob_path: Some(blob_path),
167    };
168    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
169
170    info!(bundle_id = %bundle_id, size, "initiate-export: bundle ready");
171
172    Ok(InitiateExportResultBody {
173        descriptor: build_descriptor(&record, token, deps.config).await?,
174        completion_hint: format!(
175            "Download with: pnm backup save --bundle-id {bundle_id} --output backup.vtabak"
176        ),
177    })
178}
179
180// ─── complete-export ─────────────────────────────────────────────────
181
182/// Optional ack from the client after a successful download. Idempotent
183/// on terminal states (returns `downloaded` reflecting whether the
184/// transfer actually happened).
185///
186/// Caller path: `spec/vta/backup/complete-export/1.0` trust-task handler.
187pub async fn complete_export(
188    deps: &DescriptorDeps<'_>,
189    auth: &AuthClaims,
190    body: CompleteExportBody,
191) -> Result<CompleteExportResultBody, AppError> {
192    auth.require_super_admin()?;
193    let bundle_id = parse_bundle_id(&body.bundle_id)?;
194
195    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
196    enforce_kind(&record, BundleKind::Export)?;
197
198    // A chunked bundle is never one-shot: it stays `ExportReady` while its chunks
199    // are served, and `complete-export` is what releases it. `downloaded` then
200    // means every index was served at least once (`initiate-export/1.1` §
201    // Expiry and completion), and the staged bytes go now rather than at expiry.
202    if record.algorithm == super::chunked_algorithm() && record.state == BundleState::ExportReady {
203        let downloaded = super::chunked::all_served(deps.bundles_ks, &bundle_id)
204            .await?
205            .unwrap_or(false);
206        if let Some(path) = record.blob_path.take()
207            && let Err(e) = tokio::fs::remove_file(&path).await
208            && e.kind() != std::io::ErrorKind::NotFound
209        {
210            warn!(
211                bundle_id = %bundle_id,
212                path = %path.display(),
213                error = %e,
214                "complete-export: failed to delete chunked blob; sweeper will retry"
215            );
216            record.blob_path = Some(path);
217        }
218        record.state = BundleState::ExportAcked;
219        backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
220        super::chunked::delete_plan(deps.bundles_ks, &bundle_id).await?;
221        info!(bundle_id = %bundle_id, downloaded, "complete-export (chunked): released");
222        return Ok(CompleteExportResultBody {
223            bundle_id: bundle_id.to_string(),
224            downloaded,
225        });
226    }
227
228    let downloaded = match record.state {
229        BundleState::ExportDownloaded => {
230            record.state = BundleState::ExportAcked;
231            backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
232            true
233        }
234        BundleState::ExportAcked => true,  // already acked
235        BundleState::ExportReady => false, // download never happened
236        BundleState::Aborted | BundleState::Expired => {
237            return Err(AppError::Conflict(format!(
238                "bundle {bundle_id} is in terminal state {:?}; cannot ack",
239                record.state
240            )));
241        }
242        // Import states would have failed the kind check above; this
243        // arm is unreachable in practice, but keep it exhaustive.
244        _ => {
245            return Err(AppError::Internal(format!(
246                "unexpected state for export bundle {bundle_id}: {:?}",
247                record.state
248            )));
249        }
250    };
251
252    info!(bundle_id = %bundle_id, downloaded, "complete-export: acked");
253    Ok(CompleteExportResultBody {
254        bundle_id: bundle_id.to_string(),
255        downloaded,
256    })
257}
258
259// ─── initiate-import ─────────────────────────────────────────────────
260
261/// Mint an upload slot for an import bundle. Returns the descriptor
262/// the client uses to POST bytes to the blob endpoint. Bytes aren't
263/// validated until the subsequent `finalize-import` (since the upload
264/// happens out-of-band).
265pub async fn initiate_import(
266    deps: &DescriptorDeps<'_>,
267    auth: &AuthClaims,
268    body: InitiateImportBody,
269) -> Result<InitiateImportResultBody, AppError> {
270    auth.require_super_admin()?;
271    // Before a slot is recorded — see `initiate_export`.
272    if blob_transport_base_url(deps.config).await.is_none() {
273        return Err(transport_unavailable_internal());
274    }
275    validate_algorithm(&body.algorithm)?;
276    enforce_open_bundle_cap(deps.bundles_ks, &auth.did).await?;
277
278    // Sanity-check the pre-committed hash and size — empty string
279    // or zero-length blobs almost certainly indicate a CLI bug.
280    if body.expected_sha256.len() != 64
281        || !body.expected_sha256.chars().all(|c| c.is_ascii_hexdigit())
282    {
283        return Err(AppError::Validation(format!(
284            "expected_sha256 must be 64 lowercase hex chars; got `{}`",
285            body.expected_sha256
286        )));
287    }
288    if body.expected_size_bytes == 0 {
289        return Err(AppError::Validation(
290            "expected_size_bytes must be > 0".into(),
291        ));
292    }
293
294    let bundle_id = Uuid::new_v4();
295    let (token, token_hash) = mint_token()?;
296    let now = Utc::now();
297    let record = BundleRecord {
298        bundle_id,
299        kind: BundleKind::Import,
300        state: BundleState::ImportPending,
301        created_at: now,
302        expires_at: now + bundle_ttl(),
303        created_by: auth.did.clone(),
304        algorithm: body.algorithm,
305        expected_sha256: body.expected_sha256,
306        expected_size_bytes: body.expected_size_bytes,
307        token_hash,
308        // Populated by the blob POST handler.
309        blob_path: None,
310    };
311    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
312
313    info!(bundle_id = %bundle_id, "initiate-import: slot ready");
314    Ok(InitiateImportResultBody {
315        descriptor: build_descriptor(&record, token, deps.config).await?,
316        completion_hint: format!(
317            "Upload with: pnm backup restore --bundle-id {bundle_id} --input <path> --password <pw>"
318        ),
319    })
320}
321
322// ─── finalize-import ─────────────────────────────────────────────────
323
324/// Apply (or preview) the uploaded bytes for an import bundle. The
325/// state machine allows multiple preview calls but exactly one
326/// commit (the second commit attempt finds the bundle in
327/// `ImportCommitted`, which is terminal).
328pub async fn finalize_import(
329    deps: &DescriptorDeps<'_>,
330    auth: &AuthClaims,
331    body: FinalizeImportBody,
332) -> Result<FinalizeImportResultBody, AppError> {
333    auth.require_super_admin()?;
334    let bundle_id = parse_bundle_id(&body.bundle_id)?;
335
336    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
337    enforce_kind(&record, BundleKind::Import)?;
338
339    // State must be ImportReceived (upload done) OR ImportPreviewed
340    // (re-running preview after first preview). Anything else is
341    // an error — the client must POST to /backup/blob/{id} first.
342    match record.state {
343        BundleState::ImportReceived | BundleState::ImportPreviewed => {}
344        BundleState::ImportPending => {
345            return Err(AppError::Conflict(format!(
346                "bundle {bundle_id} has no uploaded bytes yet; \
347                 POST to /backup/blob/{bundle_id} first"
348            )));
349        }
350        BundleState::ImportCommitted => {
351            return Err(AppError::Conflict(format!(
352                "bundle {bundle_id} already committed"
353            )));
354        }
355        BundleState::Aborted | BundleState::Expired => {
356            return Err(AppError::Conflict(format!(
357                "bundle {bundle_id} in terminal state {:?}",
358                record.state
359            )));
360        }
361        _ => {
362            return Err(AppError::Internal(format!(
363                "unexpected state for import bundle {bundle_id}: {:?}",
364                record.state
365            )));
366        }
367    }
368
369    let blob_path = record.blob_path.clone().ok_or_else(|| {
370        AppError::Internal(format!("bundle {bundle_id} has no blob_path on disk"))
371    })?;
372    let bytes = tokio::fs::read(&blob_path).await.map_err(AppError::Io)?;
373
374    let envelope: BackupEnvelope = serde_json::from_slice(&bytes).map_err(|e| {
375        AppError::Validation(format!("uploaded bytes are not a BackupEnvelope: {e}"))
376    })?;
377
378    if body.confirm {
379        // Commit path — call existing apply_import.
380        let result = super::apply_import(
381            &super::preview_import(&envelope, &body.password).await?.0,
382            &deps.keyspaces,
383            deps.seed_store,
384            deps.config,
385            deps.store,
386            #[cfg(feature = "tee")]
387            deps.re_encryptor,
388        )
389        .await?;
390
391        // Best-effort delete; sweeper retries.
392        if let Err(e) = tokio::fs::remove_file(&blob_path).await {
393            warn!(
394                bundle_id = %bundle_id,
395                path = %blob_path.display(),
396                error = %e,
397                "finalize-import: failed to delete blob after commit"
398            );
399        }
400        record.state = BundleState::ImportCommitted;
401        record.blob_path = None;
402        backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
403
404        info!(bundle_id = %bundle_id, "finalize-import: committed");
405        Ok(FinalizeImportResultBody {
406            bundle_id: bundle_id.to_string(),
407            status: "committed".into(),
408            source_did: result.source_did,
409            key_count: result.key_count,
410            acl_count: result.acl_count,
411            context_count: result.context_count,
412            audit_count: result.audit_count,
413            imported_secret_count: result.imported_secret_count,
414            message: result.message,
415        })
416    } else {
417        // Preview path — decrypt + validate but don't mutate state.
418        let (_payload, result) = super::preview_import(&envelope, &body.password).await?;
419        record.state = BundleState::ImportPreviewed;
420        backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
421
422        info!(bundle_id = %bundle_id, "finalize-import: preview");
423        Ok(FinalizeImportResultBody {
424            bundle_id: bundle_id.to_string(),
425            status: "preview".into(),
426            source_did: result.source_did,
427            key_count: result.key_count,
428            acl_count: result.acl_count,
429            context_count: result.context_count,
430            audit_count: result.audit_count,
431            imported_secret_count: result.imported_secret_count,
432            message: result.message,
433        })
434    }
435}
436
437// ─── abort ─────────────────────────────────────────────────────────────
438
439/// Cancel an in-flight bundle in any non-terminal state. Idempotent
440/// on terminal — returns `aborted: false` instead of erroring so
441/// re-tries from the operator are safe.
442pub async fn abort_bundle(
443    deps: &DescriptorDeps<'_>,
444    auth: &AuthClaims,
445    body: AbortBundleBody,
446) -> Result<AbortBundleResultBody, AppError> {
447    auth.require_super_admin()?;
448    let bundle_id = parse_bundle_id(&body.bundle_id)?;
449
450    let mut record = require_owned(deps.bundles_ks, &bundle_id, &auth.did).await?;
451
452    if record.state.is_terminal() {
453        info!(bundle_id = %bundle_id, state = ?record.state, "abort: bundle already terminal");
454        return Ok(AbortBundleResultBody {
455            bundle_id: bundle_id.to_string(),
456            aborted: false,
457        });
458    }
459
460    // Best-effort delete of any staged bytes (export-side: bytes
461    // are on disk; import-side: only if upload already happened).
462    if let Some(path) = record.blob_path.clone()
463        && let Err(e) = tokio::fs::remove_file(&path).await
464    {
465        // NotFound is fine — already gone. Anything else: log
466        // but proceed; the sweeper will retry.
467        if e.kind() != std::io::ErrorKind::NotFound {
468            warn!(
469                bundle_id = %bundle_id,
470                path = %path.display(),
471                error = %e,
472                "abort: failed to delete staged bytes; sweeper will retry"
473            );
474        }
475    }
476
477    record.state = BundleState::Aborted;
478    record.blob_path = None;
479    backup_bundle_store::store_bundle(deps.bundles_ks, &record).await?;
480    // A chunked bundle's manifest and progress end with it. Absent for stream.
481    super::chunked::delete_plan(deps.bundles_ks, &bundle_id).await?;
482
483    info!(bundle_id = %bundle_id, "abort: bundle cancelled");
484    Ok(AbortBundleResultBody {
485        bundle_id: bundle_id.to_string(),
486        aborted: true,
487    })
488}
489
490// ─── Internal helpers ────────────────────────────────────────────────
491
492pub(crate) fn bundle_ttl() -> Duration {
493    Duration::seconds(DEFAULT_BUNDLE_TTL_SECS as i64)
494}
495
496fn validate_algorithm(algorithm: &str) -> Result<(), AppError> {
497    if algorithm != "stream" {
498        return Err(AppError::Validation(format!(
499            "unsupported transport algorithm: `{algorithm}`; this VTA supports: stream"
500        )));
501    }
502    Ok(())
503}
504
505pub(crate) async fn enforce_open_bundle_cap(
506    ks: &KeyspaceHandle,
507    did: &str,
508) -> Result<(), AppError> {
509    let all = backup_bundle_store::list_bundles(ks).await?;
510    let open = all
511        .iter()
512        .filter(|r| r.created_by == did && !r.state.is_terminal())
513        .count();
514    if open >= MAX_OPEN_BUNDLES_PER_DID {
515        return Err(AppError::Conflict(format!(
516            "operator `{did}` has {open} open backup bundles; \
517             abort or wait for expiry before initiating another \
518             (cap: {MAX_OPEN_BUNDLES_PER_DID})"
519        )));
520    }
521    Ok(())
522}
523
524pub(crate) fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
525    Uuid::parse_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
526}
527
528/// Look up a bundle and verify the caller owns it. Returns `NotFound`
529/// for both "no such record" and "exists but wrong DID" so the API
530/// doesn't leak the existence of a peer super-admin's bundle.
531pub(crate) async fn require_owned(
532    ks: &KeyspaceHandle,
533    id: &Uuid,
534    caller_did: &str,
535) -> Result<BundleRecord, AppError> {
536    let record = backup_bundle_store::get_bundle(ks, id)
537        .await?
538        .ok_or_else(|| AppError::NotFound(format!("bundle not found: {id}")))?;
539    if record.created_by != caller_did {
540        // Don't leak the bundle's existence.
541        warn!(
542            bundle_id = %id,
543            caller = %caller_did,
544            owner = %record.created_by,
545            "bundle owned by a different super-admin; treating as not-found"
546        );
547        return Err(AppError::NotFound(format!("bundle not found: {id}")));
548    }
549    Ok(record)
550}
551
552pub(crate) fn enforce_kind(record: &BundleRecord, expected: BundleKind) -> Result<(), AppError> {
553    if record.kind != expected {
554        // Treat as not-found — don't leak the existence of a bundle
555        // of the other kind with the same id.
556        return Err(AppError::NotFound(format!(
557            "bundle not found: {}",
558            record.bundle_id
559        )));
560    }
561    Ok(())
562}
563
564async fn build_descriptor(
565    record: &BundleRecord,
566    token: BundleToken,
567    config: &tokio::sync::RwLock<AppConfig>,
568) -> Result<BundleDescriptor, AppError> {
569    let public_url = blob_transport_base_url(config)
570        .await
571        .ok_or_else(transport_unavailable_internal)?;
572    let transport_url = build_blob_url(&public_url, &record.bundle_id);
573    Ok(BundleDescriptor {
574        bundle_id: record.bundle_id.to_string(),
575        algorithm: record.algorithm.clone(),
576        transport_url,
577        transport_token: token.as_str().to_string(),
578        expected_sha256: record.expected_sha256.clone(),
579        expected_size_bytes: record.expected_size_bytes,
580        expires_at: record.expires_at,
581    })
582}
583
584/// What a `transportUnavailable` rejection says on the wire.
585///
586/// Deliberately names no configuration key: framework 0.5.0 forbids a
587/// `message` from revealing consumer-internal state. The operator-facing
588/// cause (`public_url` unset) goes to the log.
589pub const TRANSPORT_UNAVAILABLE_MESSAGE: &str = "this agent publishes no HTTPS address at which backup bytes can be \
590     transferred, so it cannot produce a `stream` descriptor; the fix is on the \
591     agent's configuration, not in the request";
592
593/// The base URL the `stream` algorithm's blob endpoint is published under, or
594/// `None` when the VTA has no public HTTPS address (`public_url` unset or
595/// blank) — the DIDComm/TSP-only deployment.
596///
597/// The `initiate-*` Trust Task handlers call this **before** the op so they
598/// can refuse with the specification's
599/// `vta/backup/initiate-{export,import}:transportUnavailable` code; the ops
600/// check it again up front, before staging anything, so a caller that skips
601/// the handler cannot leave an orphaned bundle behind an unfetchable
602/// descriptor.
603pub async fn blob_transport_base_url(config: &tokio::sync::RwLock<AppConfig>) -> Option<String> {
604    config
605        .read()
606        .await
607        .public_url
608        .clone()
609        .filter(|u| !u.trim().is_empty())
610}
611
612fn transport_unavailable_internal() -> AppError {
613    AppError::Internal(
614        "VTA `public_url` is not configured; cannot build backup bundle URL. \
615         Set `public_url` in config (or VTA_PUBLIC_URL env var) and restart."
616            .into(),
617    )
618}
619
620fn build_blob_url(public_url: &str, bundle_id: &Uuid) -> String {
621    let base = public_url.trim_end_matches('/');
622    format!("{base}/backup/blob/{bundle_id}")
623}
624
625pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
626    use sha2::{Digest, Sha256};
627    let mut hasher = Sha256::new();
628    hasher.update(bytes);
629    let out = hasher.finalize();
630    let mut s = String::with_capacity(out.len() * 2);
631    for b in out {
632        s.push_str(&format!("{b:02x}"));
633    }
634    s
635}
636
637#[cfg(unix)]
638pub(crate) async fn set_dir_mode_700(path: &Path) -> Result<(), AppError> {
639    use std::os::unix::fs::PermissionsExt;
640    let perms = std::fs::Permissions::from_mode(0o700);
641    tokio::fs::set_permissions(path, perms)
642        .await
643        .map_err(AppError::Io)
644}
645
646#[cfg(unix)]
647pub(crate) async fn set_file_mode_600(path: &Path) -> Result<(), AppError> {
648    use std::os::unix::fs::PermissionsExt;
649    let perms = std::fs::Permissions::from_mode(0o600);
650    tokio::fs::set_permissions(path, perms)
651        .await
652        .map_err(AppError::Io)
653}
654
655#[cfg(test)]
656mod tests {
657    //! Focused unit tests for the descriptor ops. The full
658    //! `initiate_export → blob GET → complete_export` lifecycle
659    //! lands once the trust-task slice + integration harness are
660    //! in (Stage 5/6); these tests cover the surface that doesn't
661    //! depend on the full `export_backup` keyspace plumbing —
662    //! mostly state-machine transitions, auth gates, owner
663    //! checks, and validation helpers.
664    //!
665    //! Lifecycle tests against the real router will land in the
666    //! Phase-6 integration suite.
667
668    use super::*;
669    use crate::backup_bundle_store::{BundleKind, BundleRecord, BundleState};
670    use chrono::Duration;
671    use std::sync::Arc;
672    use tokio::sync::RwLock;
673    use vti_common::acl::Role;
674    use vti_common::config::StoreConfig as VtiStoreConfig;
675
676    fn super_admin(did: &str) -> AuthClaims {
677        AuthClaims {
678            did: did.into(),
679            role: Role::Admin,
680            allowed_contexts: Vec::new(),
681            session_id: "test-session".into(),
682            access_expires_at: 0,
683            issued_at: 0,
684            amr: Vec::new(),
685            acr: String::new(),
686        }
687    }
688
689    fn context_admin(did: &str) -> AuthClaims {
690        AuthClaims {
691            did: did.into(),
692            role: Role::Admin,
693            allowed_contexts: vec!["ctx1".into()],
694            session_id: "test-session".into(),
695            access_expires_at: 0,
696            issued_at: 0,
697            amr: Vec::new(),
698            acr: String::new(),
699        }
700    }
701
702    async fn open_bundles_ks() -> (tempfile::TempDir, KeyspaceHandle) {
703        let dir = tempfile::tempdir().unwrap();
704        let store = Store::open(&VtiStoreConfig {
705            data_dir: dir.path().into(),
706        })
707        .unwrap();
708        let ks = store.keyspace(vta_keyspaces::BACKUP_BUNDLES).unwrap();
709        (dir, ks)
710    }
711
712    fn config_with_public_url(url: &str) -> Arc<RwLock<AppConfig>> {
713        let mut config: AppConfig = toml::from_str(&format!(
714            r#"
715            vta_did = "did:key:zTestVTA"
716            public_url = "{url}"
717            [store]
718            data_dir = "/tmp/does-not-matter-for-this-test"
719            [auth]
720            "#
721        ))
722        .expect("parse config");
723        // `from_str` doesn't populate config_path; ops that read it
724        // would need this set, but the descriptor builder only
725        // reads `public_url`.
726        config.config_path = std::path::PathBuf::from("/tmp/does-not-matter");
727        Arc::new(RwLock::new(config))
728    }
729
730    fn seed_export_ready(bundle_id: Uuid, owner: &str, token_hash: [u8; 32]) -> BundleRecord {
731        let now = Utc::now();
732        BundleRecord {
733            bundle_id,
734            kind: BundleKind::Export,
735            state: BundleState::ExportReady,
736            created_at: now,
737            expires_at: now + Duration::minutes(5),
738            created_by: owner.into(),
739            algorithm: "stream".into(),
740            expected_sha256: "deadbeef".into(),
741            expected_size_bytes: 1024,
742            token_hash,
743            blob_path: None,
744        }
745    }
746
747    #[test]
748    fn validate_algorithm_accepts_stream_only() {
749        assert!(validate_algorithm("stream").is_ok());
750        let err = validate_algorithm("s3-presigned").unwrap_err();
751        assert!(
752            matches!(err, AppError::Validation(_)),
753            "unknown algorithm must surface as Validation: {err:?}"
754        );
755        // Empty also rejected.
756        assert!(validate_algorithm("").is_err());
757        // Case-sensitive.
758        assert!(validate_algorithm("Stream").is_err());
759    }
760
761    #[test]
762    fn parse_bundle_id_rejects_malformed() {
763        assert!(parse_bundle_id("00000000-0000-0000-0000-000000000000").is_ok());
764        assert!(parse_bundle_id("not-a-uuid").is_err());
765        assert!(parse_bundle_id("").is_err());
766    }
767
768    #[test]
769    fn build_blob_url_strips_trailing_slash() {
770        let id = Uuid::nil();
771        // With trailing slash.
772        let url = build_blob_url("https://vta.example/", &id);
773        assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
774        // Without.
775        let url = build_blob_url("https://vta.example", &id);
776        assert_eq!(url, format!("https://vta.example/backup/blob/{id}"));
777    }
778
779    #[tokio::test]
780    async fn require_owned_returns_record_for_owner() {
781        let (_dir, ks) = open_bundles_ks().await;
782        let id = Uuid::new_v4();
783        let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
784        backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
785        let restored = require_owned(&ks, &id, "did:example:alice").await.unwrap();
786        assert_eq!(restored.bundle_id, id);
787    }
788
789    #[tokio::test]
790    async fn require_owned_treats_cross_did_as_not_found() {
791        // Critical security invariant: super-admin Bob can't see
792        // super-admin Alice's bundle. The response is `NotFound`,
793        // not `Forbidden`, to avoid leaking the bundle's existence.
794        let (_dir, ks) = open_bundles_ks().await;
795        let id = Uuid::new_v4();
796        let r = seed_export_ready(id, "did:example:alice", [0u8; 32]);
797        backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
798        let err = require_owned(&ks, &id, "did:example:bob")
799            .await
800            .unwrap_err();
801        assert!(
802            matches!(err, AppError::NotFound(_)),
803            "cross-DID lookup must report NotFound (don't leak existence): {err:?}"
804        );
805    }
806
807    #[tokio::test]
808    async fn require_owned_404_for_unknown_bundle() {
809        let (_dir, ks) = open_bundles_ks().await;
810        let err = require_owned(&ks, &Uuid::new_v4(), "did:example:alice")
811            .await
812            .unwrap_err();
813        assert!(matches!(err, AppError::NotFound(_)));
814    }
815
816    #[tokio::test]
817    async fn enforce_kind_rejects_wrong_kind_as_not_found() {
818        let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
819        let err = enforce_kind(&r, BundleKind::Import).unwrap_err();
820        assert!(
821            matches!(err, AppError::NotFound(_)),
822            "wrong-kind must report NotFound (don't leak the kind): {err:?}"
823        );
824        assert!(enforce_kind(&r, BundleKind::Export).is_ok());
825    }
826
827    #[tokio::test]
828    async fn enforce_open_bundle_cap_allows_under_limit() {
829        let (_dir, ks) = open_bundles_ks().await;
830        // Two open bundles; cap is 3 → ok.
831        for _ in 0..2 {
832            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
833            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
834        }
835        assert!(
836            enforce_open_bundle_cap(&ks, "did:example:alice")
837                .await
838                .is_ok()
839        );
840    }
841
842    #[tokio::test]
843    async fn enforce_open_bundle_cap_rejects_at_limit() {
844        let (_dir, ks) = open_bundles_ks().await;
845        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
846            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
847            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
848        }
849        let err = enforce_open_bundle_cap(&ks, "did:example:alice")
850            .await
851            .unwrap_err();
852        assert!(matches!(err, AppError::Conflict(_)));
853    }
854
855    #[tokio::test]
856    async fn enforce_open_bundle_cap_ignores_terminal_states() {
857        // Three Aborted bundles — terminal — must not count.
858        let (_dir, ks) = open_bundles_ks().await;
859        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
860            let mut r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
861            r.state = BundleState::Aborted;
862            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
863        }
864        assert!(
865            enforce_open_bundle_cap(&ks, "did:example:alice")
866                .await
867                .is_ok()
868        );
869    }
870
871    #[tokio::test]
872    async fn enforce_open_bundle_cap_scopes_to_did() {
873        // Alice has the cap full; Bob is fresh — still allowed.
874        let (_dir, ks) = open_bundles_ks().await;
875        for _ in 0..MAX_OPEN_BUNDLES_PER_DID {
876            let r = seed_export_ready(Uuid::new_v4(), "did:example:alice", [0u8; 32]);
877            backup_bundle_store::store_bundle(&ks, &r).await.unwrap();
878        }
879        assert!(
880            enforce_open_bundle_cap(&ks, "did:example:bob")
881                .await
882                .is_ok()
883        );
884    }
885
886    /// Smoke test: initiate_import + abort, exercising the
887    /// lightweight path that doesn't need the full keyspaces.
888    #[tokio::test]
889    async fn initiate_import_then_abort_round_trip() {
890        let (dir, bundles_ks) = open_bundles_ks().await;
891        let config = config_with_public_url("https://vta.example");
892        let blob_dir = dir.path().join("backups");
893
894        // The full DescriptorDeps requires all the other keyspaces.
895        // For tests targeting just initiate_import + abort, we
896        // construct it manually because Keyspaces holds &-refs we
897        // can't easily fabricate without an AppState. Instead, exercise
898        // the underlying call surface — `initiate_import` doesn't
899        // actually touch the other keyspaces, so re-implement its
900        // public flow here using only public helpers:
901        let _ = config; // referenced for future test additions
902
903        let auth = super_admin("did:example:alice");
904        validate_algorithm("stream").unwrap();
905        enforce_open_bundle_cap(&bundles_ks, &auth.did)
906            .await
907            .unwrap();
908        let (token, token_hash) = mint_token().unwrap();
909        let id = Uuid::new_v4();
910        let now = Utc::now();
911        let record = BundleRecord {
912            bundle_id: id,
913            kind: BundleKind::Import,
914            state: BundleState::ImportPending,
915            created_at: now,
916            expires_at: now + Duration::minutes(5),
917            created_by: auth.did.clone(),
918            algorithm: "stream".into(),
919            expected_sha256: "a".repeat(64),
920            expected_size_bytes: 100,
921            token_hash,
922            blob_path: None,
923        };
924        backup_bundle_store::store_bundle(&bundles_ks, &record)
925            .await
926            .unwrap();
927        // Token plaintext is what would go into the descriptor.
928        assert!(!token.as_str().is_empty());
929
930        // Now run the public `abort_bundle` against this seeded record.
931        // We need a DescriptorDeps; the only fields it reads are
932        // `bundles_ks` and `blob_dir` (for cleanup). Pass dummies
933        // for the rest via a focused alternative path: call
934        // `require_owned` directly + transition state.
935        let mut r = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
936        assert_eq!(r.state, BundleState::ImportPending);
937        r.state = BundleState::Aborted;
938        backup_bundle_store::store_bundle(&bundles_ks, &r)
939            .await
940            .unwrap();
941
942        // Aborted is terminal — subsequent abort is idempotent.
943        let r2 = require_owned(&bundles_ks, &id, &auth.did).await.unwrap();
944        assert!(r2.state.is_terminal());
945        let _ = blob_dir;
946    }
947
948    fn config_without_public_url() -> Arc<RwLock<AppConfig>> {
949        let config: AppConfig = toml::from_str(
950            r#"
951            vta_did = "did:key:zTestVTA"
952            [store]
953            data_dir = "/tmp/does-not-matter-for-this-test"
954            [auth]
955            "#,
956        )
957        .expect("parse config");
958        Arc::new(RwLock::new(config))
959    }
960
961    /// A DIDComm/TSP-only VTA has no `public_url`. The `initiate-*` handlers
962    /// key the spec's `transportUnavailable` refusal on this returning `None`
963    /// — including for a blank value, which is no more fetchable than an
964    /// absent one.
965    #[tokio::test]
966    async fn blob_transport_is_unavailable_without_a_public_url() {
967        assert_eq!(
968            blob_transport_base_url(&config_without_public_url()).await,
969            None
970        );
971        assert_eq!(
972            blob_transport_base_url(&config_with_public_url("  ")).await,
973            None,
974            "a blank public_url must read as unavailable"
975        );
976        assert_eq!(
977            blob_transport_base_url(&config_with_public_url("https://vta.example")).await,
978            Some("https://vta.example".to_string())
979        );
980    }
981
982    /// The wire message must not leak the configuration key (framework 0.5.0,
983    /// *What a `message` May Not Say*); the log carries that.
984    #[test]
985    fn transport_unavailable_message_names_no_config_key() {
986        assert!(!TRANSPORT_UNAVAILABLE_MESSAGE.contains("public_url"));
987    }
988
989    #[test]
990    fn context_admin_is_not_super_admin() {
991        // Pin the invariant the op layer relies on: a context-admin
992        // (Role::Admin with non-empty allowed_contexts) must NOT
993        // satisfy `require_super_admin`. If the role model ever
994        // changes, the descriptor ops' auth gate silently weakens
995        // — this catches that.
996        let auth = context_admin("did:example:ctx-admin");
997        assert!(
998            auth.require_super_admin().is_err(),
999            "context-admin must NOT pass require_super_admin"
1000        );
1001    }
1002}