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