use std::str::FromStr;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use uuid::Uuid;
use crate::error::AppError;
use crate::operations::backup::blob;
use crate::server::AppState;
const TOKEN_HEADER: &str = "x-backup-token";
#[utoipa::path(
get, path = "/backup/blob/{bundle_id}", tag = "backup",
params(
("bundle_id" = String, Path, description = "Backup bundle id"),
("x-backup-token" = String, Header, description = "One-shot backup descriptor token"),
),
responses(
(status = 200, description = "Encrypted backup bytes (application/octet-stream)"),
(status = 401, description = "Missing or malformed x-backup-token header"),
(status = 403, description = "Token does not match"),
(status = 404, description = "Bundle not found"),
(status = 410, description = "Bundle expired or already downloaded"),
),
)]
pub async fn get_blob(
State(state): State<AppState>,
Path(bundle_id_str): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let bundle_id = parse_bundle_id(&bundle_id_str)?;
let token = extract_token(&headers)?;
let bytes = blob::read_export_blob(&state.backup_bundles_ks, bundle_id, &token).await?;
Ok((
StatusCode::OK,
[
("content-type", "application/octet-stream"),
(
"content-disposition",
"attachment; filename=\"backup.vtabak\"",
),
],
bytes,
)
.into_response())
}
#[utoipa::path(
post, path = "/backup/blob/{bundle_id}", tag = "backup",
request_body(content = inline(Vec<u8>), content_type = "application/octet-stream", description = "Encrypted backup bytes"),
params(
("bundle_id" = String, Path, description = "Backup bundle id"),
("x-backup-token" = String, Header, description = "One-shot backup descriptor token"),
),
responses(
(status = 202, description = "Upload accepted and staged"),
(status = 400, description = "Body size or SHA-256 mismatch"),
(status = 401, description = "Missing or malformed x-backup-token header"),
(status = 403, description = "Token does not match"),
(status = 404, description = "Bundle not found"),
(status = 410, description = "Bundle expired or already received"),
),
)]
pub async fn post_blob(
State(state): State<AppState>,
Path(bundle_id_str): Path<String>,
headers: HeaderMap,
body: Body,
) -> Result<Response, AppError> {
let bundle_id = parse_bundle_id(&bundle_id_str)?;
let token = extract_token(&headers)?;
let bytes = axum::body::to_bytes(body, super::BACKUP_BLOB_BODY_SIZE)
.await
.map_err(|e| AppError::Validation(format!("read upload body: {e}")))?;
blob::write_import_blob(
&state.backup_bundles_ks,
&state.backup_blob_dir,
bundle_id,
&token,
&bytes,
)
.await?;
Ok((StatusCode::ACCEPTED, "").into_response())
}
fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
Uuid::from_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
}
fn extract_token(headers: &HeaderMap) -> Result<String, AppError> {
let raw = headers
.get(TOKEN_HEADER)
.ok_or_else(|| AppError::Authentication(format!("missing `{TOKEN_HEADER}` header")))?;
let s = raw
.to_str()
.map_err(|e| AppError::Authentication(format!("malformed `{TOKEN_HEADER}` header: {e}")))?;
if s.is_empty() {
return Err(AppError::Authentication(format!(
"empty `{TOKEN_HEADER}` header"
)));
}
Ok(s.to_string())
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use chrono::{Duration, Utc};
use sha2::{Digest, Sha256};
use tower::ServiceExt;
use uuid::Uuid;
use super::*;
use crate::backup_bundle_store::{self, BundleKind, BundleRecord, BundleState, mint_token};
fn sha256_hex_local(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
async fn seed_export(
ctx: &crate::test_support::TestAppContext,
bytes: &[u8],
) -> (Uuid, String) {
let bundle_id = Uuid::new_v4();
let (token, token_hash) = mint_token().expect("mint token");
tokio::fs::create_dir_all(&ctx.backup_blob_dir)
.await
.unwrap();
let path = ctx.backup_blob_dir.join(format!("{bundle_id}.vtabak"));
tokio::fs::write(&path, bytes).await.unwrap();
let record = BundleRecord {
bundle_id,
kind: BundleKind::Export,
state: BundleState::ExportReady,
created_at: Utc::now(),
expires_at: Utc::now() + Duration::minutes(5),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: sha256_hex_local(bytes),
expected_size_bytes: bytes.len() as u64,
token_hash,
blob_path: Some(path),
};
backup_bundle_store::store_bundle(&ctx.backup_bundles_ks, &record)
.await
.unwrap();
let plaintext = token.as_str().to_string();
(bundle_id, plaintext)
}
async fn seed_import_pending(
ctx: &crate::test_support::TestAppContext,
expected_bytes: &[u8],
) -> (Uuid, String, String) {
let bundle_id = Uuid::new_v4();
let (token, token_hash) = mint_token().expect("mint token");
let sha = sha256_hex_local(expected_bytes);
let record = BundleRecord {
bundle_id,
kind: BundleKind::Import,
state: BundleState::ImportPending,
created_at: Utc::now(),
expires_at: Utc::now() + Duration::minutes(5),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: sha.clone(),
expected_size_bytes: expected_bytes.len() as u64,
token_hash,
blob_path: None,
};
backup_bundle_store::store_bundle(&ctx.backup_bundles_ks, &record)
.await
.unwrap();
let plaintext = token.as_str().to_string();
(bundle_id, plaintext, sha)
}
#[tokio::test]
async fn get_blob_returns_bytes_for_valid_token() {
let (app, ctx) = crate::test_support::build_test_app().await;
let bytes = b"backup-bytes-here".to_vec();
let (bundle_id, token) = seed_export(&ctx, &bytes).await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), &bytes[..]);
let record = backup_bundle_store::get_bundle(&ctx.backup_bundles_ks, &bundle_id)
.await
.unwrap()
.unwrap();
assert_eq!(record.state, BundleState::ExportDownloaded);
assert!(record.blob_path.is_none());
}
#[tokio::test]
async fn get_blob_is_one_shot() {
let (app, ctx) = crate::test_support::build_test_app().await;
let (bundle_id, token) = seed_export(&ctx, b"once").await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn get_blob_rejects_missing_token() {
let (app, ctx) = crate::test_support::build_test_app().await;
let (bundle_id, _token) = seed_export(&ctx, b"x").await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_blob_rejects_wrong_token() {
let (app, ctx) = crate::test_support::build_test_app().await;
let (bundle_id, _token) = seed_export(&ctx, b"x").await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, "bogus-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn get_blob_404_for_unknown_id() {
let (app, _ctx) = crate::test_support::build_test_app().await;
let req = Request::builder()
.uri(format!("/backup/blob/{}", Uuid::new_v4()))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, "any-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_blob_rejects_import_bundle_as_not_found() {
let (app, ctx) = crate::test_support::build_test_app().await;
let (bundle_id, token, _) = seed_import_pending(&ctx, b"data").await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_blob_400_for_malformed_uuid() {
let (app, _ctx) = crate::test_support::build_test_app().await;
let req = Request::builder()
.uri("/backup/blob/not-a-uuid")
.method("GET")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, "x")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_blob_accepts_matching_upload() {
let (app, ctx) = crate::test_support::build_test_app().await;
let bytes = b"import-bytes".to_vec();
let (bundle_id, token, _sha) = seed_import_pending(&ctx, &bytes).await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("POST")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::from(bytes.clone()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let record = backup_bundle_store::get_bundle(&ctx.backup_bundles_ks, &bundle_id)
.await
.unwrap()
.unwrap();
assert_eq!(record.state, BundleState::ImportReceived);
let blob_path = record.blob_path.expect("blob path populated");
assert!(blob_path.exists());
let on_disk = tokio::fs::read(&blob_path).await.unwrap();
assert_eq!(on_disk, bytes);
}
#[tokio::test]
async fn post_blob_rejects_size_mismatch() {
let (app, ctx) = crate::test_support::build_test_app().await;
let expected = b"this is what we expect".to_vec();
let (bundle_id, token, _) = seed_import_pending(&ctx, &expected).await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("POST")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::from(b"short".to_vec()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_blob_rejects_hash_mismatch_with_same_size() {
let (app, ctx) = crate::test_support::build_test_app().await;
let expected = b"original-content-here".to_vec();
let (bundle_id, token, _) = seed_import_pending(&ctx, &expected).await;
let mut tampered = expected.clone();
tampered[0] ^= 0xFF;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("POST")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::from(tampered))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn post_blob_refuses_second_upload() {
let (app, ctx) = crate::test_support::build_test_app().await;
let bytes = b"once-upload".to_vec();
let (bundle_id, token, _) = seed_import_pending(&ctx, &bytes).await;
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("POST")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::from(bytes.clone()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::ACCEPTED);
let req = Request::builder()
.uri(format!("/backup/blob/{bundle_id}"))
.method("POST")
.header("x-forwarded-for", "192.0.2.1")
.header(TOKEN_HEADER, &token)
.body(Body::from(bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
}