rho-cli 0.1.27

Rho CLI tools for encrypted agent collaboration, dataset publishing, controlled runs, and result release workflows
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use crate::{RhoResult, bytes_digest, ensure_parent, file_digest, validate_relative_safe_path};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageEntry {
    pub path: String,
    pub digest: String,
    pub bytes: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StateDigest(pub String);

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProposalId(pub String);

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RhoSpace {
    pub id: String,
    pub owner: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub members: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub storage: Vec<StorageLocator>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub transfer: Vec<TransferLocator>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageLocator {
    pub kind: String,
    pub uri: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TransferLocator {
    pub kind: String,
    pub uri: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetManifest {
    pub version: u32,
    pub change_set: ChangeSet,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSet {
    pub id: String,
    pub space_id: String,
    pub author: String,
    pub base_state: StateDigest,
    pub created_at: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<ChangeSetFile>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub signatures: Vec<ChangeSetSignature>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetFile {
    pub path: String,
    pub op: ChangeSetOp,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha256: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_sha256: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub encrypted: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChangeSetOp {
    Upsert,
    Delete,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetSignature {
    pub signer: String,
    pub key_id: String,
    pub algorithm: String,
    pub signature: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedChangeSet {
    pub id: String,
    pub bytes: Vec<u8>,
}

impl SignedChangeSet {
    pub fn from_manifest(manifest: &ChangeSetManifest) -> RhoResult<Self> {
        Ok(Self {
            id: manifest.change_set.id.clone(),
            bytes: serde_yaml::to_string(manifest)?.into_bytes(),
        })
    }

    pub fn manifest(&self) -> RhoResult<ChangeSetManifest> {
        Ok(serde_yaml::from_slice(&self.bytes)?)
    }
}

pub trait StorageBackend {
    fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>>;
    fn put(
        &self,
        path: &str,
        bytes: &[u8],
        expected_previous_digest: Option<&str>,
    ) -> RhoResult<String>;
    fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>>;
    fn head(&self, space_id: &str) -> RhoResult<StateDigest>;
    fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId>;
    fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet>;
}

#[derive(Debug, Clone)]
pub struct LocalFsStorage {
    root: PathBuf,
}

impl LocalFsStorage {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    fn path(&self, relative: &str) -> RhoResult<PathBuf> {
        validate_relative_safe_path(relative)?;
        Ok(self.root.join(relative))
    }
}

impl StorageBackend for LocalFsStorage {
    fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>> {
        Ok(fs::read(self.path(path_or_hash)?)?)
    }

    fn put(
        &self,
        path: &str,
        bytes: &[u8],
        expected_previous_digest: Option<&str>,
    ) -> RhoResult<String> {
        let target = self.path(path)?;
        if let Some(expected) = expected_previous_digest {
            let actual = if target.is_file() {
                file_digest(&target)?
            } else {
                String::new()
            };
            if actual != expected {
                return Err(format!(
                    "previous digest mismatch for {path}: expected {expected}, got {actual}"
                )
                .into());
            }
        }
        ensure_parent(&target)?;
        fs::write(&target, bytes)?;
        file_digest(&target)
    }

    fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>> {
        let base = self.path(prefix)?;
        let mut entries = Vec::new();
        if !base.exists() {
            return Ok(entries);
        }
        collect_entries(&self.root, &base, &mut entries)?;
        entries.sort_by(|a, b| a.path.cmp(&b.path));
        Ok(entries)
    }

    fn head(&self, space_id: &str) -> RhoResult<StateDigest> {
        let head_path = format!("rho/heads/{}.txt", metadata_key(space_id));
        let text = fs::read_to_string(self.path(&head_path)?)?;
        Ok(StateDigest(text.trim().to_string()))
    }

    fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId> {
        validate_relative_safe_path(&change_set.id)?;
        let path = format!("rho/proposals/{}.yaml", change_set.id);
        self.put(&path, &change_set.bytes, None)?;
        Ok(ProposalId(change_set.id.clone()))
    }

    fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet> {
        validate_relative_safe_path(&id.0)?;
        let path = format!("rho/proposals/{}.yaml", id.0);
        Ok(SignedChangeSet {
            id: id.0.clone(),
            bytes: self.get(&path)?,
        })
    }
}

fn collect_entries(root: &Path, dir: &Path, entries: &mut Vec<StorageEntry>) -> RhoResult<()> {
    if dir.is_file() {
        let relative = dir
            .strip_prefix(root)?
            .to_string_lossy()
            .replace(std::path::MAIN_SEPARATOR, "/");
        entries.push(StorageEntry {
            path: relative,
            digest: file_digest(dir)?,
            bytes: fs::metadata(dir)?.len(),
        });
        return Ok(());
    }
    for entry in fs::read_dir(dir)? {
        collect_entries(root, &entry?.path(), entries)?;
    }
    Ok(())
}

fn metadata_key(value: &str) -> String {
    bytes_digest(value.as_bytes())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn serializes_signed_change_set_manifest() {
        let manifest = ChangeSetManifest {
            version: 1,
            change_set: ChangeSet {
                id: "chg-1".to_string(),
                space_id: "rho://space/github/example/project".to_string(),
                author: "rho://id/nostr/npub1abc".to_string(),
                base_state: StateDigest("sha256:base".to_string()),
                created_at: "2026-06-16T00:00:00Z".to_string(),
                message: Some("test".to_string()),
                files: vec![ChangeSetFile {
                    path: "README.md".to_string(),
                    op: ChangeSetOp::Upsert,
                    sha256: Some("sha256:file".to_string()),
                    previous_sha256: None,
                    bytes: Some(12),
                    encrypted: Some(false),
                }],
                signatures: Vec::new(),
            },
        };

        let signed = SignedChangeSet::from_manifest(&manifest).unwrap();
        assert_eq!(signed.id, "chg-1");
        assert_eq!(signed.manifest().unwrap(), manifest);
    }

    #[test]
    fn local_storage_puts_lists_and_fetches_proposals() {
        let root = temp_root("rho-storage-test");
        let storage = LocalFsStorage::new(&root);
        let digest = storage.put("docs/a.txt", b"hello", None).unwrap();

        assert_eq!(storage.get("docs/a.txt").unwrap(), b"hello");
        assert_eq!(storage.list("docs").unwrap()[0].digest, digest);

        let proposal = SignedChangeSet {
            id: "chg-2".to_string(),
            bytes: b"version: 1\n".to_vec(),
        };
        let id = storage.propose(&proposal).unwrap();
        assert_eq!(storage.fetch_proposal(&id).unwrap(), proposal);

        let _ = fs::remove_dir_all(root);
    }

    fn temp_root(prefix: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("{prefix}-{nonce}"))
    }
}