verdant-cache-runtime 0.4.2

Live cache runtime for the verdant agent-loop cache: content-addressed payload store + DDG
Documentation
use crate::cas::{CasError, ManifestEntry, TreeBlobStore, TreeManifest};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::{fs, io};

#[derive(Debug, thiserror::Error)]
pub enum TreeError {
    #[error("io error: {0}")]
    Io(#[from] io::Error),
    #[error("cas error: {0}")]
    Cas(#[from] CasError),
    #[error("blob missing for {path}")]
    BlobMissing { path: PathBuf },
}

pub fn capture_tree(blobs: &TreeBlobStore, root: &Path) -> Result<TreeManifest, TreeError> {
    let mut entries: Vec<ManifestEntry> = Vec::new();
    collect_entries(blobs, root, root, &mut entries)?;
    entries.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(TreeManifest::new(entries, vec![]))
}

fn collect_entries(
    blobs: &TreeBlobStore,
    root: &Path,
    dir: &Path,
    entries: &mut Vec<ManifestEntry>,
) -> Result<(), TreeError> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let file_type = entry.file_type()?;

        if file_type.is_symlink() {
            // symlinks out of scope for M4
            continue;
        }

        if file_type.is_dir() {
            collect_entries(blobs, root, &path, entries)?;
        } else if file_type.is_file() {
            let metadata = fs::metadata(&path)?;
            let content = fs::read(&path)?;
            let key = blobs.put_file_blob(&content)?;
            let mode = (metadata.mode() & 0o7777) as u32;
            let mtime_secs = metadata.mtime();
            let mtime_nanos = metadata.mtime_nsec() as u32;
            let rel = path
                .strip_prefix(root)
                .expect("path must be under root")
                .to_path_buf();
            entries.push(ManifestEntry::entry_for(
                rel,
                key,
                mode,
                (mtime_secs, mtime_nanos),
            ));
        }
    }
    Ok(())
}

pub fn restore_tree(
    blobs: &TreeBlobStore,
    manifest: &TreeManifest,
    dest: &Path,
) -> Result<(), TreeError> {
    for entry in &manifest.entries {
        let target = dest.join(&entry.path);
        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)?;
        }

        let bytes =
            blobs
                .get_file_blob(&entry.content_hash)?
                .ok_or_else(|| TreeError::BlobMissing {
                    path: entry.path.clone(),
                })?;

        fs::write(&target, &bytes)?;

        // Set permissions before mtime so the permission change doesn't bump mtime.
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(&target, fs::Permissions::from_mode(entry.mode))?;

        // Set mtime after writing and after permissions, because both operations
        // would otherwise reset it.
        let ft = filetime::FileTime::from_unix_time(entry.mtime_secs, entry.mtime_nanos);
        filetime::set_file_mtime(&target, ft)?;
    }

    for deleted in &manifest.deleted {
        let target = dest.join(deleted);
        if target.exists() {
            fs::remove_file(&target)?;
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cas::TreeBlobStore;
    use crate::store::FileStore;
    use filetime::FileTime;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn setup_store() -> (TempDir, FileStore) {
        let dir = TempDir::new().unwrap();
        let store = FileStore::open(dir.path()).unwrap();
        (dir, store)
    }

    #[test]
    fn round_trip_fidelity() {
        let (_store_dir, store) = setup_store();
        let blobs = TreeBlobStore::new(&store);

        let src_dir = TempDir::new().unwrap();
        let nested = src_dir.path().join("sub/dir");
        fs::create_dir_all(&nested).unwrap();

        let file_a = src_dir.path().join("top.txt");
        fs::write(&file_a, b"hello top").unwrap();

        let file_b = nested.join("deep.bin");
        fs::write(&file_b, b"\x00\x01\x02\x03").unwrap();

        // Set a precise mtime with non-zero nanoseconds on file_a.
        let known_mtime = FileTime::from_unix_time(1_700_000_000, 123_456_789);
        filetime::set_file_mtime(&file_a, known_mtime).unwrap();

        // Capture permissions before capture so we can verify restoration.
        use std::os::unix::fs::PermissionsExt;
        let mode_a = fs::metadata(&file_a).unwrap().permissions().mode() & 0o7777;
        let mode_b = fs::metadata(&file_b).unwrap().permissions().mode() & 0o7777;
        let mtime_b = {
            use std::os::unix::fs::MetadataExt;
            let md = fs::metadata(&file_b).unwrap();
            FileTime::from_unix_time(md.mtime(), md.mtime_nsec() as u32)
        };

        let manifest = capture_tree(&blobs, src_dir.path()).unwrap();
        assert_eq!(manifest.entries.len(), 2);
        assert!(manifest.deleted.is_empty());

        let dest_dir = TempDir::new().unwrap();
        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();

        // Verify contents.
        let restored_a = fs::read(dest_dir.path().join("top.txt")).unwrap();
        assert_eq!(restored_a, b"hello top");
        let restored_b = fs::read(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
        assert_eq!(restored_b, b"\x00\x01\x02\x03");

        // Verify permissions.
        let restored_mode_a = fs::metadata(dest_dir.path().join("top.txt"))
            .unwrap()
            .permissions()
            .mode()
            & 0o7777;
        let restored_mode_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin"))
            .unwrap()
            .permissions()
            .mode()
            & 0o7777;
        assert_eq!(restored_mode_a, mode_a);
        assert_eq!(restored_mode_b, mode_b);

        // Verify mtimes including nanoseconds.
        use std::os::unix::fs::MetadataExt;
        let md_a = fs::metadata(dest_dir.path().join("top.txt")).unwrap();
        assert_eq!(md_a.mtime(), 1_700_000_000);
        assert_eq!(md_a.mtime_nsec() as u32, 123_456_789);

        let md_b = fs::metadata(dest_dir.path().join("sub/dir/deep.bin")).unwrap();
        assert_eq!(
            FileTime::from_unix_time(md_b.mtime(), md_b.mtime_nsec() as u32),
            mtime_b
        );
    }

    #[test]
    fn deletion_removes_file_from_dest() {
        let (_store_dir, store) = setup_store();
        let blobs = TreeBlobStore::new(&store);

        let dest_dir = TempDir::new().unwrap();
        let target = dest_dir.path().join("to_delete.txt");
        fs::write(&target, b"present").unwrap();
        assert!(target.exists());

        let manifest = TreeManifest::new(vec![], vec![PathBuf::from("to_delete.txt")]);
        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();

        assert!(!target.exists(), "deleted path must be removed from dest");
    }

    #[test]
    fn missing_blob_returns_error() {
        let (_store_dir, store) = setup_store();
        let blobs = TreeBlobStore::new(&store);
        let dest_dir = TempDir::new().unwrap();

        let phantom_key = crate::store::Key::from_bytes(b"never-stored");
        let entry =
            ManifestEntry::entry_for(PathBuf::from("ghost.txt"), phantom_key, 0o644, (0, 0));
        let manifest = TreeManifest::new(vec![entry], vec![]);
        let err = restore_tree(&blobs, &manifest, dest_dir.path())
            .expect_err("must error on missing blob");
        assert!(
            matches!(err, TreeError::BlobMissing { .. }),
            "unexpected error variant: {err:?}"
        );
    }
}