Skip to main content

vta_backup/ops/
blob.rs

1//! Op-layer for the backup-blob byte transport (`GET/POST /backup/blob/{id}`).
2//!
3//! The token-gated state machine that moves the staged `.vtabak` bytes —
4//! GET streams an export bundle (one-shot, delete-before-flip), POST
5//! accepts an import upload (size + SHA-256 verified) — lives here. The
6//! HTTP route adapters in `routes/backup_blob.rs` handle only transport
7//! concerns (path/header parsing, body cap, response framing) and
8//! delegate the state machine to these two functions.
9//!
10//! See `docs/05-design-notes/backup-descriptor-pattern.md` for the full
11//! state machine, and §"Auth model" for why the bearer token — not a
12//! JWT — is the authenticator.
13
14use std::path::Path;
15
16use chrono::Utc;
17use tracing::{info, warn};
18use uuid::Uuid;
19
20use crate::backup_bundle_store::{self, BundleKind, BundleRecord, BundleState, verify_token};
21use vti_common::error::AppError;
22use vti_common::store::KeyspaceHandle;
23
24/// Download the encrypted bytes of an export bundle. One-shot: on
25/// success the record transitions to `ExportDownloaded` (terminal) and
26/// the bytes are removed from disk.
27///
28/// Returns the staged bytes; the route adapter frames them as an
29/// `application/octet-stream` attachment.
30///
31/// Failure modes (the route maps the `AppError` variants to HTTP status):
32/// - bundle_id not found → `NotFound`
33/// - token doesn't match the stored hash → `Forbidden`
34/// - bundle is an import bundle, or already-downloaded / aborted /
35///   expired → `NotFound` / `Conflict` (see [`enforce_export_ready`])
36/// - bundle expired by clock → `Conflict` (410-style, via [`gone`])
37/// - blob bytes missing on disk → `Conflict` (already swept)
38pub async fn read_export_blob(
39    bundles_ks: &KeyspaceHandle,
40    bundle_id: Uuid,
41    token: &str,
42) -> Result<Vec<u8>, AppError> {
43    let mut record = match backup_bundle_store::get_bundle(bundles_ks, &bundle_id).await? {
44        Some(r) => r,
45        None => {
46            warn!(bundle_id = %bundle_id, "GET blob: bundle not found");
47            return Err(AppError::NotFound(format!("bundle not found: {bundle_id}")));
48        }
49    };
50
51    enforce_token(&record, token)?;
52    enforce_export_ready(&record)?;
53    enforce_not_expired(&record)?;
54
55    let blob_path = record
56        .blob_path
57        .clone()
58        .ok_or_else(|| AppError::Internal(format!("bundle {bundle_id} has no blob path")))?;
59
60    let bytes = match tokio::fs::read(&blob_path).await {
61        Ok(b) => b,
62        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
63            warn!(
64                bundle_id = %bundle_id,
65                path = %blob_path.display(),
66                "GET blob: file missing on disk; bundle treated as expired"
67            );
68            return Err(gone(format!("bundle {bundle_id} expired (blob missing)")));
69        }
70        Err(e) => return Err(AppError::Io(e)),
71    };
72
73    // One-shot semantics: bytes are gone after this call. We delete
74    // the file BEFORE flipping the state so a crash mid-transition
75    // leaves the bytes deleted and the state recoverable on next
76    // boot's sweeper pass.
77    if let Err(e) = tokio::fs::remove_file(&blob_path).await {
78        // Best-effort: failure to delete is not fatal — the sweeper
79        // will clean up. But log loud since the file is now
80        // operator-visible after the response.
81        warn!(
82            bundle_id = %bundle_id,
83            path = %blob_path.display(),
84            error = %e,
85            "GET blob: failed to delete blob after read; sweeper will retry"
86        );
87    }
88    record.state = BundleState::ExportDownloaded;
89    record.blob_path = None;
90    backup_bundle_store::store_bundle(bundles_ks, &record).await?;
91
92    info!(bundle_id = %bundle_id, bytes = bytes.len(), "GET blob: served");
93
94    Ok(bytes)
95}
96
97/// Accept the encrypted upload for an import bundle. The body's
98/// SHA-256 must match the record's `expected_sha256`; the byte count
99/// must match `expected_size_bytes`. On success the state moves to
100/// `ImportReceived` (no further uploads accepted against this
101/// bundle_id).
102///
103/// Multi-shot until the first successful upload — a failed upload
104/// (mismatched hash or interrupted transfer) can be retried without
105/// re-running `initiate-import`.
106///
107/// Failure modes mirror [`read_export_blob`], plus:
108/// - body size doesn't match `expected_size_bytes` → `Validation`
109/// - body SHA-256 doesn't match `expected_sha256` → `Validation`
110/// - I/O error staging to disk → `Io`
111pub async fn write_import_blob(
112    bundles_ks: &KeyspaceHandle,
113    blob_dir: &Path,
114    bundle_id: Uuid,
115    token: &str,
116    bytes: &[u8],
117) -> Result<(), AppError> {
118    let mut record = match backup_bundle_store::get_bundle(bundles_ks, &bundle_id).await? {
119        Some(r) => r,
120        None => {
121            warn!(bundle_id = %bundle_id, "POST blob: bundle not found");
122            return Err(AppError::NotFound(format!("bundle not found: {bundle_id}")));
123        }
124    };
125
126    enforce_token(&record, token)?;
127    enforce_import_pending(&record)?;
128    enforce_not_expired(&record)?;
129
130    if bytes.len() as u64 != record.expected_size_bytes {
131        return Err(AppError::Validation(format!(
132            "upload size mismatch for bundle {bundle_id}: expected {} bytes, got {}",
133            record.expected_size_bytes,
134            bytes.len()
135        )));
136    }
137
138    let actual_sha = sha256_hex(bytes);
139    if actual_sha != record.expected_sha256 {
140        return Err(AppError::Validation(format!(
141            "upload integrity check failed for bundle {bundle_id}: \
142             expected sha256={}, got {}",
143            record.expected_sha256, actual_sha
144        )));
145    }
146
147    // Stage the bytes on disk. Ensure the blob dir exists; this is
148    // the first place that may need to create it (export-side
149    // creates at descriptor mint time).
150    tokio::fs::create_dir_all(blob_dir)
151        .await
152        .map_err(AppError::Io)?;
153    #[cfg(unix)]
154    set_dir_mode_700(blob_dir).await?;
155
156    let blob_path = blob_dir.join(format!("{bundle_id}.vtabak"));
157    tokio::fs::write(&blob_path, bytes)
158        .await
159        .map_err(AppError::Io)?;
160    #[cfg(unix)]
161    set_file_mode_600(&blob_path).await?;
162
163    record.state = BundleState::ImportReceived;
164    record.blob_path = Some(blob_path);
165    backup_bundle_store::store_bundle(bundles_ks, &record).await?;
166
167    info!(bundle_id = %bundle_id, bytes = bytes.len(), "POST blob: accepted");
168
169    Ok(())
170}
171
172// ─── State-machine guards ──────────────────────────────────────────────
173
174fn enforce_token(record: &BundleRecord, provided: &str) -> Result<(), AppError> {
175    if !verify_token(provided, &record.token_hash) {
176        return Err(AppError::Forbidden(format!(
177            "token does not match for bundle {}",
178            record.bundle_id
179        )));
180    }
181    Ok(())
182}
183
184fn enforce_not_expired(record: &BundleRecord) -> Result<(), AppError> {
185    if record.expires_at < Utc::now() {
186        return Err(gone(format!(
187            "bundle {} expired at {}",
188            record.bundle_id, record.expires_at
189        )));
190    }
191    Ok(())
192}
193
194fn enforce_export_ready(record: &BundleRecord) -> Result<(), AppError> {
195    if record.kind != BundleKind::Export {
196        // Treat as not-found — the bundle exists but doesn't fit
197        // this endpoint's verb. Don't leak the kind.
198        return Err(AppError::NotFound(format!(
199            "bundle not found: {}",
200            record.bundle_id
201        )));
202    }
203    match record.state {
204        BundleState::ExportReady => Ok(()),
205        BundleState::ExportDownloaded => Err(gone(format!(
206            "bundle {} already downloaded (one-shot)",
207            record.bundle_id
208        ))),
209        BundleState::Aborted => Err(gone(format!("bundle {} was aborted", record.bundle_id))),
210        BundleState::Expired => Err(gone(format!("bundle {} expired", record.bundle_id))),
211        _ => Err(AppError::Conflict(format!(
212            "bundle {} is in state {:?}, not ready for download",
213            record.bundle_id, record.state
214        ))),
215    }
216}
217
218fn enforce_import_pending(record: &BundleRecord) -> Result<(), AppError> {
219    if record.kind != BundleKind::Import {
220        return Err(AppError::NotFound(format!(
221            "bundle not found: {}",
222            record.bundle_id
223        )));
224    }
225    match record.state {
226        BundleState::ImportPending => Ok(()),
227        BundleState::ImportReceived
228        | BundleState::ImportPreviewed
229        | BundleState::ImportCommitted => Err(AppError::Conflict(format!(
230            "bundle {} upload already accepted",
231            record.bundle_id
232        ))),
233        BundleState::Aborted => Err(gone(format!("bundle {} was aborted", record.bundle_id))),
234        BundleState::Expired => Err(gone(format!("bundle {} expired", record.bundle_id))),
235        _ => Err(AppError::Conflict(format!(
236            "bundle {} is in state {:?}, not ready for upload",
237            record.bundle_id, record.state
238        ))),
239    }
240}
241
242fn gone(message: String) -> AppError {
243    // `AppError` doesn't have a `Gone` variant. The blob endpoints
244    // want 410 specifically so the operator CLI can distinguish
245    // "this slot was valid but is now consumed/expired" from
246    // "this slot never existed" (404). Map via `Conflict` for now —
247    // a follow-on can add a typed variant if the CLI surface
248    // requires it.
249    AppError::Conflict(message)
250}
251
252fn sha256_hex(bytes: &[u8]) -> String {
253    use sha2::{Digest, Sha256};
254    let mut hasher = Sha256::new();
255    hasher.update(bytes);
256    let digest = hasher.finalize();
257    hex_lower(&digest)
258}
259
260fn hex_lower(bytes: &[u8]) -> String {
261    let mut out = String::with_capacity(bytes.len() * 2);
262    for b in bytes {
263        out.push_str(&format!("{b:02x}"));
264    }
265    out
266}
267
268#[cfg(unix)]
269async fn set_dir_mode_700(path: &std::path::Path) -> Result<(), AppError> {
270    use std::os::unix::fs::PermissionsExt;
271    let perms = std::fs::Permissions::from_mode(0o700);
272    tokio::fs::set_permissions(path, perms)
273        .await
274        .map_err(AppError::Io)
275}
276
277#[cfg(unix)]
278async fn set_file_mode_600(path: &std::path::Path) -> Result<(), AppError> {
279    use std::os::unix::fs::PermissionsExt;
280    let perms = std::fs::Permissions::from_mode(0o600);
281    tokio::fs::set_permissions(path, perms)
282        .await
283        .map_err(AppError::Io)
284}