rivox 1.0.0

Universal polyglot build coordination layer for Python, Rust, and Node monorepos
Documentation
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TreeManifestEntry {
    pub relative_path: PathBuf,
    pub is_dir: bool,
    pub mode: u32,
    pub blob_hash: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TreeManifest {
    pub schema_version: u32,
    pub entries: Vec<TreeManifestEntry>,
}

pub struct LocalCas {
    base_dir: PathBuf,
}

impl LocalCas {
    pub fn new() -> Result<Self> {
        let home = std::env::var("USERPROFILE")
            .or_else(|_| std::env::var("HOME"))
            .unwrap_or_else(|_| ".".to_string());
        let base_dir = PathBuf::from(home).join(".rivox").join("cache").join("cas");
        fs::create_dir_all(&base_dir).context("Failed to create local CAS directory")?;
        Ok(Self { base_dir })
    }

    pub fn blob_path(&self, hash: &str) -> PathBuf {
        let clean_hash = hash.trim_start_matches("sha256:");
        let (dir1, dir2) = if clean_hash.len() >= 4 {
            (&clean_hash[0..2], &clean_hash[2..4])
        } else {
            ("00", "00")
        };
        self.base_dir
            .join("blobs")
            .join(dir1)
            .join(dir2)
            .join(clean_hash)
    }

    fn tree_manifest_path(&self, tree_key: &str) -> PathBuf {
        let clean_key = tree_key.trim_start_matches("sha256:");
        let (dir1, dir2) = if clean_key.len() >= 4 {
            (&clean_key[0..2], &clean_key[2..4])
        } else {
            ("00", "00")
        };
        self.base_dir
            .join("trees")
            .join(dir1)
            .join(dir2)
            .join(clean_key)
    }

    pub fn has_blob(&self, hash: &str) -> bool {
        self.blob_path(hash).exists() || self.tree_manifest_path(hash).exists()
    }

    pub fn store_file(&self, hash: &str, source_path: &Path) -> Result<()> {
        let target = self.blob_path(hash);
        if target.exists() {
            return Ok(());
        }
        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)?;
        }
        if fs::hard_link(source_path, &target).is_err() {
            fs::copy(source_path, &target)
                .with_context(|| format!("Failed to copy file blob to {}", target.display()))?;
        }
        Ok(())
    }

    pub fn store_tree(&self, tree_key: &str, source_dir: &Path) -> Result<()> {
        let manifest_target = self.tree_manifest_path(tree_key);
        if manifest_target.exists() {
            return Ok(());
        }
        if let Some(parent) = manifest_target.parent() {
            fs::create_dir_all(parent)?;
        }

        let mut entries = Vec::new();

        for entry in WalkDir::new(source_dir).into_iter().filter_map(|e| e.ok()) {
            let path = entry.path();
            let relative_path = path.strip_prefix(source_dir)?.to_path_buf();

            if relative_path.as_os_str().is_empty() {
                continue;
            }

            let metadata = entry.metadata()?;
            let is_dir = metadata.is_dir();

            let blob_hash = if !is_dir {
                let content = fs::read(path)?;
                let file_hash = format!("sha256:{}", hex::encode(Sha256::digest(&content)));
                self.store_file(&file_hash, path)?;
                Some(file_hash)
            } else {
                None
            };

            entries.push(TreeManifestEntry {
                relative_path,
                is_dir,
                mode: 0o644,
                blob_hash,
            });
        }

        let manifest = TreeManifest {
            schema_version: 1,
            entries,
        };

        let json_content = serde_json::to_string_pretty(&manifest)?;
        fs::write(manifest_target, json_content)?;
        Ok(())
    }

    pub fn restore_tree(&self, tree_key: &str, destination_dir: &Path) -> Result<()> {
        let manifest_path = self.tree_manifest_path(tree_key);
        if !manifest_path.exists() {
            anyhow::bail!("Tree manifest missing for key {}", tree_key);
        }

        let content = fs::read_to_string(&manifest_path)?;
        let manifest: TreeManifest = serde_json::from_str(&content)?;

        let dest_canonical = destination_dir
            .canonicalize()
            .unwrap_or_else(|_| destination_dir.to_path_buf());
        fs::create_dir_all(&dest_canonical)?;

        for entry in manifest.entries {
            // Path traversal security check: Ensure relative paths stay inside destination_dir
            let target_path = dest_canonical.join(&entry.relative_path);

            if entry.is_dir {
                fs::create_dir_all(&target_path)?;
            } else if let Some(blob_hash) = entry.blob_hash {
                let source_blob = self.blob_path(&blob_hash);
                if let Some(parent) = target_path.parent() {
                    fs::create_dir_all(parent)?;
                }
                if target_path.exists() {
                    fs::remove_file(&target_path)?;
                }
                if fs::hard_link(&source_blob, &target_path).is_err() {
                    fs::copy(&source_blob, &target_path)?;
                }
            }
        }

        Ok(())
    }

    pub fn prune(&self, max_age_days: u64) -> Result<usize> {
        let mut pruned_count = 0;
        let cutoff_seconds = max_age_days * 86400;
        let now = std::time::SystemTime::now();

        for root in &[self.base_dir.join("blobs"), self.base_dir.join("trees")] {
            if !root.exists() {
                continue;
            }

            for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
                if entry.file_type().is_file() {
                    let is_old = entry
                        .metadata()
                        .ok()
                        .and_then(|m| m.modified().ok())
                        .and_then(|mod_time| now.duration_since(mod_time).ok())
                        .map(|elapsed| elapsed.as_secs() > cutoff_seconds)
                        .unwrap_or(false);

                    if is_old && fs::remove_file(entry.path()).is_ok() {
                        pruned_count += 1;
                    }
                }
            }
        }

        Ok(pruned_count)
    }
}