use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::hash::Hash;
use crate::object::Object;
use crate::wire;
#[derive(Debug, Clone)]
pub struct ObjectStore {
objects_dir: PathBuf,
}
impl ObjectStore {
pub fn open(gpp_dir: &Path) -> Self {
Self {
objects_dir: gpp_dir.join("objects"),
}
}
pub fn init(gpp_dir: &Path) -> Result<Self> {
let store = Self::open(gpp_dir);
fs::create_dir_all(&store.objects_dir)?;
Ok(store)
}
fn path_for(&self, id: &Hash) -> PathBuf {
let s = id.to_base32();
let (shard, rest) = s.split_at(2);
self.objects_dir.join(shard).join(rest)
}
pub fn contains(&self, id: &Hash) -> bool {
self.path_for(id).exists()
}
pub fn write<T: Object>(&self, object: &T) -> Result<Hash> {
let body = object.encode_body()?;
let id = Hash::of(&body);
let path = self.path_for(&id);
if path.exists() {
tracing::trace!(%id, "object already present, skipping write");
return Ok(id);
}
let frame = wire::encode(T::TYPE, &body)?;
let dir = path
.parent()
.expect("object path always has a shard parent");
fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".tmp-{}", id.short()));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(&frame)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)?;
tracing::debug!(%id, bytes = frame.len(), "wrote object");
Ok(id)
}
pub fn read_raw(&self, id: &Hash) -> Result<Vec<u8>> {
match fs::read(self.path_for(id)) {
Ok(b) => Ok(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::NotFound(*id)),
Err(e) => Err(Error::Io(e)),
}
}
pub fn write_raw(&self, id: &Hash, frame: &[u8]) -> Result<()> {
let decoded = wire::decode(frame)?;
let computed = Hash::of(&decoded.body);
if computed != *id {
return Err(Error::HashMismatch {
expected: *id,
computed,
});
}
let path = self.path_for(id);
if path.exists() {
return Ok(());
}
let dir = path
.parent()
.expect("object path always has a shard parent");
fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".tmp-{}", id.short()));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(frame)?;
f.sync_all()?;
}
fs::rename(&tmp, &path)?;
Ok(())
}
pub fn iter_ids(&self) -> Vec<Hash> {
let mut out = Vec::new();
let Ok(shards) = fs::read_dir(&self.objects_dir) else {
return out;
};
for shard in shards.flatten() {
if !shard.path().is_dir() {
continue;
}
let shard_name = shard.file_name().to_string_lossy().into_owned();
let Ok(rest) = fs::read_dir(shard.path()) else {
continue;
};
for ent in rest.flatten() {
let fname = ent.file_name().to_string_lossy().into_owned();
if fname.starts_with(".tmp-") {
continue;
}
if let Ok(h) = Hash::from_base32(&format!("{shard_name}{fname}")) {
out.push(h);
}
}
}
out
}
pub fn read<T: Object>(&self, id: &Hash) -> Result<T> {
let path = self.path_for(id);
let frame = match fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::NotFound(*id));
}
Err(e) => return Err(Error::Io(e)),
};
let decoded = wire::decode(&frame)?;
if decoded.object_type != T::TYPE {
return Err(Error::TypeMismatch {
expected: T::TYPE.code(),
found: decoded.object_type.code(),
});
}
let computed = Hash::of(&decoded.body);
if computed != *id {
return Err(Error::HashMismatch {
expected: *id,
computed,
});
}
T::decode_body(&decoded.body)
}
}
pub fn flatten_tree(
store: &ObjectStore,
root: &Hash,
) -> Result<std::collections::BTreeMap<String, Hash>> {
fn walk(
store: &ObjectStore,
tree_hash: &Hash,
prefix: &str,
out: &mut std::collections::BTreeMap<String, Hash>,
) -> Result<()> {
let tree: crate::Tree = store.read(tree_hash)?;
for e in tree.entries {
let path = if prefix.is_empty() {
e.name.clone()
} else {
format!("{prefix}/{}", e.name)
};
match e.kind {
crate::EntryKind::Directory => walk(store, &e.hash, &path, out)?,
crate::EntryKind::File | crate::EntryKind::Symlink => {
out.insert(path, e.hash);
}
}
}
Ok(())
}
let mut out = std::collections::BTreeMap::new();
walk(store, root, "", &mut out)?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object::{Blob, EntryKind, Tree, TreeEntry};
fn store() -> (tempfile::TempDir, ObjectStore) {
let dir = tempfile::tempdir().unwrap();
let store = ObjectStore::init(dir.path()).unwrap();
(dir, store)
}
#[test]
fn write_then_read_blob() {
let (_d, s) = store();
let blob = Blob::new(b"content addressed!".to_vec());
let id = s.write(&blob).unwrap();
assert!(s.contains(&id));
assert_eq!(s.read::<Blob>(&id).unwrap(), blob);
}
#[test]
fn write_is_idempotent() {
let (_d, s) = store();
let blob = Blob::new(b"dup".to_vec());
assert_eq!(s.write(&blob).unwrap(), s.write(&blob).unwrap());
}
#[test]
fn write_then_read_tree() {
let (_d, s) = store();
let blob_id = s.write(&Blob::new(b"file".to_vec())).unwrap();
let tree = Tree::new(vec![TreeEntry {
name: "file.txt".into(),
kind: EntryKind::File,
hash: blob_id,
mode: 0o644,
size: 4,
}]);
let id = s.write(&tree).unwrap();
assert_eq!(s.read::<Tree>(&id).unwrap(), tree);
}
#[test]
fn missing_object_is_not_found() {
let (_d, s) = store();
let err = s.read::<Blob>(&Hash::of(b"nope")).unwrap_err();
assert!(matches!(err, Error::NotFound(_)));
}
#[test]
fn type_mismatch_is_rejected() {
let (_d, s) = store();
let id = s.write(&Blob::new(b"i am a blob".to_vec())).unwrap();
assert!(matches!(
s.read::<Tree>(&id).unwrap_err(),
Error::TypeMismatch { .. }
));
}
#[test]
fn raw_transfer_roundtrips_and_enumerates() {
let (_d, src) = store();
let id = src.write(&Blob::new(b"sync me".to_vec())).unwrap();
let frame = src.read_raw(&id).unwrap();
let dst_dir = tempfile::tempdir().unwrap();
let dst = ObjectStore::init(dst_dir.path()).unwrap();
dst.write_raw(&id, &frame).unwrap();
assert_eq!(dst.read::<Blob>(&id).unwrap().content, b"sync me");
let ids = dst.iter_ids();
assert_eq!(ids, vec![id]);
dst.write_raw(&id, &frame).unwrap();
}
#[test]
fn write_raw_rejects_wrong_address() {
let (_d, s) = store();
let frame = wire::encode(crate::object::ObjectType::Blob, b"real").unwrap();
let wrong = Hash::of(b"not-the-body");
assert!(matches!(
s.write_raw(&wrong, &frame).unwrap_err(),
Error::HashMismatch { .. }
));
}
#[test]
fn corrupted_object_is_detected() {
let (_d, s) = store();
let id = s.write(&Blob::new(b"tamper me".to_vec())).unwrap();
let path = s.path_for(&id);
let mut bytes = fs::read(&path).unwrap();
let n = bytes.len();
bytes[n - 5] ^= 0xff;
fs::write(&path, &bytes).unwrap();
assert!(s.read::<Blob>(&id).is_err());
}
}