verdant-cache-runtime 0.4.8

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 },
    #[error("restore path escapes destination root: {path}")]
    PathEscape { 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> {
    // restore may target a dest that does not exist yet (a build output dir
    // wiped before replay), so create it before canonicalizing for the
    // symlink bounds check below.
    fs::create_dir_all(dest)?;
    let dest_canonical = dest.canonicalize()?;
    for entry in &manifest.entries {
        let target = dest.join(&entry.path);
        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)?;
        }

        // A pre-existing symlink at the destination would be followed by
        // fs::write, letting a replayed write escape the project root.
        // safety: refuse to write through a symlink whose real path leaves
        // the destination root (or is broken); a legitimate capture never
        // targets a symlink because overlayfs copies up the link's target,
        // not the link itself.
        if let Ok(meta) = fs::symlink_metadata(&target) {
            if meta.file_type().is_symlink() {
                let resolved = target.canonicalize().map_err(|_| TreeError::PathEscape {
                    path: entry.path.clone(),
                })?;
                if !resolved.starts_with(&dest_canonical) {
                    return Err(TreeError::PathEscape {
                        path: entry.path.clone(),
                    });
                }
            }
        }

        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 restore_refuses_symlink_escaping_root() {
        let (_store_dir, store) = setup_store();
        let blobs = TreeBlobStore::new(&store);

        let outside = TempDir::new().unwrap();
        let victim = outside.path().join("victim.txt");
        fs::write(&victim, b"original").unwrap();

        let dest_dir = TempDir::new().unwrap();
        let link = dest_dir.path().join("link");
        std::os::unix::fs::symlink(&victim, &link).unwrap();

        let key = blobs.put_file_blob(b"pwned").unwrap();
        let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
        let manifest = TreeManifest::new(vec![entry], vec![]);

        let err = restore_tree(&blobs, &manifest, dest_dir.path())
            .expect_err("must refuse a symlink escaping root");
        assert!(
            matches!(err, TreeError::PathEscape { .. }),
            "unexpected error variant: {err:?}"
        );
        assert_eq!(fs::read(&victim).unwrap(), b"original", "victim untouched");
    }

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

        let dest_dir = TempDir::new().unwrap();
        let real = dest_dir.path().join("real.txt");
        fs::write(&real, b"original").unwrap();
        let link = dest_dir.path().join("link");
        std::os::unix::fs::symlink(&real, &link).unwrap();

        let key = blobs.put_file_blob(b"updated").unwrap();
        let entry = ManifestEntry::entry_for(PathBuf::from("link"), key, 0o644, (0, 0));
        let manifest = TreeManifest::new(vec![entry], vec![]);

        restore_tree(&blobs, &manifest, dest_dir.path()).unwrap();
        assert_eq!(fs::read(&real).unwrap(), b"updated");
    }

    #[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:?}"
        );
    }
}