local.rs raw

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<PathBuf>) -> Result<LocalBackend, Error> {
        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<Option<Vec<u8>>, 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<bool, Error> {
        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<Option<u64>, Error> {
        free_space(&self.root)
    }

    fn used_space(&self) -> Result<Option<u64>, 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<Option<u64>, 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<Option<u64>, 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<u64, Error> {
    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<Vec<String>, 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(())
}