LeVCS/crates/levcs-core/src/store.rs

201 lines
6.3 KiB
Rust

//! Filesystem object store, sharded by the first two hex characters of the
//! object hash. Objects with hash `abcd1234...` live at
//! `.levcs/objects/ab/cd1234...`.
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use crate::error::{Error, IoExt, Result};
use crate::hash::{blake3_hash, ObjectId};
use crate::object::{ObjectType, RawObject};
#[derive(Clone, Debug)]
pub struct ObjectStore {
pub root: PathBuf,
}
impl ObjectStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.root).ctx(self.root.clone())?;
Ok(())
}
pub fn path_for(&self, id: ObjectId) -> PathBuf {
let hex = id.to_hex();
self.root.join(&hex[0..2]).join(&hex[2..])
}
pub fn contains(&self, id: ObjectId) -> bool {
self.path_for(id).is_file()
}
/// Persist raw object bytes. Returns the BLAKE3 hash of the bytes.
/// Verifies that the bytes are at least a parseable object before
/// writing.
pub fn write_raw(&self, bytes: &[u8]) -> Result<ObjectId> {
// Validate framing.
let _ = RawObject::parse(bytes)?;
let id = blake3_hash(bytes);
self.write_at(id, bytes)
}
/// Persist raw bytes at a known hash. The caller asserts that
/// `blake3(bytes) == id`; this is checked.
pub fn write_at(&self, id: ObjectId, bytes: &[u8]) -> Result<ObjectId> {
let actual = blake3_hash(bytes);
if actual != id {
return Err(Error::HashMismatch {
expected: id.to_hex(),
actual: actual.to_hex(),
});
}
let path = self.path_for(id);
if path.is_file() {
return Ok(id);
}
let parent = path.parent().expect("sharded path has parent");
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
// Write to a temp file in the same directory, then atomically rename.
let tmp = parent.join(format!("tmp.{}", id.to_hex()));
{
let mut f = fs::File::create(&tmp).ctx(tmp.clone())?;
f.write_all(bytes).ctx(tmp.clone())?;
f.sync_all().ctx(tmp.clone())?;
}
fs::rename(&tmp, &path).ctx(path.clone())?;
Ok(id)
}
pub fn read_raw(&self, id: ObjectId) -> Result<Vec<u8>> {
let path = self.path_for(id);
let mut f = fs::File::open(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound(id.to_hex())
} else {
Error::Io {
path: Some(path.clone()),
source: e,
}
}
})?;
let mut buf = Vec::new();
f.read_to_end(&mut buf).ctx(path.clone())?;
// Verify integrity.
let actual = blake3_hash(&buf);
if actual != id {
return Err(Error::HashMismatch {
expected: id.to_hex(),
actual: actual.to_hex(),
});
}
Ok(buf)
}
pub fn read_object(&self, id: ObjectId) -> Result<RawObject> {
let bytes = self.read_raw(id)?;
RawObject::parse(&bytes)
}
pub fn read_typed(&self, id: ObjectId, expected: ObjectType) -> Result<RawObject> {
let obj = self.read_object(id)?;
if obj.object_type != expected {
return Err(Error::MalformedObject(format!(
"expected {}, got {}",
expected.name(),
obj.object_type.name()
)));
}
Ok(obj)
}
/// Iterate all object IDs currently on disk (used by gc/verify). Returns
/// loose objects only.
pub fn iter_ids(&self) -> Result<Vec<ObjectId>> {
let mut out = Vec::new();
if !self.root.is_dir() {
return Ok(out);
}
for shard in fs::read_dir(&self.root).ctx(self.root.clone())? {
let shard = shard.ctx(self.root.clone())?;
let shard_name = shard.file_name();
let shard_str = match shard_name.to_str() {
Some(s) if s.len() == 2 => s.to_string(),
_ => continue,
};
for ent in fs::read_dir(shard.path()).ctx(shard.path())? {
let ent = ent.ctx(shard.path())?;
let name = ent.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if name_str.starts_with("tmp.") {
continue;
}
let full = format!("{}{}", shard_str, name_str);
if let Ok(id) = ObjectId::from_hex(&full) {
out.push(id);
}
}
}
Ok(out)
}
}
/// Convenience: clean up any orphan tmp.* files left by interrupted writes.
pub fn cleanup_temp(store: &ObjectStore) -> Result<()> {
if !store.root.is_dir() {
return Ok(());
}
for shard in fs::read_dir(&store.root).ctx(store.root.clone())? {
let shard = shard.ctx(store.root.clone())?;
for ent in fs::read_dir(shard.path()).ctx(shard.path())? {
let ent = ent.ctx(shard.path())?;
let name = ent.file_name();
if name.to_string_lossy().starts_with("tmp.") {
let _ = fs::remove_file(ent.path());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blob::Blob;
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let n: u64 = blake3::hash(
format!("{:?}-{}", std::time::SystemTime::now(), std::process::id()).as_bytes(),
)
.as_bytes()
.iter()
.take(8)
.fold(0u64, |acc, b| (acc << 8) | *b as u64);
p.push(format!("levcs-store-test-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn roundtrip_blob() {
let dir = tempdir();
let store = ObjectStore::new(dir.join("objects"));
store.ensure_dirs().unwrap();
let blob = Blob::new(b"hello".to_vec());
let bytes = blob.serialize();
let id = store.write_raw(&bytes).unwrap();
assert!(store.contains(id));
let read = store.read_raw(id).unwrap();
assert_eq!(read, bytes);
let _ = std::fs::remove_dir_all(dir);
}
}