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;
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}