Skip to main content

rho_core/
lib.rs

1// Lets the command modules (which refer to crate items as `rho_core::...`) compile
2// both here in the library and in the `rho` binary.
3extern crate self as rho_core;
4
5use std::ffi::OsStr;
6use std::fs::{self, File};
7use std::io::{self, Read};
8use std::path::{Path, PathBuf};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14pub type RhoResult<T> = Result<T, Box<dyn std::error::Error>>;
15
16pub mod commands;
17pub mod providers;
18pub mod storage;
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct RequestManifest {
22    pub version: u32,
23    pub request: RunRequest,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct RunRequest {
28    pub id: String,
29    pub from: String,
30    pub to: String,
31    pub tool_id: String,
32    pub dataset_uuid: String,
33    pub code_paths: Vec<String>,
34    pub code_sha256: String,
35    pub command: Vec<String>,
36    pub requested_tier: String,
37    pub created_at: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ApprovalManifest {
42    pub version: u32,
43    pub approval: Approval,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct Approval {
48    pub request_id: String,
49    pub decision: String,
50    pub approver: String,
51    pub note: String,
52    pub created_at: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct RunManifest {
57    pub version: u32,
58    pub run: RunRecord,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct RunRecord {
63    pub id: String,
64    pub request_id: String,
65    pub status: String,
66    pub tier: String,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub runner: Option<String>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub dataset_csv: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub code_path: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub code_sha256: Option<String>,
75    pub command: Vec<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub exit_code: Option<i32>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub error: Option<String>,
80    pub stdout_path: String,
81    pub stderr_path: String,
82    pub created_at: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct SandboxRunManifest {
87    pub version: u32,
88    pub sandbox_run: SandboxRunRecord,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct SandboxRunRecord {
93    pub id: String,
94    pub request_id: String,
95    pub runner: String,
96    pub tier: String,
97    pub dataset_csv: String,
98    pub code_path: String,
99    pub command: Vec<String>,
100    pub artifact_dir: String,
101    pub stdout_path: String,
102    pub stderr_path: String,
103    pub mounts: Vec<SandboxMount>,
104    pub network: SandboxNetworkPolicy,
105    pub created_at: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct SandboxMount {
110    pub host_path: String,
111    pub guest_path: String,
112    pub mode: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct SandboxNetworkPolicy {
117    pub default_deny: bool,
118    #[serde(default)]
119    pub allow_hosts: Vec<String>,
120    #[serde(default)]
121    pub tcp_maps: Vec<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct ControlledActionManifest {
126    pub version: u32,
127    pub kind: String,
128    pub action: ControlledAction,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct ControlledAction {
133    pub action_id: String,
134    pub request_id: String,
135    pub tool_id: String,
136    pub requested_by: String,
137    pub requested_for: String,
138    pub action_type: String,
139    pub summary: String,
140    pub reason: String,
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub input_path: Option<String>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub script_path: Option<String>,
145    pub output_path: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149pub struct ProposedActionManifest {
150    pub version: u32,
151    pub proposed_action: ProposedAction,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct ProposedAction {
156    pub action_id: String,
157    pub request_id: String,
158    pub tool_id: String,
159    pub requested_by: String,
160    pub requested_for: String,
161    pub action_type: String,
162    pub script_path: String,
163    pub output_path: String,
164    pub summary: String,
165    pub reason: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
169pub struct ToolManifest {
170    pub version: u32,
171    pub tool: Tool,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct Tool {
176    pub id: String,
177    pub action_type: String,
178    pub owner: String,
179    pub approval_required: bool,
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub command_template: Vec<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct ActionGrantManifest {
186    pub version: u32,
187    pub action_grant: ActionGrant,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct ActionGrant {
192    pub action_id: String,
193    pub request_id: String,
194    pub tool_id: String,
195    pub action_type: String,
196    pub decision: String,
197    pub granted_by: String,
198    pub created_at: String,
199    pub action: GrantedActionFile,
200    pub repo: GrantedRepoState,
201    #[serde(default)]
202    pub inputs: Vec<GrantedInput>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
206pub struct GrantedActionFile {
207    pub path: String,
208    pub sha256: String,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212pub struct GrantedRepoState {
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub git_commit: Option<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218pub struct GrantedInput {
219    pub kind: String,
220    pub path: String,
221    pub sha256: String,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct ControlledActionStatusManifest {
226    pub version: u32,
227    pub status: ControlledActionStatus,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231pub struct ControlledActionStatus {
232    pub action_id: String,
233    pub request_id: String,
234    pub status: String,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub run_id: Option<String>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub stdout_path: Option<String>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub error: Option<String>,
241    pub created_at: String,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct IdentityBundleManifest {
246    pub version: u32,
247    pub identity: IdentityBundle,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub self_signature: Option<SignatureRecord>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct IdentityBundle {
254    pub id: String,
255    pub kind: String,
256    pub handle: String,
257    pub display_name: String,
258    pub public_keys: Vec<IdentityPublicKey>,
259    pub proofs: Vec<IdentityProof>,
260    pub created_at: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct IdentityPublicKey {
265    pub id: String,
266    pub kind: String,
267    pub algorithm: String,
268    pub public_key: String,
269    pub fingerprint: String,
270    pub created_at: String,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274pub struct IdentityProof {
275    pub kind: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub provider_url: Option<String>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub claim: Option<String>,
280    pub proof_url: String,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub verified_at: Option<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286pub struct LocalIdentityManifest {
287    pub version: u32,
288    pub local_identity: LocalIdentity,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
292pub struct LocalIdentity {
293    pub identity: IdentityBundle,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub controller_key: Option<LocalControllerKey>,
296    pub signing_key: LocalSigningKey,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub encryption_key: Option<LocalEncryptionKey>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub git: Option<LocalGitIdentity>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304pub struct LocalGitIdentity {
305    pub github_login: String,
306    pub commit_name: String,
307    pub commit_email: String,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
311pub struct LocalSigningKey {
312    pub kind: String,
313    pub algorithm: String,
314    pub public_key_path: String,
315    pub private_key_ref: PrivateKeyRef,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
319pub struct LocalControllerKey {
320    pub kind: String,
321    pub algorithm: String,
322    pub public_key_path: String,
323    pub private_key_ref: PrivateKeyRef,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
327pub struct LocalEncryptionKey {
328    pub kind: String,
329    pub algorithm: String,
330    pub public_key_path: String,
331    pub private_key_ref: PrivateKeyRef,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
335pub struct PrivateKeyRef {
336    pub backend: String,
337    pub path: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
341pub struct TrustManifest {
342    pub version: u32,
343    pub trust: TrustRecord,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347pub struct TrustRecord {
348    pub identity_id: String,
349    pub decision: String,
350    pub trusted_at: String,
351    pub source: String,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
355pub struct SignatureManifest {
356    pub version: u32,
357    pub signature: SignatureRecord,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
361pub struct SignatureRecord {
362    pub signed_path: String,
363    pub signed_sha256: String,
364    pub signer: String,
365    pub key_id: String,
366    pub algorithm: String,
367    pub namespace: String,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub context: Option<SignatureContext>,
370    pub signature: String,
371    pub created_at: String,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
375pub struct SignatureContext {
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub repo_id: Option<String>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub request_id: Option<String>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub message_id: Option<String>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub recipient_id: Option<String>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub purpose: Option<String>,
386}
387
388pub fn to_yaml<T: Serialize>(value: &T) -> RhoResult<String> {
389    Ok(serde_yaml::to_string(value)?)
390}
391
392pub fn from_yaml<T: for<'de> Deserialize<'de>>(value: &str) -> RhoResult<T> {
393    Ok(serde_yaml::from_str(value)?)
394}
395
396pub fn bytes_digest(bytes: &[u8]) -> String {
397    let mut hasher = Sha256::new();
398    hasher.update(bytes);
399    hex_lower_bytes(&hasher.finalize())
400}
401
402fn hex_lower_bytes(bytes: &[u8]) -> String {
403    let mut out = String::with_capacity(bytes.len() * 2);
404    for byte in bytes {
405        out.push_str(&format!("{byte:02x}"));
406    }
407    out
408}
409
410pub fn yaml_top_level_kind(value: &str) -> Option<String> {
411    let parsed: serde_yaml::Value = serde_yaml::from_str(value).ok()?;
412    let mapping = parsed.as_mapping()?;
413    mapping
414        .get(serde_yaml::Value::String("kind".to_string()))?
415        .as_str()
416        .map(ToOwned::to_owned)
417}
418
419pub fn is_rho_encrypted_text(value: &str) -> bool {
420    matches!(
421        yaml_top_level_kind(value).as_deref(),
422        Some("rho_recipient_envelope" | "rho_transparent_file")
423    )
424}
425
426pub fn to_json_pretty<T: Serialize>(value: &T) -> RhoResult<String> {
427    Ok(serde_json::to_string_pretty(value)? + "\n")
428}
429
430pub fn from_json<T: for<'de> Deserialize<'de>>(value: &str) -> RhoResult<T> {
431    Ok(serde_json::from_str(value)?)
432}
433
434pub fn validate_actor_id(value: &str) -> RhoResult<()> {
435    if value.starts_with("rho://id/") {
436        providers::parse_identity_id(value)?;
437        return Ok(());
438    }
439    validate_simple_id(value, "actor id", true)
440}
441
442pub fn normalize_actor_id(value: &str) -> RhoResult<String> {
443    if value.starts_with("rho://id/") {
444        validate_actor_id(value)?;
445        return Ok(value.to_string());
446    }
447    if let Some(handle) = value.strip_prefix("github/") {
448        return providers::github::identity_id(handle);
449    }
450    providers::github::identity_id(value)
451}
452
453pub fn normalize_repo_id(value: &str) -> RhoResult<String> {
454    if value.starts_with("rho://repo/") {
455        validate_relative_safe_path(value.trim_start_matches("rho://repo/"))?;
456        return Ok(value.to_string());
457    }
458    let path = value
459        .strip_prefix("https://github.com/")
460        .or_else(|| value.strip_prefix("git@github.com:"))
461        .unwrap_or(value)
462        .trim_end_matches(".git")
463        .trim_matches('/');
464    let parts: Vec<&str> = path.split('/').collect();
465    if parts.len() != 2 {
466        return Err(format!(
467            "repo id must be rho://repo/..., https://github.com/owner/repo, or owner/repo: {value}"
468        )
469        .into());
470    }
471    validate_simple_id(parts[0], "github owner", true)?;
472    validate_simple_id(parts[1], "github repo", true)?;
473    Ok(format!("rho://repo/github/{}/{}", parts[0], parts[1]))
474}
475
476pub fn validate_request_id(value: &str) -> RhoResult<()> {
477    if !value.starts_with("req-") {
478        return Err(format!("request id must start with req-: {value}").into());
479    }
480    validate_simple_id(value, "request id", true)
481}
482
483pub fn validate_run_id(value: &str) -> RhoResult<()> {
484    if !value.starts_with("run-") {
485        return Err(format!("run id must start with run-: {value}").into());
486    }
487    validate_simple_id(value, "run id", true)
488}
489
490pub fn validate_action_id(value: &str) -> RhoResult<()> {
491    if !value.starts_with("act-") {
492        return Err(format!("action id must start with act-: {value}").into());
493    }
494    validate_simple_id(value, "action id", true)
495}
496
497pub fn validate_tool_id(value: &str) -> RhoResult<()> {
498    validate_simple_id(value, "tool id", true)
499}
500
501pub fn validate_action_type(value: &str) -> RhoResult<()> {
502    match value {
503        "run_mock_data" | "run_real_data" | "release_results" => Ok(()),
504        _ => Err(format!("unsupported action type: {value}").into()),
505    }
506}
507
508pub fn validate_tier(value: &str) -> RhoResult<()> {
509    match value {
510        "public" | "mock" | "real" => Ok(()),
511        _ => Err(format!("unsupported tier: {value}").into()),
512    }
513}
514
515pub fn validate_relative_safe_path(value: &str) -> RhoResult<()> {
516    if value.is_empty() || value.contains('\0') {
517        return Err("path must be non-empty and contain no NUL bytes".into());
518    }
519    let path = Path::new(value);
520    for component in path.components() {
521        match component {
522            std::path::Component::ParentDir => {
523                return Err(format!("path must not contain ..: {value}").into());
524            }
525            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
526                return Err(format!("path must be relative: {value}").into());
527            }
528            _ => {}
529        }
530    }
531    Ok(())
532}
533
534pub fn path_matches_pattern(path: &str, pattern: &str) -> bool {
535    let path = normalize_match_path(path);
536    let pattern = normalize_match_path(pattern);
537    if path.is_empty() || pattern.is_empty() {
538        return path == pattern;
539    }
540    let path_segments = path.split('/').collect::<Vec<_>>();
541    let pattern_segments = pattern.split('/').collect::<Vec<_>>();
542    match_path_segments(&path_segments, &pattern_segments)
543}
544
545fn normalize_match_path(value: &str) -> String {
546    value
547        .replace('\\', "/")
548        .trim_start_matches("./")
549        .trim_matches('/')
550        .to_string()
551}
552
553fn match_path_segments(path: &[&str], pattern: &[&str]) -> bool {
554    match pattern.split_first() {
555        None => path.is_empty(),
556        Some((first, rest)) if *first == "**" => {
557            match_path_segments(path, rest)
558                || (!path.is_empty() && match_path_segments(&path[1..], pattern))
559        }
560        Some((first, rest)) => {
561            let Some((path_first, path_rest)) = path.split_first() else {
562                return false;
563            };
564            segment_matches(path_first, first) && match_path_segments(path_rest, rest)
565        }
566    }
567}
568
569fn segment_matches(value: &str, pattern: &str) -> bool {
570    let value = value.as_bytes();
571    let pattern = pattern.as_bytes();
572    let mut value_index = 0usize;
573    let mut pattern_index = 0usize;
574    let mut star_index = None;
575    let mut value_after_star = 0usize;
576
577    while value_index < value.len() {
578        if pattern_index < pattern.len()
579            && pattern[pattern_index] != b'*'
580            && pattern[pattern_index] == value[value_index]
581        {
582            value_index += 1;
583            pattern_index += 1;
584        } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
585            star_index = Some(pattern_index);
586            pattern_index += 1;
587            value_after_star = value_index;
588        } else if let Some(star) = star_index {
589            pattern_index = star + 1;
590            value_after_star += 1;
591            value_index = value_after_star;
592        } else {
593            return false;
594        }
595    }
596
597    pattern[pattern_index..].iter().all(|byte| *byte == b'*')
598}
599
600fn validate_simple_id(value: &str, label: &str, allow_hyphen: bool) -> RhoResult<()> {
601    if value.is_empty() || value.len() > 96 {
602        return Err(format!("{label} must be 1-96 characters").into());
603    }
604    let valid = value.chars().all(|ch| {
605        ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || (allow_hyphen && ch == '-')
606    });
607    if !valid {
608        return Err(format!("{label} has invalid characters: {value}").into());
609    }
610    Ok(())
611}
612
613pub fn arg_value(args: &[String], flag: &str) -> Option<String> {
614    args.windows(2)
615        .find(|window| window[0] == flag)
616        .map(|window| window[1].clone())
617}
618
619pub fn has_flag(args: &[String], flag: &str) -> bool {
620    args.iter().any(|arg| arg == flag)
621}
622
623pub fn require_arg(args: &[String], flag: &str) -> RhoResult<String> {
624    arg_value(args, flag).ok_or_else(|| format!("missing required argument: {flag}").into())
625}
626
627pub fn ensure_parent(path: &Path) -> io::Result<()> {
628    if let Some(parent) = path.parent() {
629        fs::create_dir_all(parent)?;
630    }
631    Ok(())
632}
633
634pub fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
635    fs::create_dir_all(target)?;
636    for entry in fs::read_dir(source)? {
637        let entry = entry?;
638        let source_path = entry.path();
639        let target_path = target.join(entry.file_name());
640        let file_type = entry.file_type()?;
641        if file_type.is_dir() {
642            copy_dir_recursive(&source_path, &target_path)?;
643        } else if file_type.is_file() {
644            fs::copy(&source_path, &target_path)?;
645        }
646    }
647    Ok(())
648}
649
650pub fn remove_dir_if_exists(path: &Path) -> io::Result<()> {
651    if path.exists() {
652        fs::remove_dir_all(path)?;
653    }
654    Ok(())
655}
656
657pub fn yaml_quote(value: &str) -> String {
658    let escaped = value
659        .replace('\\', "\\\\")
660        .replace('"', "\\\"")
661        .replace('\n', "\\n")
662        .replace('\r', "");
663    format!("\"{escaped}\"")
664}
665
666pub fn now_rfc3339() -> String {
667    let seconds = SystemTime::now()
668        .duration_since(UNIX_EPOCH)
669        .unwrap_or_default()
670        .as_secs();
671    format_unix_seconds_rfc3339(seconds)
672}
673
674pub fn uuid_like() -> String {
675    let mut bytes = [0u8; 16];
676    getrandom::getrandom(&mut bytes).expect("secure random UUID generation failed");
677    bytes[6] = (bytes[6] & 0x0f) | 0x40;
678    bytes[8] = (bytes[8] & 0x3f) | 0x80;
679    format!(
680        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
681        bytes[0],
682        bytes[1],
683        bytes[2],
684        bytes[3],
685        bytes[4],
686        bytes[5],
687        bytes[6],
688        bytes[7],
689        bytes[8],
690        bytes[9],
691        bytes[10],
692        bytes[11],
693        bytes[12],
694        bytes[13],
695        bytes[14],
696        bytes[15]
697    )
698}
699
700pub fn file_digest(path: &Path) -> RhoResult<String> {
701    let mut file = File::open(path)?;
702    let mut hasher = Sha256::new();
703    let mut buffer = [0u8; 16 * 1024];
704    loop {
705        let read = file.read(&mut buffer)?;
706        if read == 0 {
707            break;
708        }
709        hasher.update(&buffer[..read]);
710    }
711    Ok(format!("{:x}", hasher.finalize()))
712}
713
714pub fn mime_type(path: &Path) -> String {
715    let Some(extension) = path
716        .extension()
717        .and_then(OsStr::to_str)
718        .map(|value| value.to_ascii_lowercase())
719    else {
720        return "application/octet-stream".to_string();
721    };
722    match extension.as_str() {
723        "csv" => "text/csv",
724        "json" => "application/json",
725        "yaml" | "yml" => "application/yaml",
726        "txt" | "md" => "text/plain",
727        "py" => "text/x-python",
728        "rs" => "text/rust",
729        "pdf" => "application/pdf",
730        "png" => "image/png",
731        "jpg" | "jpeg" => "image/jpeg",
732        "gif" => "image/gif",
733        "svg" => "image/svg+xml",
734        "zip" => "application/zip",
735        "tar" => "application/x-tar",
736        "gz" => "application/gzip",
737        "rhoenc" => "application/vnd.rho.envelope",
738        _ => "application/octet-stream",
739    }
740    .to_string()
741}
742
743fn format_unix_seconds_rfc3339(seconds: u64) -> String {
744    let days = (seconds / 86_400) as i64;
745    let seconds_of_day = seconds % 86_400;
746    let (year, month, day) = civil_from_days(days);
747    let hour = seconds_of_day / 3_600;
748    let minute = (seconds_of_day % 3_600) / 60;
749    let second = seconds_of_day % 60;
750    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
751}
752
753fn civil_from_days(days_since_unix_epoch: i64) -> (i64, u64, u64) {
754    let z = days_since_unix_epoch + 719_468;
755    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
756    let day_of_era = z - era * 146_097;
757    let year_of_era =
758        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
759    let year = year_of_era + era * 400;
760    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
761    let month_prime = (5 * day_of_year + 2) / 153;
762    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
763    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
764    let year = year + if month <= 2 { 1 } else { 0 };
765    (year, month as u64, day as u64)
766}
767
768pub fn file_name(path: &Path) -> RhoResult<String> {
769    path.file_name()
770        .and_then(OsStr::to_str)
771        .map(ToOwned::to_owned)
772        .ok_or_else(|| format!("path has no valid file name: {}", path.display()).into())
773}
774
775pub fn canonical_display(path: &Path) -> String {
776    path.canonicalize()
777        .unwrap_or_else(|_| PathBuf::from(path))
778        .display()
779        .to_string()
780}
781
782pub fn read_to_string_if_exists(path: &Path) -> io::Result<Option<String>> {
783    match fs::read_to_string(path) {
784        Ok(value) => Ok(Some(value)),
785        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
786        Err(error) => Err(error),
787    }
788}
789
790pub fn split_command_line(value: &str) -> RhoResult<Vec<String>> {
791    let mut parts = Vec::new();
792    let mut current = String::new();
793    let mut chars = value.chars().peekable();
794    let mut quote: Option<char> = None;
795
796    while let Some(ch) = chars.next() {
797        match (quote, ch) {
798            (Some(active), next) if next == active => quote = None,
799            (Some(_), '\\') => {
800                if let Some(next) = chars.next() {
801                    current.push(next);
802                }
803            }
804            (Some(_), next) => current.push(next),
805            (None, '\'' | '"') => quote = Some(ch),
806            (None, next) if next.is_whitespace() => {
807                if !current.is_empty() {
808                    parts.push(std::mem::take(&mut current));
809                }
810            }
811            (None, next) => current.push(next),
812        }
813    }
814
815    if quote.is_some() {
816        return Err("unterminated quote in command_text".into());
817    }
818    if !current.is_empty() {
819        parts.push(current);
820    }
821    if parts.is_empty() {
822        return Err("command_text is empty".into());
823    }
824    Ok(parts)
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    #[test]
832    fn split_command_line_preserves_quoted_args() {
833        let parts = split_command_line("python3 \"sum prices.py\" DATASET_CSV").unwrap();
834        assert_eq!(parts, vec!["python3", "sum prices.py", "DATASET_CSV"]);
835    }
836
837    #[test]
838    fn split_command_line_rejects_unterminated_quotes() {
839        assert!(split_command_line("python3 \"unterminated").is_err());
840    }
841
842    #[test]
843    fn validates_expected_ids() {
844        assert!(validate_actor_id("agent1").is_ok());
845        assert!(validate_request_id("req-abc-123").is_ok());
846        assert!(validate_run_id("run-real-123").is_ok());
847        assert!(validate_actor_id("test-runner").is_ok());
848        assert!(validate_actor_id("Agent1").is_err());
849        assert!(validate_request_id("abc-123").is_err());
850        assert!(validate_run_id("real-123").is_err());
851    }
852
853    #[test]
854    fn rejects_unsafe_relative_paths() {
855        assert!(validate_relative_safe_path("workspace/sum_prices.py").is_ok());
856        assert!(validate_relative_safe_path("../private/data.csv").is_err());
857        assert!(validate_relative_safe_path("/tmp/data.csv").is_err());
858        assert!(validate_relative_safe_path("").is_err());
859    }
860
861    #[test]
862    fn path_patterns_are_segment_aware_globs() {
863        assert!(path_matches_pattern(
864            "rho/messages/inbox/id/github/rho-owner/request.yaml",
865            "rho/messages/inbox/id/github/rho-owner/**"
866        ));
867        assert!(path_matches_pattern(
868            "rho/messages/inbox/id/github/rho-owner",
869            "rho/messages/inbox/id/github/rho-owner/**"
870        ));
871        assert!(path_matches_pattern(
872            "rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
873            "rho/messages/inbox/id/github/*/req-*/request.yaml"
874        ));
875        assert!(path_matches_pattern(
876            "./rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
877            "rho/messages/**/request.*"
878        ));
879        assert!(!path_matches_pattern(
880            "rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
881            "rho/messages/inbox/id/github/*/request.yaml"
882        ));
883        assert!(!path_matches_pattern(
884            "rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
885            "rho/messages/inbox/id/github/rho-owner/req-124/**"
886        ));
887        assert!(!path_matches_pattern(
888            "rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
889            "rho/messages/inbox/id/github/rho-*"
890        ));
891    }
892
893    #[test]
894    fn file_digest_uses_sha256() {
895        let path = std::env::temp_dir().join(format!("rho-file-digest-{}.txt", uuid_like()));
896        fs::write(&path, b"abc").unwrap();
897        let digest = file_digest(&path).unwrap();
898        fs::remove_file(&path).unwrap();
899
900        assert_eq!(
901            digest,
902            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
903        );
904        assert!(!digest.starts_with("fallback-noncryptographic-"));
905    }
906
907    #[test]
908    fn formats_unix_seconds_as_rfc3339_utc() {
909        assert_eq!(format_unix_seconds_rfc3339(0), "1970-01-01T00:00:00Z");
910        assert_eq!(
911            format_unix_seconds_rfc3339(946_684_800),
912            "2000-01-01T00:00:00Z"
913        );
914        assert_eq!(
915            format_unix_seconds_rfc3339(1_609_459_200),
916            "2021-01-01T00:00:00Z"
917        );
918    }
919
920    #[test]
921    fn uuid_like_generates_uuid_v4_shape() {
922        let value = uuid_like();
923        assert_eq!(value.len(), 36);
924        assert_eq!(&value[8..9], "-");
925        assert_eq!(&value[13..14], "-");
926        assert_eq!(&value[18..19], "-");
927        assert_eq!(&value[23..24], "-");
928        assert_eq!(&value[14..15], "4");
929        assert!(matches!(&value[19..20], "8" | "9" | "a" | "b"));
930        assert!(value.chars().all(|ch| ch.is_ascii_hexdigit() || ch == '-'));
931    }
932
933    #[test]
934    fn mime_type_uses_deterministic_extension_mapping() {
935        assert_eq!(mime_type(Path::new("prices.csv")), "text/csv");
936        assert_eq!(mime_type(Path::new("manifest.yaml")), "application/yaml");
937        assert_eq!(mime_type(Path::new("report.pdf")), "application/pdf");
938        assert_eq!(
939            mime_type(Path::new("unknown.nope")),
940            "application/octet-stream"
941        );
942    }
943
944    #[test]
945    fn encrypted_envelope_detection_requires_top_level_yaml_kind() {
946        assert!(is_rho_encrypted_text(
947            "version: 1\nkind: rho_recipient_envelope\n"
948        ));
949        assert!(is_rho_encrypted_text(
950            "version: 1\nkind: rho_transparent_file\n"
951        ));
952        assert!(!is_rho_encrypted_text(
953            "message:\n  kind: rho_recipient_envelope\n"
954        ));
955        assert!(!is_rho_encrypted_text(
956            "not yaml: [\nkind: rho_recipient_envelope\n"
957        ));
958    }
959
960    #[test]
961    fn request_manifest_round_trips_as_yaml() {
962        let manifest = RequestManifest {
963            version: 1,
964            request: RunRequest {
965                id: "req-abc-123".to_string(),
966                from: "agent2".to_string(),
967                to: "agent1".to_string(),
968                tool_id: "run_real".to_string(),
969                dataset_uuid: "11111111-2222-3333-4444-555555555555".to_string(),
970                code_paths: vec!["sandbox/two-console/shared/workspace/sum_prices.py".to_string()],
971                code_sha256: "abc123".to_string(),
972                command: vec![
973                    "python3".to_string(),
974                    "sum_prices.py".to_string(),
975                    "DATASET_CSV".to_string(),
976                ],
977                requested_tier: "real".to_string(),
978                created_at: "2026-04-30T00:00:00Z".to_string(),
979            },
980        };
981
982        let yaml = to_yaml(&manifest).unwrap();
983        assert!(yaml.contains("command:"));
984        assert!(!yaml.contains("command_text"));
985        let parsed: RequestManifest = from_yaml(&yaml).unwrap();
986        assert_eq!(parsed, manifest);
987    }
988
989    #[test]
990    fn run_manifest_omits_absent_optional_fields() {
991        let manifest = RunManifest {
992            version: 1,
993            run: RunRecord {
994                id: "run-blocked-123".to_string(),
995                request_id: "req-abc-123".to_string(),
996                status: "blocked".to_string(),
997                tier: "real".to_string(),
998                runner: None,
999                dataset_csv: None,
1000                code_path: Some("sandbox/two-console/shared/workspace/sum_prices.py".to_string()),
1001                code_sha256: None,
1002                command: vec!["python3".to_string()],
1003                exit_code: None,
1004                error: Some("requires approval".to_string()),
1005                stdout_path: "sandbox/two-console/shared/.rho/runs/run-blocked-123/stdout.txt"
1006                    .to_string(),
1007                stderr_path: "sandbox/two-console/shared/.rho/runs/run-blocked-123/stderr.txt"
1008                    .to_string(),
1009                created_at: "2026-04-30T00:00:00Z".to_string(),
1010            },
1011        };
1012
1013        let yaml = to_yaml(&manifest).unwrap();
1014        assert!(!yaml.contains("dataset_csv"));
1015        assert!(!yaml.contains("runner"));
1016        assert!(!yaml.contains("exit_code"));
1017        assert!(yaml.contains("requires approval"));
1018    }
1019
1020    #[test]
1021    fn sandbox_run_manifest_round_trips_as_yaml() {
1022        let manifest = SandboxRunManifest {
1023            version: 1,
1024            sandbox_run: SandboxRunRecord {
1025                id: "run-run-mock".to_string(),
1026                request_id: "req-mock-123".to_string(),
1027                runner: "local".to_string(),
1028                tier: "mock".to_string(),
1029                dataset_csv: "sandbox/two-console/shared/datasets/id/mock/prices.csv".to_string(),
1030                code_path: "sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
1031                command: vec![
1032                    "python3".to_string(),
1033                    "sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
1034                    "sandbox/two-console/shared/datasets/id/mock/prices.csv".to_string(),
1035                ],
1036                artifact_dir: "sandbox/two-console/shared/.rho/runs/run-run-mock".to_string(),
1037                stdout_path: "sandbox/two-console/shared/.rho/runs/run-run-mock/stdout.txt"
1038                    .to_string(),
1039                stderr_path: "sandbox/two-console/shared/.rho/runs/run-run-mock/stderr.txt"
1040                    .to_string(),
1041                mounts: vec![SandboxMount {
1042                    host_path: "sandbox/two-console/shared/workspace".to_string(),
1043                    guest_path: "/workspace".to_string(),
1044                    mode: "ro".to_string(),
1045                }],
1046                network: SandboxNetworkPolicy {
1047                    default_deny: true,
1048                    allow_hosts: vec![],
1049                    tcp_maps: vec![],
1050                },
1051                created_at: "2026-04-30T00:00:00Z".to_string(),
1052            },
1053        };
1054
1055        let yaml = to_yaml(&manifest).unwrap();
1056        assert!(yaml.contains("sandbox_run:"));
1057        assert!(yaml.contains("runner: local"));
1058        let parsed: SandboxRunManifest = from_yaml(&yaml).unwrap();
1059        assert_eq!(parsed, manifest);
1060    }
1061
1062    #[test]
1063    fn controlled_action_round_trips_as_json() {
1064        let manifest = ControlledActionManifest {
1065            version: 1,
1066            kind: "controlled_action".to_string(),
1067            action: ControlledAction {
1068                action_id: "act-run-real".to_string(),
1069                request_id: "req-abc-123".to_string(),
1070                tool_id: "run_real".to_string(),
1071                requested_by: "agent2".to_string(),
1072                requested_for: "agent1".to_string(),
1073                action_type: "run_real_data".to_string(),
1074                summary: "Run approved script".to_string(),
1075                reason: "Need aggregate".to_string(),
1076                input_path: None,
1077                script_path: Some("sandbox/two-console/shared/workspace/sum_prices.py".to_string()),
1078                output_path: "sandbox/two-console/shared/.rho/runs/run-act-run-real/stdout.txt"
1079                    .to_string(),
1080            },
1081        };
1082
1083        let json = to_json_pretty(&manifest).unwrap();
1084        assert!(json.contains("\"controlled_action\""));
1085        let parsed: ControlledActionManifest = from_json(&json).unwrap();
1086        assert_eq!(parsed, manifest);
1087    }
1088
1089    #[test]
1090    fn validates_action_ids_and_types() {
1091        assert!(validate_action_id("act-run-real").is_ok());
1092        assert!(validate_action_id("run-real").is_err());
1093        assert!(validate_tool_id("run_real").is_ok());
1094        assert!(validate_action_type("run_real_data").is_ok());
1095        assert!(validate_action_type("run_mock_data").is_ok());
1096        assert!(validate_action_type("delete_private_data").is_err());
1097    }
1098
1099    #[test]
1100    fn tool_manifest_round_trips_as_yaml() {
1101        let manifest = ToolManifest {
1102            version: 1,
1103            tool: Tool {
1104                id: "run_real".to_string(),
1105                action_type: "run_real_data".to_string(),
1106                owner: "agent1".to_string(),
1107                approval_required: true,
1108                command_template: vec![
1109                    "python3".to_string(),
1110                    "CODE_PATH".to_string(),
1111                    "DATASET_CSV".to_string(),
1112                ],
1113            },
1114        };
1115        let yaml = to_yaml(&manifest).unwrap();
1116        assert!(yaml.contains("run_real_data"));
1117        assert!(yaml.contains("command_template"));
1118        let parsed: ToolManifest = from_yaml(&yaml).unwrap();
1119        assert_eq!(parsed, manifest);
1120    }
1121
1122    #[test]
1123    fn action_grant_manifest_round_trips_as_yaml() {
1124        let manifest = ActionGrantManifest {
1125            version: 1,
1126            action_grant: ActionGrant {
1127                action_id: "act-run-real".to_string(),
1128                request_id: "req-abc-123".to_string(),
1129                tool_id: "run_real".to_string(),
1130                action_type: "run_real_data".to_string(),
1131                decision: "approved".to_string(),
1132                granted_by: "agent1".to_string(),
1133                created_at: "2026-04-30T00:00:00Z".to_string(),
1134                action: GrantedActionFile {
1135                    path: "sandbox/two-console/control/outbox/act-run-real.json".to_string(),
1136                    sha256: "action-sha".to_string(),
1137                },
1138                repo: GrantedRepoState {
1139                    git_commit: Some("commit-sha".to_string()),
1140                },
1141                inputs: vec![GrantedInput {
1142                    kind: "code".to_string(),
1143                    path: "sandbox/two-console/shared/workspace/sum_prices.py".to_string(),
1144                    sha256: "input-sha".to_string(),
1145                }],
1146            },
1147        };
1148
1149        let yaml = to_yaml(&manifest).unwrap();
1150        assert!(yaml.contains("action_grant:"));
1151        assert!(yaml.contains("action-sha"));
1152        assert!(yaml.contains("input-sha"));
1153        let parsed: ActionGrantManifest = from_yaml(&yaml).unwrap();
1154        assert_eq!(parsed, manifest);
1155    }
1156
1157    #[test]
1158    fn github_provider_parses_standard_and_alias_remotes() {
1159        use crate::providers::github::repo_candidate_from_remote_with_host_resolver;
1160
1161        assert_eq!(
1162            repo_candidate_from_remote_with_host_resolver(
1163                "git@github.com:madhavajay/rho-live.git",
1164                |_| false,
1165            ),
1166            Some("madhavajay/rho-live".to_string())
1167        );
1168        assert_eq!(
1169            repo_candidate_from_remote_with_host_resolver(
1170                "git@github-madhavajay:madhavajay/rho-live.git",
1171                |host| host == "github-madhavajay",
1172            ),
1173            Some("madhavajay/rho-live".to_string())
1174        );
1175        assert_eq!(
1176            repo_candidate_from_remote_with_host_resolver(
1177                "git@example.com:madhavajay/rho-live.git",
1178                |_| false,
1179            ),
1180            None
1181        );
1182    }
1183}