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