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 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;
bundle_store::store_bundle(ks, &record).await?;
chunked::delete_plan(ks, id).await?;
Ok(true)
}