use std::{ fs::File, io::Write as _, path::{Path, PathBuf}, }; use crate::{ ChunkId, Error, backend::{Backend, ObjectKey, ObjectKind}, }; /// A [`Backend`] storing objects as files under a local directory. /// /// The directory layout follows [`ObjectKey::segments`]. Writes go to a /// temporary file that is atomically renamed into place and synced, so a /// crash never leaves a partially written object under its final name. #[derive(Debug)] pub struct LocalBackend { root: PathBuf, } impl LocalBackend { /// Uses `root` as a repository directory, creating it if necessary. pub fn new(root: impl Into) -> Result { let root = root.into(); std::fs::create_dir_all(&root)?; // Canonical form, so the path can be compared against scanner // paths (see Backend::local_root) let root = root.canonicalize()?; Ok(LocalBackend { root }) } fn object_path(&self, key: &ObjectKey) -> PathBuf { let mut path = self.root.clone(); for segment in key.segments() { path.push(segment); } path } } impl Backend for LocalBackend { fn put(&self, key: &ObjectKey, data: &[u8]) -> Result<(), Error> { let path = self.object_path(key); if path.try_exists()? { // Objects are write-once and content-addressed or unique by // construction, so an existing object is already this one. return Ok(()); } let parent = path .parent() .expect("object paths always have a parent directory"); std::fs::create_dir_all(parent)?; let mut temp = tempfile::NamedTempFile::new_in(parent)?; temp.write_all(data)?; temp.as_file().sync_all()?; temp.persist(&path).map_err(|err| err.error)?; sync_dir(parent)?; Ok(()) } fn get(&self, key: &ObjectKey) -> Result>, Error> { match std::fs::read(self.object_path(key)) { Ok(data) => Ok(Some(data)), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err.into()), } } fn contains(&self, key: &ObjectKey) -> Result { Ok(self.object_path(key).try_exists()?) } fn list( &self, kind: ObjectKind, visit: &mut dyn FnMut(ObjectKey) -> Result<(), Error>, ) -> Result<(), Error> { match kind { ObjectKind::Header => { if self.contains(&ObjectKey::Header)? { visit(ObjectKey::Header)?; } } // The flat kinds: one directory of hex-named objects each ObjectKind::Snapshot | ObjectKind::Prune | ObjectKind::Lock => { for name in dir_file_names(&self.root.join(kind.directory()))? { // Names that don't parse are not objects (stray temp // files from an interrupted put, for example) if let Some(key) = kind.key_for(&name) { visit(key)?; } } } ObjectKind::Chunk => { let chunks = self.root.join("chunks"); for prefix in dir_file_names(&chunks)? { if prefix.len() != 2 || !prefix.bytes().all(|b| b.is_ascii_hexdigit()) { continue; } for name in dir_file_names(&chunks.join(prefix))? { if let Ok(id) = ChunkId::from_hex(name) { visit(ObjectKey::Chunk(id))?; } } } } } Ok(()) } fn free_space(&self) -> Result, Error> { free_space(&self.root) } fn used_space(&self) -> Result, Error> { Ok(Some(tree_bytes(&self.root)?)) } fn delete(&self, key: &ObjectKey) -> Result<(), Error> { match std::fs::remove_file(self.object_path(key)) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(err) => Err(err.into()), } } fn local_root(&self) -> Option<&Path> { Some(&self.root) } } /// The space left on the filesystem holding `root`, as available to /// this user: reserved blocks and quotas are already deducted, which is /// what a backup wanting to leave room behind cares about. #[cfg(unix)] fn free_space(root: &Path) -> Result, Error> { let stats = rustix::fs::statvfs(root).map_err(std::io::Error::from)?; Ok(Some(stats.f_bavail.saturating_mul(stats.f_frsize))) } #[cfg(not(unix))] fn free_space(_root: &Path) -> Result, Error> { Ok(None) } /// How many bytes of file content a directory tree holds. A directory /// that vanishes mid-walk contributes nothing, which is the truth by /// the time the walk ends. fn tree_bytes(path: &Path) -> Result { let entries = match std::fs::read_dir(path) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), Err(err) => return Err(err.into()), }; let mut total = 0; for entry in entries { let entry = entry?; let metadata = match entry.metadata() { Ok(metadata) => metadata, Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, Err(err) => return Err(err.into()), }; total += if metadata.is_dir() { tree_bytes(&entry.path())? } else { metadata.len() }; } Ok(total) } /// The file names within a directory, treating a missing directory as /// empty. Names that are not valid UTF-8 cannot be object names, so they /// are omitted. fn dir_file_names(path: &Path) -> Result, Error> { let entries = match std::fs::read_dir(path) { Ok(entries) => entries, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(err) => return Err(err.into()), }; let mut names = Vec::new(); for entry in entries { if let Ok(name) = entry?.file_name().into_string() { names.push(name); } } Ok(names) } #[cfg(unix)] fn sync_dir(path: &Path) -> Result<(), Error> { File::open(path)?.sync_all()?; Ok(()) } #[cfg(not(unix))] fn sync_dir(_path: &Path) -> Result<(), Error> { Ok(()) }