1extern 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}