rho-cli 0.1.25

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

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

pub mod github;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityLocator {
    pub provider: String,
    pub handle: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RhoIdentity {
    pub id: String,
    pub provider: String,
    pub handle: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub keys: Vec<IdentityKey>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub proofs: Vec<IdentityProof>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub discovery: Vec<DiscoveryLocator>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityKey {
    pub id: String,
    pub kind: IdentityKeyKind,
    pub algorithm: String,
    pub public_key: String,
    pub fingerprint: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum IdentityKeyKind {
    Signing,
    Encryption,
    Transport,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityProof {
    pub kind: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub claim: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub proof_url: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub verified_at: Option<String>,
}

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

pub trait IdentityProvider {
    fn provider(&self) -> &'static str;
    fn validate_handle(&self, handle: &str) -> RhoResult<()>;

    fn identity_id(&self, handle: &str) -> RhoResult<String> {
        self.validate_handle(handle)?;
        Ok(format!("rho://id/{}/{}", self.provider(), handle))
    }

    fn handle_from_identity_id(&self, identity_id: &str) -> RhoResult<String> {
        let locator = parse_identity_id(identity_id)?;
        if locator.provider != self.provider() {
            return Err(format!(
                "unsupported identity provider for {}: {}",
                self.provider(),
                identity_id
            )
            .into());
        }
        self.validate_handle(&locator.handle)?;
        Ok(locator.handle)
    }

    fn identity(&self, handle: &str) -> RhoResult<RhoIdentity> {
        self.validate_handle(handle)?;
        Ok(RhoIdentity {
            id: self.identity_id(handle)?,
            provider: self.provider().to_string(),
            handle: handle.to_string(),
            display_name: None,
            keys: Vec::new(),
            proofs: Vec::new(),
            discovery: Vec::new(),
        })
    }
}

pub fn parse_identity_id(identity_id: &str) -> RhoResult<IdentityLocator> {
    let Some(rest) = identity_id.strip_prefix("rho://id/") else {
        return Err(format!("identity id must start with rho://id/: {identity_id}").into());
    };
    let Some((provider, handle)) = rest.split_once('/') else {
        return Err(format!("identity id must include provider and handle: {identity_id}").into());
    };
    if provider.is_empty() || handle.is_empty() || handle.contains('/') {
        return Err(format!("invalid identity id: {identity_id}").into());
    }
    validate_relative_safe_path(provider)?;
    validate_relative_safe_path(handle)?;
    Ok(IdentityLocator {
        provider: provider.to_string(),
        handle: handle.to_string(),
    })
}

pub fn identity_inbox_relative_path(identity_id: &str) -> RhoResult<PathBuf> {
    let locator = parse_identity_id(identity_id)?;
    Ok(PathBuf::from("id")
        .join(locator.provider)
        .join(locator.handle))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Debug, Clone, Copy)]
    struct TestProvider;

    impl IdentityProvider for TestProvider {
        fn provider(&self) -> &'static str {
            "test"
        }

        fn validate_handle(&self, handle: &str) -> RhoResult<()> {
            if handle.is_empty() || handle.contains('/') {
                return Err("invalid handle".into());
            }
            Ok(())
        }
    }

    #[test]
    fn builds_provider_neutral_identity() {
        let identity = TestProvider.identity("alice").unwrap();
        assert_eq!(identity.id, "rho://id/test/alice");
        assert_eq!(identity.provider, "test");
        assert!(identity.keys.is_empty());
    }

    #[test]
    fn parses_identity_locator() {
        let locator = parse_identity_id("rho://id/nostr/npub1abc").unwrap();
        assert_eq!(locator.provider, "nostr");
        assert_eq!(locator.handle, "npub1abc");
    }
}