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