use crate::error::{BlobError, Result, StorageError};
use std::fs;
use std::path::{Path, PathBuf};
use zstd::stream::{decode_all, encode_all};
const ZSTD_LEVEL: i32 = 3;
const HASH_HEX_LEN: usize = 64;
pub struct BlobStore {
blobs_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PutOutcome {
pub hash: String,
pub size: u64,
}
impl BlobStore {
pub fn new(blobs_dir: PathBuf) -> Self {
Self { blobs_dir }
}
pub(crate) fn blob_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2];
self.blobs_dir.join(prefix).join(format!("{hash}.zst"))
}
#[must_use]
pub fn hash(content: &[u8]) -> String {
blake3::hash(content).to_hex().to_string()
}
pub fn put(&self, content: &[u8]) -> Result<PutOutcome> {
let hash = blake3::hash(content).to_hex().to_string();
let size = u64::try_from(content.len()).unwrap_or(u64::MAX);
let path = self.blob_path(&hash);
if !path.exists() {
Self::write_blob(&path, content)?;
}
Ok(PutOutcome { hash, size })
}
pub fn get(&self, hash: &str) -> Result<Vec<u8>> {
if hash.len() != HASH_HEX_LEN {
return Err(BlobError::HashMismatch {
expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
actual: hash.to_string(),
}
.into());
}
let path = self.blob_path(hash);
let compressed = match fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(StorageError::MissingBlob(hash.to_string()).into());
}
Err(e) => {
return Err(BlobError::Io { path, source: e }.into());
}
};
let decompressed =
decode_all(&compressed[..]).map_err(|e| BlobError::Zstd(e.to_string()))?;
let actual = blake3::hash(&decompressed).to_hex().to_string();
if actual != hash {
return Err(BlobError::HashMismatch {
expected: hash.to_string(),
actual,
}
.into());
}
Ok(decompressed)
}
fn write_blob(path: &Path, content: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| BlobError::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"blob path has no parent",
),
})?;
create_dir_secure(parent)?;
let compressed =
encode_all(content, ZSTD_LEVEL).map_err(|e| BlobError::Zstd(e.to_string()))?;
write_file_secure(path, &compressed)
}
pub fn remove_if_unreferenced(&self, hash: &str, refcount: i64) -> Result<bool> {
if refcount > 0 {
return Ok(false);
}
let path = self.blob_path(hash);
match fs::remove_file(&path) {
Ok(()) => {
if let Some(shard) = path.parent() {
let _ = fs::remove_dir(shard);
}
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(BlobError::Io { path, source: e }.into()),
}
}
pub fn root(&self) -> &Path {
&self.blobs_dir
}
}
fn create_dir_secure(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(path)
.map_err(|e| BlobError::Io {
path: path.to_path_buf(),
source: e,
})?;
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(path).map_err(|e| BlobError::Io {
path: path.to_path_buf(),
source: e,
})?;
}
Ok(())
}
fn write_file_secure(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension("zst.tmp");
{
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
f.write_all(bytes).map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
f.sync_all().map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
}
#[cfg(not(unix))]
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp).map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
f.write_all(bytes).map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
f.sync_all().map_err(|e| BlobError::Io {
path: tmp.clone(),
source: e,
})?;
}
}
fs::rename(&tmp, path).map_err(|e| BlobError::Io {
path: path.to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn store() -> (TempDir, BlobStore) {
let tmp = TempDir::new().unwrap();
let s = BlobStore::new(tmp.path().join("blobs"));
(tmp, s)
}
#[test]
fn put_get_roundtrip_small() {
let (_tmp, s) = store();
let content = b"hello halfhand blobs";
let out = s.put(content).unwrap();
assert_eq!(out.size, content.len() as u64);
let p = s.blob_path(&out.hash);
assert!(p.exists());
assert!(p.starts_with(s.root()));
assert!(p.parent().unwrap().file_name().unwrap().len() == 2);
let got = s.get(&out.hash).unwrap();
assert_eq!(got, content);
}
#[test]
fn put_get_roundtrip_compressible() {
let (_tmp, s) = store();
let content = "a".repeat(64 * 1024).into_bytes();
let out = s.put(&content).unwrap();
let on_disk = fs::read(s.blob_path(&out.hash)).unwrap();
assert!(
on_disk.len() < content.len(),
"zstd should compress repetitive input"
);
let got = s.get(&out.hash).unwrap();
assert_eq!(got, content);
}
#[test]
fn put_is_idempotent_on_disk() {
let (_tmp, s) = store();
let out1 = s.put(b"same content").unwrap();
let out2 = s.put(b"same content").unwrap();
assert_eq!(out1.hash, out2.hash);
assert_eq!(fs::read_dir(s.root()).unwrap().count(), 1);
}
#[test]
fn get_missing_blob_errors() {
let (_tmp, s) = store();
let h = "a".repeat(64);
let err = s.get(&h).unwrap_err();
assert!(matches!(
err,
crate::Error::Storage(StorageError::MissingBlob(_))
));
}
#[test]
fn get_detects_corruption() {
let (_tmp, s) = store();
let out = s.put(b"original content").unwrap();
let path = s.blob_path(&out.hash);
let bad = zstd::stream::encode_all(b"different content".as_ref(), 3).unwrap();
fs::write(&path, &bad).unwrap();
let err = s.get(&out.hash).unwrap_err();
assert!(matches!(
err,
crate::Error::Blob(BlobError::HashMismatch { .. })
));
}
#[test]
fn remove_deletes_when_unreferenced() {
let (_tmp, s) = store();
let out = s.put(b"to be deleted").unwrap();
let path = s.blob_path(&out.hash);
assert!(path.exists());
assert!(s.remove_if_unreferenced(&out.hash, 0).unwrap());
assert!(!path.exists());
assert!(!s.remove_if_unreferenced(&out.hash, 0).unwrap());
}
#[test]
fn remove_keeps_when_still_referenced() {
let (_tmp, s) = store();
let out = s.put(b"still referenced").unwrap();
let path = s.blob_path(&out.hash);
assert!(!s.remove_if_unreferenced(&out.hash, 1).unwrap());
assert!(path.exists());
}
#[cfg(unix)]
#[test]
fn blob_files_are_0600_and_dirs_0700() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, s) = store();
let out = s.put(b"secret bytes").unwrap();
let path = s.blob_path(&out.hash);
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let shard = path.parent().unwrap();
let dmode = fs::metadata(shard).unwrap().permissions().mode();
assert_eq!(dmode & 0o777, 0o700);
}
}