use std::collections::HashSet;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::capability::StorageCapability;
use crate::error::StorageError;
use crate::types::StorageResult;
const CONTENT_REF_HEX_LEN: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct ContentRef(String);
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 !hex
.bytes()
.all(|b| b.is_ascii_digit() || (b.is_ascii_lowercase() && b.is_ascii_hexdigit()))
{
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,
}
#[async_trait]
pub trait BlobStore: Send + Sync + std::fmt::Debug + 'static {
async fn put(&self, bytes: Vec<u8>) -> StorageResult<ContentRef>;
async fn get(&self, content_ref: &ContentRef) -> StorageResult<Vec<u8>>;
async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool>;
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(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_hex_accepts_valid_lowercase_digest() {
let hex = "a".repeat(64);
let cref = ContentRef::from_hex(hex.clone()).unwrap();
assert_eq!(cref.as_str(), hex);
assert_eq!(cref.to_string(), hex);
}
#[test]
fn from_hex_rejects_short_string() {
let err = ContentRef::from_hex("abc").unwrap_err();
assert!(
err.contains("64"),
"error must mention expected length: {err}"
);
}
#[test]
fn from_hex_rejects_long_string() {
let err = ContentRef::from_hex("a".repeat(65)).unwrap_err();
assert!(
err.contains("64"),
"error must mention expected length: {err}"
);
}
#[test]
fn from_hex_rejects_uppercase() {
let err = ContentRef::from_hex("A".repeat(64)).unwrap_err();
assert!(
err.contains("lowercase"),
"error must mention lowercase requirement: {err}"
);
}
#[test]
fn from_hex_rejects_non_hex_characters() {
let mut hex = "a".repeat(63);
hex.push('z');
let err = ContentRef::from_hex(hex).unwrap_err();
assert!(
err.contains("lowercase hex"),
"error must mention hex requirement: {err}"
);
}
#[test]
fn from_digest_bytes_matches_known_blake3_hash() {
let hash = blake3_hash_of_empty();
let cref = ContentRef::from_digest_bytes(&hash);
assert_eq!(
cref.as_str(),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
fn blake3_hash_of_empty() -> [u8; 32] {
let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
let mut out = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
let s = std::str::from_utf8(chunk).unwrap();
out[i] = u8::from_str_radix(s, 16).unwrap();
}
out
}
#[test]
fn deserialize_accepts_a_valid_lowercase_digest() {
let hex = "d".repeat(64);
let json = serde_json::to_string(&hex).unwrap();
let cref: ContentRef = serde_json::from_str(&json).unwrap();
assert_eq!(cref.as_str(), hex);
}
#[test]
fn deserialize_rejects_short_string() {
let err = serde_json::from_str::<ContentRef>("\"x\"").unwrap_err();
assert!(
err.to_string().contains("64"),
"deserialize error must mention the expected length: {err}"
);
}
#[test]
fn deserialize_rejects_uppercase() {
let hex = "A".repeat(64);
let json = serde_json::to_string(&hex).unwrap();
let err = serde_json::from_str::<ContentRef>(&json).unwrap_err();
assert!(
err.to_string().contains("lowercase"),
"deserialize error must mention the lowercase requirement: {err}"
);
}
#[test]
fn deserialize_rejects_non_hex_characters() {
let mut hex = "a".repeat(63);
hex.push('z');
let json = serde_json::to_string(&hex).unwrap();
let err = serde_json::from_str::<ContentRef>(&json).unwrap_err();
assert!(
err.to_string().contains("lowercase hex"),
"deserialize error must mention the hex requirement: {err}"
);
}
#[test]
fn content_ref_equality_and_hash_are_string_based() {
let a = ContentRef::from_hex("b".repeat(64)).unwrap();
let b = ContentRef::from_hex("b".repeat(64)).unwrap();
let c = ContentRef::from_hex("c".repeat(64)).unwrap();
assert_eq!(a, b);
assert_ne!(a, c);
use std::collections::HashSet;
let mut set = HashSet::new();
set.insert(a.clone());
assert!(set.contains(&b));
assert!(!set.contains(&c));
}
}