use std::collections::HashSet;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::capability::StorageCapability;
use crate::error::StorageError;
use crate::sql::SqlAccess;
use crate::types::StorageResult;
const CONTENT_REF_HEX_LEN: usize = 64;
pub const MAX_BLOB_WHOLE_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ContentRef(String);
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct UploadId(String);
impl UploadId {
pub fn from_bytes(bytes: &[u8; 16]) -> Self {
Self(hex_encode(bytes))
}
pub fn from_hex(value: impl Into<String>) -> Result<Self, String> {
let value = value.into();
if value.len() != 32 || !khive_types::is_lowercase_hex(&value) {
return Err("upload_id must be 32 lowercase hex characters".into());
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for UploadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for UploadId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Self::from_hex(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
}
}
impl<'de> Deserialize<'de> for ContentRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
ContentRef::from_hex(raw).map_err(serde::de::Error::custom)
}
}
impl ContentRef {
pub fn from_hex(hex: impl Into<String>) -> Result<Self, String> {
let hex = hex.into();
if hex.len() != CONTENT_REF_HEX_LEN {
return Err(format!(
"content_ref must be {CONTENT_REF_HEX_LEN} hex characters, got length {} ({hex:?})",
hex.len()
));
}
if !khive_types::is_lowercase_hex(&hex) {
return Err(format!(
"content_ref must be lowercase hex (0-9, a-f), got {hex:?}"
));
}
Ok(Self(hex))
}
pub fn from_digest_bytes(digest: &[u8; 32]) -> Self {
Self(hex_encode(digest))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ContentRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ContentRef {
fn as_ref(&self) -> &str {
&self.0
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BlobOrphanSweepConfig {
pub live_refs: HashSet<ContentRef>,
pub dry_run: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct BlobOrphanSweepResult {
pub scanned: u64,
pub deleted: u64,
pub would_delete: u64,
pub grace_period_skipped: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct UploadLeaseConfig {
owner: uuid::Uuid,
idle_secs: u64,
}
impl UploadLeaseConfig {
pub fn new(owner: uuid::Uuid, idle: std::time::Duration) -> StorageResult<Self> {
if owner.is_nil() {
return Err(StorageError::InvalidInput {
capability: StorageCapability::Blob,
operation: "upload_lease_config".into(),
message: "upload lease owner must be a non-nil durable UUID".into(),
});
}
if idle.is_zero() || idle.subsec_nanos() != 0 {
return Err(StorageError::InvalidInput {
capability: StorageCapability::Blob,
operation: "upload_lease_config".into(),
message: "upload lease bound must be positive whole seconds".into(),
});
}
Ok(Self {
owner,
idle_secs: idle.as_secs(),
})
}
pub fn owner(&self) -> uuid::Uuid {
self.owner
}
pub fn idle_secs(&self) -> u64 {
self.idle_secs
}
}
#[async_trait]
pub trait BlobStore: Send + Sync + std::fmt::Debug + 'static {
async fn put(&self, bytes: Vec<u8>) -> StorageResult<ContentRef>;
async fn refresh_publish_grace(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
self.size(content_ref).await
}
fn upload_lease_idle_cap(&self) -> Option<std::time::Duration> {
None
}
async fn begin_upload_with_lease(
&self,
declared_size: u64,
config: UploadLeaseConfig,
) -> StorageResult<UploadId> {
let _ = (declared_size, config);
Err(unsupported_upload("begin_upload_with_lease"))
}
async fn renew_upload(&self, id: &UploadId) -> StorageResult<()> {
let _ = id;
Err(unsupported_upload("renew_upload"))
}
async fn begin_upload(&self, declared_size: u64) -> StorageResult<UploadId> {
let _ = declared_size;
Err(unsupported_upload("begin_upload"))
}
async fn append_part(&self, id: &UploadId, bytes: Vec<u8>) -> StorageResult<u64> {
let _ = (id, bytes);
Err(unsupported_upload("append_part"))
}
async fn commit_upload(&self, id: &UploadId, content_ref: &ContentRef) -> StorageResult<()> {
let _ = (id, content_ref);
Err(unsupported_upload("commit_upload"))
}
async fn abort_upload(&self, id: &UploadId) -> StorageResult<()> {
let _ = id;
Err(unsupported_upload("abort_upload"))
}
async fn sweep_uploads(&self, idle_for: std::time::Duration) -> StorageResult<u64> {
let _ = idle_for;
Err(unsupported_upload("sweep_uploads"))
}
async fn get_bounded_verified(
&self,
content_ref: &ContentRef,
max_bytes: u64,
) -> StorageResult<Vec<u8>>;
async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool>;
async fn size(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>>;
async fn delete(&self, content_ref: &ContentRef) -> StorageResult<bool>;
async fn orphan_sweep(
&self,
config: &BlobOrphanSweepConfig,
) -> StorageResult<BlobOrphanSweepResult> {
let _ = config;
Err(StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: "orphan_sweep".into(),
message: "this backend does not support orphan sweep".into(),
})
}
async fn transactional_orphan_sweep(
&self,
sql: &dyn SqlAccess,
dry_run: bool,
) -> StorageResult<BlobOrphanSweepResult> {
let _ = (sql, dry_run);
Err(StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: "transactional_orphan_sweep".into(),
message: "this backend does not support a database-coordinated orphan sweep".into(),
})
}
}
fn unsupported_upload(operation: &'static str) -> StorageError {
StorageError::Unsupported {
capability: StorageCapability::Blob,
operation: operation.into(),
message: "this backend does not support staged uploads".into(),
}
}
#[cfg(test)]
#[path = "blob/tests.rs"]
mod tests;