//! Loose object store (spec §13.1): one file per object under //! `objects/loose//`, written atomically and verified on read. //! Packs come later. use crate::attest::Attest; use crate::hash::{hash_object, header, ObjectRef, ObjectType}; use crate::identity::{Identity, Signature}; use crate::objects::{Change, ObjectError, StateObject}; use rand::RngCore; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; #[derive(Debug, thiserror::Error)] pub enum StoreError { #[error("io: {0}")] Io(#[from] io::Error), #[error("corrupt object {0}: {1}")] Corrupt(ObjectRef, String), #[error("object {0}")] Object(#[from] ObjectError), #[error("object {0} is a {1}, expected {2}")] WrongType(ObjectRef, &'static str, &'static str), #[error("object {0} not found")] NotFound(ObjectRef), } pub type Result = std::result::Result; #[derive(Debug, Clone)] pub struct Store { dir: PathBuf, } impl Store { pub fn open(dir: impl Into) -> io::Result { let dir = dir.into(); fs::create_dir_all(dir.join("loose"))?; Ok(Store { dir }) } pub fn dir(&self) -> &Path { &self.dir } fn path_for(&self, r: &ObjectRef) -> PathBuf { let h = r.to_hex(); self.dir.join("loose").join(&h[..2]).join(&h[2..]) } pub fn has(&self, r: &ObjectRef) -> bool { self.path_for(r).is_file() } /// Store `body` as an object of type `ty`; idempotent. pub fn put(&self, ty: ObjectType, body: &[u8]) -> Result { let r = hash_object(ty, body); let path = self.path_for(&r); if path.is_file() { return Ok(r); } fs::create_dir_all(path.parent().unwrap())?; let mut nonce = [0u8; 8]; rand::rngs::OsRng.fill_bytes(&mut nonce); let tmp = self.dir.join("loose").join(format!("tmp-{}", hex::encode(nonce))); { let mut f = fs::File::create(&tmp)?; f.write_all(&header(ty, body.len()))?; f.write_all(body)?; f.sync_all()?; } fs::rename(&tmp, &path)?; Ok(r) } /// Read and verify an object. pub fn get(&self, r: &ObjectRef) -> Result<(ObjectType, Vec)> { let path = self.path_for(r); let data = match fs::read(&path) { Ok(d) => d, Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(StoreError::NotFound(*r)), Err(e) => return Err(e.into()), }; let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?; let head = std::str::from_utf8(&data[..nul]).map_err(|_| StoreError::Corrupt(*r, "header not UTF-8".into()))?; let mut parts = head.split(' '); let (Some("oxy1"), Some(ty), Some(len), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return Err(StoreError::Corrupt(*r, format!("bad header {head:?}"))); }; let ty = ObjectType::from_name(ty).ok_or_else(|| StoreError::Corrupt(*r, format!("unknown type {ty}")))?; let len: usize = len.parse().map_err(|_| StoreError::Corrupt(*r, "bad length".into()))?; let body = &data[nul + 1..]; if body.len() != len { return Err(StoreError::Corrupt(*r, format!("length {} != header {len}", body.len()))); } if hash_object(ty, body) != *r { return Err(StoreError::Corrupt(*r, "hash mismatch".into())); } Ok((ty, body.to_vec())) } pub fn get_typed(&self, r: &ObjectRef, want: ObjectType) -> Result> { let (ty, body) = self.get(r)?; if ty != want { return Err(StoreError::WrongType(*r, ty.name(), want.name())); } Ok(body) } pub fn put_change(&self, c: &Change) -> Result { self.put(ObjectType::Change, &c.encode()) } pub fn get_change(&self, r: &ObjectRef) -> Result { Ok(Change::decode(&self.get_typed(r, ObjectType::Change)?)?) } pub fn put_chunk(&self, bytes: &[u8]) -> Result { self.put(ObjectType::Chunk, bytes) } pub fn put_state(&self, st: &StateObject) -> Result { self.put(ObjectType::State, &st.encode()) } pub fn get_state(&self, r: &ObjectRef) -> Result { Ok(StateObject::decode(&self.get_typed(r, ObjectType::State)?)?) } pub fn put_attest(&self, a: &Attest) -> Result { self.put(ObjectType::Attest, &a.encode()) } pub fn get_attest(&self, r: &ObjectRef) -> Result { Ok(Attest::decode(&self.get_typed(r, ObjectType::Attest)?)?) } fn seal_index_path(&self, change: &ObjectRef) -> PathBuf { let h = change.to_hex(); self.dir.join("seals").join(&h[..2]).join(&h[2..]) } /// Record that seal attestation `attest` covers `change`. pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> { let mut seals = self.seals_for(change)?; if seals.contains(attest) { return Ok(()); } seals.push(*attest); seals.sort(); let path = self.seal_index_path(change); fs::create_dir_all(path.parent().unwrap())?; let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect(); let tmp = path.with_extension("tmp"); fs::write(&tmp, text)?; fs::rename(&tmp, &path)?; Ok(()) } /// Seal attestations known to cover `change`. pub fn seals_for(&self, change: &ObjectRef) -> Result> { match fs::read_to_string(self.seal_index_path(change)) { Ok(t) => Ok(t.lines().filter_map(ObjectRef::from_hex).collect()), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]), Err(e) => Err(e.into()), } } pub fn put_identity(&self, id: &Identity) -> Result { self.put(ObjectType::Identity, &id.encode()) } pub fn get_identity(&self, r: &ObjectRef) -> Result { Ok(Identity::decode(&self.get_typed(r, ObjectType::Identity)?)?) } fn sig_path(&self, r: &ObjectRef) -> PathBuf { let h = r.to_hex(); self.dir.join("sigs").join(&h[..2]).join(&h[2..]) } /// Signatures stored alongside object `r` (spec §5.4). Empty if none. pub fn signatures(&self, r: &ObjectRef) -> Result> { match fs::read(self.sig_path(r)) { Ok(b) => Ok(Signature::decode_list(&b)?), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]), Err(e) => Err(e.into()), } } /// Attach a signature to object `r`, replacing any earlier one by the same key. pub fn put_signature(&self, r: &ObjectRef, sig: &Signature) -> Result<()> { let mut sigs = self.signatures(r)?; sigs.retain(|s| s.key != sig.key); sigs.push(sig.clone()); sigs.sort_by_key(|s| s.key); let path = self.sig_path(r); fs::create_dir_all(path.parent().unwrap())?; let mut nonce = [0u8; 8]; rand::rngs::OsRng.fill_bytes(&mut nonce); let tmp = self.dir.join(format!("sigs-tmp-{}", hex::encode(nonce))); { let mut f = fs::File::create(&tmp)?; f.write_all(&Signature::encode_list(&sigs))?; f.sync_all()?; } fs::rename(&tmp, &path)?; Ok(()) } /// Every object reference in the store (slow; for gc and tests). pub fn list(&self) -> io::Result> { let mut out = Vec::new(); let loose = self.dir.join("loose"); for d in fs::read_dir(&loose)? { let d = d?; if !d.file_type()?.is_dir() { continue; } let prefix = d.file_name().to_string_lossy().to_string(); for f in fs::read_dir(d.path())? { let f = f?; let name = f.file_name().to_string_lossy().to_string(); if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) { out.push(r); } } } out.sort(); Ok(out) } } #[cfg(test)] mod tests { use super::*; #[test] fn put_get_verify() { let dir = tempfile::tempdir().unwrap(); let s = Store::open(dir.path().join("objects")).unwrap(); let r = s.put(ObjectType::Chunk, b"hello").unwrap(); assert!(s.has(&r)); assert_eq!(s.get(&r).unwrap(), (ObjectType::Chunk, b"hello".to_vec())); assert_eq!(s.put(ObjectType::Chunk, b"hello").unwrap(), r); assert_eq!(s.list().unwrap(), vec![r]); assert!(matches!(s.get_typed(&r, ObjectType::Blob), Err(StoreError::WrongType(..)))); assert!(matches!(s.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_)))); // Tamper: flip a byte, read must fail. let path = s.path_for(&r); let mut data = fs::read(&path).unwrap(); let last = data.len() - 1; data[last] ^= 1; fs::write(&path, data).unwrap(); assert!(matches!(s.get(&r), Err(StoreError::Corrupt(..)))); } #[test] fn signatures_are_stored_beside_objects() { use crate::identity::{Domain, SecretKey}; let dir = tempfile::tempdir().unwrap(); let s = Store::open(dir.path()).unwrap(); let r = s.put(ObjectType::Chunk, b"x").unwrap(); assert!(s.signatures(&r).unwrap().is_empty()); let sk = SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] }; let id = sk.identity("a", "b", 0); let key = s.put_identity(&id).unwrap(); assert_eq!(s.get_identity(&key).unwrap(), id); let sig = sk.sign(Domain::Change, &key, &r); s.put_signature(&r, &sig).unwrap(); s.put_signature(&r, &sig).unwrap(); assert_eq!(s.signatures(&r).unwrap(), vec![sig]); // The object itself is untouched by its signatures. assert_eq!(s.get(&r).unwrap().1, b"x"); } #[test] fn change_round_trip_through_store() { let dir = tempfile::tempdir().unwrap(); let s = Store::open(dir.path()).unwrap(); let c = crate::objects::tests::sample_change(); let r = s.put_change(&c).unwrap(); assert_eq!(r, c.hash()); assert_eq!(s.get_change(&r).unwrap(), c); } }