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