201 lines
6.3 KiB
Rust
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);
|
|
}
|
|
}
|