use std::path::Path;
use chrono::Duration;
use tracing::warn;
use uuid::Uuid;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
pub mod bundle_store;
pub mod chunked;
pub mod sweeper;
use bundle_store::{BundleKind, BundleRecord};
pub const DEFAULT_BUNDLE_TTL_SECS: u64 = 300;
pub const MAX_BUNDLE_TTL_SECS: u64 = 3600;
pub const MAX_OPEN_BUNDLES_PER_DID: usize = 3;
pub fn bundle_ttl() -> Duration {
Duration::seconds(DEFAULT_BUNDLE_TTL_SECS as i64)
}
pub async fn enforce_open_bundle_cap(ks: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
let all = bundle_store::list_bundles(ks).await?;
let open = all
.iter()
.filter(|r| r.created_by == did && !r.state.is_terminal())
.count();
if open >= MAX_OPEN_BUNDLES_PER_DID {
return Err(AppError::Conflict(format!(
"operator `{did}` has {open} open backup bundles; \
abort or wait for expiry before initiating another \
(cap: {MAX_OPEN_BUNDLES_PER_DID})"
)));
}
Ok(())
}
pub fn parse_bundle_id(s: &str) -> Result<Uuid, AppError> {
Uuid::parse_str(s).map_err(|e| AppError::Validation(format!("invalid bundle_id `{s}`: {e}")))
}
pub async fn require_owned(
ks: &KeyspaceHandle,
id: &Uuid,
caller_did: &str,
) -> Result<BundleRecord, AppError> {
let record = bundle_store::get_bundle(ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("bundle not found: {id}")))?;
if record.created_by != caller_did {
warn!(
bundle_id = %id,
caller = %caller_did,
owner = %record.created_by,
"bundle owned by a different operator; treating as not-found"
);
return Err(AppError::NotFound(format!("bundle not found: {id}")));
}
Ok(record)
}
pub fn enforce_kind(record: &BundleRecord, expected: BundleKind) -> Result<(), AppError> {
if record.kind != expected {
return Err(AppError::NotFound(format!(
"bundle not found: {}",
record.bundle_id
)));
}
Ok(())
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let out = Sha256::digest(bytes);
let mut s = String::with_capacity(out.len() * 2);
for b in out {
s.push_str(&format!("{b:02x}"));
}
s
}
#[cfg(unix)]
pub async fn set_dir_mode_700(path: &Path) -> Result<(), AppError> {
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
.await
.map_err(AppError::Io)
}
#[cfg(unix)]
pub async fn set_file_mode_600(path: &Path) -> Result<(), AppError> {
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.await
.map_err(AppError::Io)
}
pub async fn abort(ks: &KeyspaceHandle, caller_did: &str, id: &Uuid) -> Result<bool, AppError> {
let _guard = chunked::chunk_lock().lock().await;
let mut record = require_owned(ks, id, caller_did).await?;
if record.state.is_terminal() {
return Ok(false);
}
if let Some(path) = record.blob_path.clone()
&& let Err(e) = tokio::fs::remove_file(&path).await
&& e.kind() != std::io::ErrorKind::NotFound
{
warn!(
bundle_id = %id,
path = %path.display(),
error = %e,
"abort: failed to delete staged bytes; sweeper will retry"
);
}
record.state = bundle_store::BundleState::Aborted;
record.blob_path = None;
let mut batch = crate::store::Batch::new();
batch
.insert(bundle_store::bundle_key(id), &record)?
.remove(chunked::plan_key(id));
ks.apply_batch(batch).await?;
Ok(true)
}
#[cfg(test)]
mod abort_tests {
use super::bundle_store::{BundleState, store_bundle};
use super::*;
use crate::config::StoreConfig;
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn racing_aborts_abort_a_bundle_exactly_once() {
let dir = tempfile::tempdir().unwrap();
let store = crate::store::Store::open(&StoreConfig {
data_dir: dir.path().into(),
})
.unwrap();
let ks = store.keyspace("backup_bundles_abort_test").unwrap();
for _ in 0..40 {
let now = chrono::Utc::now();
let record = BundleRecord {
bundle_id: Uuid::new_v4(),
kind: BundleKind::Import,
state: BundleState::ImportPending,
created_at: now,
expires_at: now + Duration::hours(1),
created_by: "did:example:admin".into(),
algorithm: "stream".into(),
expected_sha256: "0".repeat(64),
expected_size_bytes: 1,
token_hash: [0u8; 32],
blob_path: None,
};
store_bundle(&ks, &record).await.unwrap();
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(8));
let mut tasks = Vec::new();
for _ in 0..8 {
let (ks, barrier, id) = (ks.clone(), barrier.clone(), record.bundle_id);
tasks.push(tokio::spawn(async move {
barrier.wait().await;
abort(&ks, "did:example:admin", &id).await.unwrap()
}));
}
let mut aborted = 0;
for t in tasks {
aborted += t.await.unwrap() as usize;
}
assert_eq!(aborted, 1, "{aborted} callers aborted one bundle");
}
}
}