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