extern crate self as rho_core;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub type RhoResult<T> = Result<T, Box<dyn std::error::Error>>;
pub mod commands;
pub mod providers;
pub mod storage;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RequestManifest {
pub version: u32,
pub request: RunRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunRequest {
pub id: String,
pub from: String,
pub to: String,
pub tool_id: String,
pub dataset_uuid: String,
pub code_paths: Vec<String>,
pub code_sha256: String,
pub command: Vec<String>,
pub requested_tier: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApprovalManifest {
pub version: u32,
pub approval: Approval,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Approval {
pub request_id: String,
pub decision: String,
pub approver: String,
pub note: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunManifest {
pub version: u32,
pub run: RunRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RunRecord {
pub id: String,
pub request_id: String,
pub status: String,
pub tier: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub runner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dataset_csv: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_sha256: Option<String>,
pub command: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub stdout_path: String,
pub stderr_path: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxRunManifest {
pub version: u32,
pub sandbox_run: SandboxRunRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxRunRecord {
pub id: String,
pub request_id: String,
pub runner: String,
pub tier: String,
pub dataset_csv: String,
pub code_path: String,
pub command: Vec<String>,
pub artifact_dir: String,
pub stdout_path: String,
pub stderr_path: String,
pub mounts: Vec<SandboxMount>,
pub network: SandboxNetworkPolicy,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxMount {
pub host_path: String,
pub guest_path: String,
pub mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SandboxNetworkPolicy {
pub default_deny: bool,
#[serde(default)]
pub allow_hosts: Vec<String>,
#[serde(default)]
pub tcp_maps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlledActionManifest {
pub version: u32,
pub kind: String,
pub action: ControlledAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlledAction {
pub action_id: String,
pub request_id: String,
pub tool_id: String,
pub requested_by: String,
pub requested_for: String,
pub action_type: String,
pub summary: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script_path: Option<String>,
pub output_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProposedActionManifest {
pub version: u32,
pub proposed_action: ProposedAction,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProposedAction {
pub action_id: String,
pub request_id: String,
pub tool_id: String,
pub requested_by: String,
pub requested_for: String,
pub action_type: String,
pub script_path: String,
pub output_path: String,
pub summary: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ToolManifest {
pub version: u32,
pub tool: Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Tool {
pub id: String,
pub action_type: String,
pub owner: String,
pub approval_required: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_template: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ActionGrantManifest {
pub version: u32,
pub action_grant: ActionGrant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ActionGrant {
pub action_id: String,
pub request_id: String,
pub tool_id: String,
pub action_type: String,
pub decision: String,
pub granted_by: String,
pub created_at: String,
pub action: GrantedActionFile,
pub repo: GrantedRepoState,
#[serde(default)]
pub inputs: Vec<GrantedInput>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrantedActionFile {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrantedRepoState {
#[serde(skip_serializing_if = "Option::is_none")]
pub git_commit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrantedInput {
pub kind: String,
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlledActionStatusManifest {
pub version: u32,
pub status: ControlledActionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlledActionStatus {
pub action_id: String,
pub request_id: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stdout_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityBundleManifest {
pub version: u32,
pub identity: IdentityBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub self_signature: Option<SignatureRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityBundle {
pub id: String,
pub kind: String,
pub handle: String,
pub display_name: String,
pub public_keys: Vec<IdentityPublicKey>,
pub proofs: Vec<IdentityProof>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityPublicKey {
pub id: String,
pub kind: String,
pub algorithm: String,
pub public_key: String,
pub fingerprint: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IdentityProof {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claim: Option<String>,
pub proof_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub verified_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalIdentityManifest {
pub version: u32,
pub local_identity: LocalIdentity,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalIdentity {
pub identity: IdentityBundle,
pub signing_key: LocalSigningKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encryption_key: Option<LocalEncryptionKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<LocalGitIdentity>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalGitIdentity {
pub github_login: String,
pub commit_name: String,
pub commit_email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalSigningKey {
pub kind: String,
pub algorithm: String,
pub public_key_path: String,
pub private_key_ref: PrivateKeyRef,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LocalEncryptionKey {
pub kind: String,
pub algorithm: String,
pub public_key_path: String,
pub private_key_ref: PrivateKeyRef,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrivateKeyRef {
pub backend: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustManifest {
pub version: u32,
pub trust: TrustRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustRecord {
pub identity_id: String,
pub decision: String,
pub trusted_at: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignatureManifest {
pub version: u32,
pub signature: SignatureRecord,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignatureRecord {
pub signed_path: String,
pub signed_sha256: String,
pub signer: String,
pub key_id: String,
pub algorithm: String,
pub namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<SignatureContext>,
pub signature: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignatureContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recipient_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<String>,
}
pub fn to_yaml<T: Serialize>(value: &T) -> RhoResult<String> {
Ok(serde_yaml::to_string(value)?)
}
pub fn from_yaml<T: for<'de> Deserialize<'de>>(value: &str) -> RhoResult<T> {
Ok(serde_yaml::from_str(value)?)
}
pub fn bytes_digest(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex_lower_bytes(&hasher.finalize())
}
fn hex_lower_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub fn yaml_top_level_kind(value: &str) -> Option<String> {
let parsed: serde_yaml::Value = serde_yaml::from_str(value).ok()?;
let mapping = parsed.as_mapping()?;
mapping
.get(serde_yaml::Value::String("kind".to_string()))?
.as_str()
.map(ToOwned::to_owned)
}
pub fn is_rho_encrypted_text(value: &str) -> bool {
matches!(
yaml_top_level_kind(value).as_deref(),
Some("rho_recipient_envelope" | "rho_transparent_file")
)
}
pub fn to_json_pretty<T: Serialize>(value: &T) -> RhoResult<String> {
Ok(serde_json::to_string_pretty(value)? + "\n")
}
pub fn from_json<T: for<'de> Deserialize<'de>>(value: &str) -> RhoResult<T> {
Ok(serde_json::from_str(value)?)
}
pub fn validate_actor_id(value: &str) -> RhoResult<()> {
if value.starts_with("rho://id/") {
providers::parse_identity_id(value)?;
return Ok(());
}
validate_simple_id(value, "actor id", true)
}
pub fn normalize_actor_id(value: &str) -> RhoResult<String> {
if value.starts_with("rho://id/") {
validate_actor_id(value)?;
return Ok(value.to_string());
}
if let Some(handle) = value.strip_prefix("github/") {
return providers::github::identity_id(handle);
}
providers::github::identity_id(value)
}
pub fn normalize_repo_id(value: &str) -> RhoResult<String> {
if value.starts_with("rho://repo/") {
validate_relative_safe_path(value.trim_start_matches("rho://repo/"))?;
return Ok(value.to_string());
}
let path = value
.strip_prefix("https://github.com/")
.or_else(|| value.strip_prefix("git@github.com:"))
.unwrap_or(value)
.trim_end_matches(".git")
.trim_matches('/');
let parts: Vec<&str> = path.split('/').collect();
if parts.len() != 2 {
return Err(format!(
"repo id must be rho://repo/..., https://github.com/owner/repo, or owner/repo: {value}"
)
.into());
}
validate_simple_id(parts[0], "github owner", true)?;
validate_simple_id(parts[1], "github repo", true)?;
Ok(format!("rho://repo/github/{}/{}", parts[0], parts[1]))
}
pub fn validate_request_id(value: &str) -> RhoResult<()> {
if !value.starts_with("req-") {
return Err(format!("request id must start with req-: {value}").into());
}
validate_simple_id(value, "request id", true)
}
pub fn validate_run_id(value: &str) -> RhoResult<()> {
if !value.starts_with("run-") {
return Err(format!("run id must start with run-: {value}").into());
}
validate_simple_id(value, "run id", true)
}
pub fn validate_action_id(value: &str) -> RhoResult<()> {
if !value.starts_with("act-") {
return Err(format!("action id must start with act-: {value}").into());
}
validate_simple_id(value, "action id", true)
}
pub fn validate_tool_id(value: &str) -> RhoResult<()> {
validate_simple_id(value, "tool id", true)
}
pub fn validate_action_type(value: &str) -> RhoResult<()> {
match value {
"run_mock_data" | "run_real_data" | "release_results" => Ok(()),
_ => Err(format!("unsupported action type: {value}").into()),
}
}
pub fn validate_tier(value: &str) -> RhoResult<()> {
match value {
"public" | "mock" | "real" => Ok(()),
_ => Err(format!("unsupported tier: {value}").into()),
}
}
pub fn validate_relative_safe_path(value: &str) -> RhoResult<()> {
if value.is_empty() || value.contains('\0') {
return Err("path must be non-empty and contain no NUL bytes".into());
}
let path = Path::new(value);
for component in path.components() {
match component {
std::path::Component::ParentDir => {
return Err(format!("path must not contain ..: {value}").into());
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(format!("path must be relative: {value}").into());
}
_ => {}
}
}
Ok(())
}
pub fn path_matches_pattern(path: &str, pattern: &str) -> bool {
let path = normalize_match_path(path);
let pattern = normalize_match_path(pattern);
if path.is_empty() || pattern.is_empty() {
return path == pattern;
}
let path_segments = path.split('/').collect::<Vec<_>>();
let pattern_segments = pattern.split('/').collect::<Vec<_>>();
match_path_segments(&path_segments, &pattern_segments)
}
fn normalize_match_path(value: &str) -> String {
value
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string()
}
fn match_path_segments(path: &[&str], pattern: &[&str]) -> bool {
match pattern.split_first() {
None => path.is_empty(),
Some((first, rest)) if *first == "**" => {
match_path_segments(path, rest)
|| (!path.is_empty() && match_path_segments(&path[1..], pattern))
}
Some((first, rest)) => {
let Some((path_first, path_rest)) = path.split_first() else {
return false;
};
segment_matches(path_first, first) && match_path_segments(path_rest, rest)
}
}
}
fn segment_matches(value: &str, pattern: &str) -> bool {
let value = value.as_bytes();
let pattern = pattern.as_bytes();
let mut value_index = 0usize;
let mut pattern_index = 0usize;
let mut star_index = None;
let mut value_after_star = 0usize;
while value_index < value.len() {
if pattern_index < pattern.len()
&& pattern[pattern_index] != b'*'
&& pattern[pattern_index] == value[value_index]
{
value_index += 1;
pattern_index += 1;
} else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
star_index = Some(pattern_index);
pattern_index += 1;
value_after_star = value_index;
} else if let Some(star) = star_index {
pattern_index = star + 1;
value_after_star += 1;
value_index = value_after_star;
} else {
return false;
}
}
pattern[pattern_index..].iter().all(|byte| *byte == b'*')
}
fn validate_simple_id(value: &str, label: &str, allow_hyphen: bool) -> RhoResult<()> {
if value.is_empty() || value.len() > 96 {
return Err(format!("{label} must be 1-96 characters").into());
}
let valid = value.chars().all(|ch| {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || (allow_hyphen && ch == '-')
});
if !valid {
return Err(format!("{label} has invalid characters: {value}").into());
}
Ok(())
}
pub fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.windows(2)
.find(|window| window[0] == flag)
.map(|window| window[1].clone())
}
pub fn has_flag(args: &[String], flag: &str) -> bool {
args.iter().any(|arg| arg == flag)
}
pub fn require_arg(args: &[String], flag: &str) -> RhoResult<String> {
arg_value(args, flag).ok_or_else(|| format!("missing required argument: {flag}").into())
}
pub fn ensure_parent(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
Ok(())
}
pub fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)?;
}
}
Ok(())
}
pub fn remove_dir_if_exists(path: &Path) -> io::Result<()> {
if path.exists() {
fs::remove_dir_all(path)?;
}
Ok(())
}
pub fn yaml_quote(value: &str) -> String {
let escaped = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "");
format!("\"{escaped}\"")
}
pub fn now_rfc3339() -> String {
let seconds = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format_unix_seconds_rfc3339(seconds)
}
pub fn uuid_like() -> String {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes).expect("secure random UUID generation failed");
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0],
bytes[1],
bytes[2],
bytes[3],
bytes[4],
bytes[5],
bytes[6],
bytes[7],
bytes[8],
bytes[9],
bytes[10],
bytes[11],
bytes[12],
bytes[13],
bytes[14],
bytes[15]
)
}
pub fn file_digest(path: &Path) -> RhoResult<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 16 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn mime_type(path: &Path) -> String {
let Some(extension) = path
.extension()
.and_then(OsStr::to_str)
.map(|value| value.to_ascii_lowercase())
else {
return "application/octet-stream".to_string();
};
match extension.as_str() {
"csv" => "text/csv",
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"txt" | "md" => "text/plain",
"py" => "text/x-python",
"rs" => "text/rust",
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"zip" => "application/zip",
"tar" => "application/x-tar",
"gz" => "application/gzip",
"rhoenc" => "application/vnd.rho.envelope",
_ => "application/octet-stream",
}
.to_string()
}
fn format_unix_seconds_rfc3339(seconds: u64) -> String {
let days = (seconds / 86_400) as i64;
let seconds_of_day = seconds % 86_400;
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days_since_unix_epoch: i64) -> (i64, u64, u64) {
let z = days_since_unix_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
let year = year + if month <= 2 { 1 } else { 0 };
(year, month as u64, day as u64)
}
pub fn file_name(path: &Path) -> RhoResult<String> {
path.file_name()
.and_then(OsStr::to_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("path has no valid file name: {}", path.display()).into())
}
pub fn canonical_display(path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| PathBuf::from(path))
.display()
.to_string()
}
pub fn read_to_string_if_exists(path: &Path) -> io::Result<Option<String>> {
match fs::read_to_string(path) {
Ok(value) => Ok(Some(value)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub fn split_command_line(value: &str) -> RhoResult<Vec<String>> {
let mut parts = Vec::new();
let mut current = String::new();
let mut chars = value.chars().peekable();
let mut quote: Option<char> = None;
while let Some(ch) = chars.next() {
match (quote, ch) {
(Some(active), next) if next == active => quote = None,
(Some(_), '\\') => {
if let Some(next) = chars.next() {
current.push(next);
}
}
(Some(_), next) => current.push(next),
(None, '\'' | '"') => quote = Some(ch),
(None, next) if next.is_whitespace() => {
if !current.is_empty() {
parts.push(std::mem::take(&mut current));
}
}
(None, next) => current.push(next),
}
}
if quote.is_some() {
return Err("unterminated quote in command_text".into());
}
if !current.is_empty() {
parts.push(current);
}
if parts.is_empty() {
return Err("command_text is empty".into());
}
Ok(parts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_command_line_preserves_quoted_args() {
let parts = split_command_line("python3 \"sum prices.py\" DATASET_CSV").unwrap();
assert_eq!(parts, vec!["python3", "sum prices.py", "DATASET_CSV"]);
}
#[test]
fn split_command_line_rejects_unterminated_quotes() {
assert!(split_command_line("python3 \"unterminated").is_err());
}
#[test]
fn validates_expected_ids() {
assert!(validate_actor_id("agent1").is_ok());
assert!(validate_request_id("req-abc-123").is_ok());
assert!(validate_run_id("run-real-123").is_ok());
assert!(validate_actor_id("test-runner").is_ok());
assert!(validate_actor_id("Agent1").is_err());
assert!(validate_request_id("abc-123").is_err());
assert!(validate_run_id("real-123").is_err());
}
#[test]
fn rejects_unsafe_relative_paths() {
assert!(validate_relative_safe_path("workspace/sum_prices.py").is_ok());
assert!(validate_relative_safe_path("../private/data.csv").is_err());
assert!(validate_relative_safe_path("/tmp/data.csv").is_err());
assert!(validate_relative_safe_path("").is_err());
}
#[test]
fn path_patterns_are_segment_aware_globs() {
assert!(path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/request.yaml",
"rho/messages/inbox/id/github/rho-owner/**"
));
assert!(path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner",
"rho/messages/inbox/id/github/rho-owner/**"
));
assert!(path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
"rho/messages/inbox/id/github/*/req-*/request.yaml"
));
assert!(path_matches_pattern(
"./rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
"rho/messages/**/request.*"
));
assert!(!path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
"rho/messages/inbox/id/github/*/request.yaml"
));
assert!(!path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
"rho/messages/inbox/id/github/rho-owner/req-124/**"
));
assert!(!path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
"rho/messages/inbox/id/github/rho-*"
));
}
#[test]
fn file_digest_uses_sha256() {
let path = std::env::temp_dir().join(format!("rho-file-digest-{}.txt", uuid_like()));
fs::write(&path, b"abc").unwrap();
let digest = file_digest(&path).unwrap();
fs::remove_file(&path).unwrap();
assert_eq!(
digest,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert!(!digest.starts_with("fallback-noncryptographic-"));
}
#[test]
fn formats_unix_seconds_as_rfc3339_utc() {
assert_eq!(format_unix_seconds_rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(
format_unix_seconds_rfc3339(946_684_800),
"2000-01-01T00:00:00Z"
);
assert_eq!(
format_unix_seconds_rfc3339(1_609_459_200),
"2021-01-01T00:00:00Z"
);
}
#[test]
fn uuid_like_generates_uuid_v4_shape() {
let value = uuid_like();
assert_eq!(value.len(), 36);
assert_eq!(&value[8..9], "-");
assert_eq!(&value[13..14], "-");
assert_eq!(&value[18..19], "-");
assert_eq!(&value[23..24], "-");
assert_eq!(&value[14..15], "4");
assert!(matches!(&value[19..20], "8" | "9" | "a" | "b"));
assert!(value.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'));
}
#[test]
fn mime_type_uses_deterministic_extension_mapping() {
assert_eq!(mime_type(Path::new("prices.csv")), "text/csv");
assert_eq!(mime_type(Path::new("manifest.yaml")), "application/yaml");
assert_eq!(mime_type(Path::new("report.pdf")), "application/pdf");
assert_eq!(
mime_type(Path::new("unknown.nope")),
"application/octet-stream"
);
}
#[test]
fn encrypted_envelope_detection_requires_top_level_yaml_kind() {
assert!(is_rho_encrypted_text(
"version: 1\nkind: rho_recipient_envelope\n"
));
assert!(is_rho_encrypted_text(
"version: 1\nkind: rho_transparent_file\n"
));
assert!(!is_rho_encrypted_text(
"message:\n kind: rho_recipient_envelope\n"
));
assert!(!is_rho_encrypted_text(
"not yaml: [\nkind: rho_recipient_envelope\n"
));
}
#[test]
fn request_manifest_round_trips_as_yaml() {
let manifest = RequestManifest {
version: 1,
request: RunRequest {
id: "req-abc-123".to_string(),
from: "agent2".to_string(),
to: "agent1".to_string(),
tool_id: "run_real".to_string(),
dataset_uuid: "11111111-2222-3333-4444-555555555555".to_string(),
code_paths: vec!["sandbox/two-console/shared/workspace/sum_prices.py".to_string()],
code_sha256: "abc123".to_string(),
command: vec![
"python3".to_string(),
"sum_prices.py".to_string(),
"DATASET_CSV".to_string(),
],
requested_tier: "real".to_string(),
created_at: "2026-04-30T00:00:00Z".to_string(),
},
};
let yaml = to_yaml(&manifest).unwrap();
assert!(yaml.contains("command:"));
assert!(!yaml.contains("command_text"));
let parsed: RequestManifest = from_yaml(&yaml).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn run_manifest_omits_absent_optional_fields() {
let manifest = RunManifest {
version: 1,
run: RunRecord {
id: "run-blocked-123".to_string(),
request_id: "req-abc-123".to_string(),
status: "blocked".to_string(),
tier: "real".to_string(),
runner: None,
dataset_csv: None,
code_path: Some("sandbox/two-console/shared/workspace/sum_prices.py".to_string()),
code_sha256: None,
command: vec!["python3".to_string()],
exit_code: None,
error: Some("requires approval".to_string()),
stdout_path: "sandbox/two-console/shared/.rho/runs/run-blocked-123/stdout.txt"
.to_string(),
stderr_path: "sandbox/two-console/shared/.rho/runs/run-blocked-123/stderr.txt"
.to_string(),
created_at: "2026-04-30T00:00:00Z".to_string(),
},
};
let yaml = to_yaml(&manifest).unwrap();
assert!(!yaml.contains("dataset_csv"));
assert!(!yaml.contains("runner"));
assert!(!yaml.contains("exit_code"));
assert!(yaml.contains("requires approval"));
}
#[test]
fn sandbox_run_manifest_round_trips_as_yaml() {
let manifest = SandboxRunManifest {
version: 1,
sandbox_run: SandboxRunRecord {
id: "run-run-mock".to_string(),
request_id: "req-mock-123".to_string(),
runner: "local".to_string(),
tier: "mock".to_string(),
dataset_csv: "sandbox/two-console/shared/datasets/id/mock/prices.csv".to_string(),
code_path: "sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
command: vec![
"python3".to_string(),
"sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
"sandbox/two-console/shared/datasets/id/mock/prices.csv".to_string(),
],
artifact_dir: "sandbox/two-console/shared/.rho/runs/run-run-mock".to_string(),
stdout_path: "sandbox/two-console/shared/.rho/runs/run-run-mock/stdout.txt"
.to_string(),
stderr_path: "sandbox/two-console/shared/.rho/runs/run-run-mock/stderr.txt"
.to_string(),
mounts: vec![SandboxMount {
host_path: "sandbox/two-console/shared/workspace".to_string(),
guest_path: "/workspace".to_string(),
mode: "ro".to_string(),
}],
network: SandboxNetworkPolicy {
default_deny: true,
allow_hosts: vec![],
tcp_maps: vec![],
},
created_at: "2026-04-30T00:00:00Z".to_string(),
},
};
let yaml = to_yaml(&manifest).unwrap();
assert!(yaml.contains("sandbox_run:"));
assert!(yaml.contains("runner: local"));
let parsed: SandboxRunManifest = from_yaml(&yaml).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn controlled_action_round_trips_as_json() {
let manifest = ControlledActionManifest {
version: 1,
kind: "controlled_action".to_string(),
action: ControlledAction {
action_id: "act-run-real".to_string(),
request_id: "req-abc-123".to_string(),
tool_id: "run_real".to_string(),
requested_by: "agent2".to_string(),
requested_for: "agent1".to_string(),
action_type: "run_real_data".to_string(),
summary: "Run approved script".to_string(),
reason: "Need aggregate".to_string(),
input_path: None,
script_path: Some("sandbox/two-console/shared/workspace/sum_prices.py".to_string()),
output_path: "sandbox/two-console/shared/.rho/runs/run-act-run-real/stdout.txt"
.to_string(),
},
};
let json = to_json_pretty(&manifest).unwrap();
assert!(json.contains("\"controlled_action\""));
let parsed: ControlledActionManifest = from_json(&json).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn validates_action_ids_and_types() {
assert!(validate_action_id("act-run-real").is_ok());
assert!(validate_action_id("run-real").is_err());
assert!(validate_tool_id("run_real").is_ok());
assert!(validate_action_type("run_real_data").is_ok());
assert!(validate_action_type("run_mock_data").is_ok());
assert!(validate_action_type("delete_private_data").is_err());
}
#[test]
fn tool_manifest_round_trips_as_yaml() {
let manifest = ToolManifest {
version: 1,
tool: Tool {
id: "run_real".to_string(),
action_type: "run_real_data".to_string(),
owner: "agent1".to_string(),
approval_required: true,
command_template: vec![
"python3".to_string(),
"CODE_PATH".to_string(),
"DATASET_CSV".to_string(),
],
},
};
let yaml = to_yaml(&manifest).unwrap();
assert!(yaml.contains("run_real_data"));
assert!(yaml.contains("command_template"));
let parsed: ToolManifest = from_yaml(&yaml).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn action_grant_manifest_round_trips_as_yaml() {
let manifest = ActionGrantManifest {
version: 1,
action_grant: ActionGrant {
action_id: "act-run-real".to_string(),
request_id: "req-abc-123".to_string(),
tool_id: "run_real".to_string(),
action_type: "run_real_data".to_string(),
decision: "approved".to_string(),
granted_by: "agent1".to_string(),
created_at: "2026-04-30T00:00:00Z".to_string(),
action: GrantedActionFile {
path: "sandbox/two-console/control/outbox/act-run-real.json".to_string(),
sha256: "action-sha".to_string(),
},
repo: GrantedRepoState {
git_commit: Some("commit-sha".to_string()),
},
inputs: vec![GrantedInput {
kind: "code".to_string(),
path: "sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
sha256: "input-sha".to_string(),
}],
},
};
let yaml = to_yaml(&manifest).unwrap();
assert!(yaml.contains("action_grant:"));
assert!(yaml.contains("action-sha"));
assert!(yaml.contains("input-sha"));
let parsed: ActionGrantManifest = from_yaml(&yaml).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn github_provider_parses_standard_and_alias_remotes() {
use crate::providers::github::repo_candidate_from_remote_with_host_resolver;
assert_eq!(
repo_candidate_from_remote_with_host_resolver(
"git@github.com:madhavajay/rho-live.git",
|_| false,
),
Some("madhavajay/rho-live".to_string())
);
assert_eq!(
repo_candidate_from_remote_with_host_resolver(
"git@github-madhavajay:madhavajay/rho-live.git",
|host| host == "github-madhavajay",
),
Some("madhavajay/rho-live".to_string())
);
assert_eq!(
repo_candidate_from_remote_with_host_resolver(
"git@example.com:madhavajay/rho-live.git",
|_| false,
),
None
);
}
}