use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use rho_core::{
RhoResult, RunManifest, ensure_parent, file_digest, from_json, from_yaml, has_flag,
is_rho_encrypted_text, normalize_actor_id, normalize_repo_id, providers, require_arg,
validate_request_id, yaml_quote,
};
use serde::Deserialize;
use sha2::{Digest, Sha256};
fn usage() -> ! {
eprintln!(
"usage:\n rho result publish --shared-root <path> --request-id <id> --result-id <id> --from <id> --to <id> --artifact <path> [--text <text>]\n rho result release <req-id> --run-id <id> --to <id> [--from <id>] [--root <repo>] [--output <path>|--value <text>] [--inline-max-bytes <n>] [--runner <name>] [--no-branch] [--no-stage] [--allow-missing-request] [--commit] [--push] [--pr]\n rho result release-pr <pr-number> --run-id <id> --to <id> [--request-id <id>] [--root <repo>] [--output <path>|--value <text>] [--inline-max-bytes <n>] [--runner <name>] [--no-sync] [--commit] [--push] [--pr]\n rho result verify <req-id> [--root <repo>] [--to <id>]"
);
std::process::exit(2);
}
const DEFAULT_INLINE_RESULT_MAX_BYTES: usize = 4096;
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
match command {
"publish" => publish(&args[1..]),
"release" => release(&args[1..]),
"release-pr" => release_pr(&args[1..]),
"verify" => verify(&args[1..]),
_ => usage(),
}
}
fn publish(rest: &[String]) -> RhoResult<()> {
let shared_root = PathBuf::from(require_arg(rest, "--shared-root").unwrap_or_else(|_| usage()));
let request_id = require_arg(rest, "--request-id").unwrap_or_else(|_| usage());
let result_id = require_arg(rest, "--result-id").unwrap_or_else(|_| usage());
let from = normalize_actor_id(&require_arg(rest, "--from").unwrap_or_else(|_| usage()))?;
let to = normalize_actor_id(&require_arg(rest, "--to").unwrap_or_else(|_| usage()))?;
let artifact = require_arg(rest, "--artifact").unwrap_or_else(|_| usage());
let text = rho_core::arg_value(rest, "--text")
.unwrap_or_else(|| format!("Result released for {request_id}."));
let manifest = shared_root
.join(".rho/results")
.join(format!("{request_id}.yaml"));
ensure_parent(&manifest)?;
fs::write(
&manifest,
format!(
"version: 1\nresult:\n id: {}\n request_id: {}\n from: {}\n to: {}\n status: \"released\"\n artifact_paths:\n - {}\n",
yaml_quote(&result_id),
yaml_quote(&request_id),
yaml_quote(&from),
yaml_quote(&to),
yaml_quote(&artifact),
),
)?;
let inbox = shared_root
.join(".rho/inbox")
.join(&to)
.join(format!("result-{request_id}.yaml"));
ensure_parent(&inbox)?;
fs::write(
&inbox,
format!(
"version: 1\nmessage:\n id: {}\n from: {}\n to: {}\n type: \"result_available\"\n related_request_id: {}\n body:\n text: {}\n result_manifest: {}\n",
yaml_quote(&format!("result-{request_id}")),
yaml_quote(&from),
yaml_quote(&to),
yaml_quote(&request_id),
yaml_quote(&text),
yaml_quote(&manifest.display().to_string()),
),
)?;
println!("{}", manifest.display());
Ok(())
}
fn release(rest: &[String]) -> RhoResult<()> {
let request_id = rest.first().cloned().unwrap_or_else(|| usage());
validate_request_id(&request_id)?;
let root = rho_core::arg_value(rest, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let run_id = require_arg(rest, "--run-id").unwrap_or_else(|_| usage());
let to = normalize_actor_id(&require_arg(rest, "--to").unwrap_or_else(|_| usage()))?;
let from = rho_core::arg_value(rest, "--from")
.map(|value| normalize_actor_id(&value))
.transpose()?
.or_else(active_identity)
.ok_or("missing --from and RHO_IDENTITY is not set")?;
let runner = rho_core::arg_value(rest, "--runner").unwrap_or_else(|| "unknown".to_string());
let repo_id = read_repo_id(&root)?;
if !has_flag(rest, "--allow-missing-request") {
require_committed_request(&root, &request_id)?;
}
let from_handle = github_handle_from_identity_id(&from)?;
if !has_flag(rest, "--no-branch") {
switch_branch(&root, &format!("{from_handle}/result-{request_id}"))?;
}
let release_output = read_release_output(&root, &run_id, rest)?;
preflight_run_input_commitment(&root, &request_id, &run_id)?;
let result_dir = root
.join("rho/messages/inbox")
.join(identity_inbox_relative_path(&to)?)
.join(&request_id);
let result_path = result_dir.join("result.yaml");
let signature_path = result_dir.join("result.rhosig.yaml");
let receipt_path = root
.join("rho/run-receipts")
.join(&request_id)
.join(format!("{run_id}.yaml"));
let receipt_signature_path = root
.join("rho/run-receipts")
.join(&request_id)
.join(format!("{run_id}.rhosig.yaml"));
ensure_parent(&result_path)?;
let (output_yaml, attachment_path) = match &release_output.payload {
ReleasePayload::Inline { value } => (
format!(
" output: {}\n output_sha256: {}\n",
yaml_quote(value),
yaml_quote(&release_output.output_sha256),
),
None,
),
ReleasePayload::Attachment { file_name, bytes } => {
let attachment_relative = format!("attachments/{file_name}");
let attachment_path = result_dir.join(&attachment_relative);
ensure_parent(&attachment_path)?;
fs::write(&attachment_path, bytes)?;
(
format!(
" output_sha256: {}\n attachments:\n - path: {}\n original_name: {}\n sha256: {}\n",
yaml_quote(&release_output.output_sha256),
yaml_quote(&attachment_relative),
yaml_quote(file_name),
yaml_quote(&release_output.output_sha256),
),
Some(attachment_path),
)
}
};
fs::write(
&result_path,
format!(
"version: 1\nresult:\n request_id: {}\n from: {}\n to: {}\n{} run_id: {}\n runner: {}\n note: \"encrypted result released from approved run\"\n",
yaml_quote(&request_id),
yaml_quote(&from),
yaml_quote(&to),
output_yaml,
yaml_quote(&run_id),
yaml_quote(&runner),
),
)?;
sign_file_with_context(SignatureWrite {
signed_path: &result_path,
signature_path: &signature_path,
identity: &from,
repo_id: &repo_id,
request_id: &request_id,
recipient: &to,
purpose: "rho.result",
})?;
let wrote_receipt = write_run_receipt_if_available(RunReceiptWrite {
root: &root,
repo_id: &repo_id,
request_id: &request_id,
run_id: &run_id,
runner: &runner,
result_path: &result_path,
receipt_path: &receipt_path,
receipt_signature_path: &receipt_signature_path,
output_sha256: &release_output.output_sha256,
signer: &from,
recipient: &to,
})?;
if !has_flag(rest, "--no-stage") {
if let Some(path) = &attachment_path {
git_add_with_identity(&root, path, Some(&from))?;
verify_staged_encrypted(&root, path)?;
}
git_add_with_identity(&root, &result_path, Some(&from))?;
git_add_with_identity(&root, &signature_path, Some(&from))?;
verify_staged_encrypted(&root, &result_path)?;
verify_staged_encrypted(&root, &signature_path)?;
println!("staged encrypted result files");
if wrote_receipt {
git_add(&root, &receipt_path)?;
git_add(&root, &receipt_signature_path)?;
println!("staged signed run receipt");
}
}
println!("{}", result_path.display());
if let Some(path) = &attachment_path {
println!("{}", path.display());
}
println!("{}", signature_path.display());
if wrote_receipt {
println!("{}", receipt_path.display());
println!("{}", receipt_signature_path.display());
}
println!("result branch: {from_handle}/result-{request_id}");
let mut release_paths = Vec::new();
if let Some(path) = &attachment_path {
release_paths.push(path.as_path());
}
release_paths.push(result_path.as_path());
release_paths.push(signature_path.as_path());
if wrote_receipt {
release_paths.push(receipt_path.as_path());
release_paths.push(receipt_signature_path.as_path());
}
finish_branch_work(rest, &root, &request_id, &release_paths)?;
println!("next: rho commit -m \"Release encrypted result for {request_id}\"");
print_create_pr_next_step(
&root,
&format!("Release encrypted result for {request_id}"),
&format!("Release signed encrypted result {request_id} to {to}."),
);
Ok(())
}
fn release_pr(rest: &[String]) -> RhoResult<()> {
let pr_number = rest.first().cloned().unwrap_or_else(|| usage());
let root = rho_core::arg_value(rest, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
if !has_flag(rest, "--no-sync") {
run_rho_command(&[
"repo".to_string(),
"sync".to_string(),
"--root".to_string(),
root.display().to_string(),
])?;
}
let pr = gh_pr_metadata(&root, &pr_number)?;
gh_pr_checkout(&root, &pr_number)?;
let request_id = match rho_core::arg_value(rest, "--request-id") {
Some(request_id) => request_id,
None => find_single_repo_request_id(&root)?,
};
validate_request_id(&request_id)?;
let want_pr = has_flag(rest, "--pr");
let want_commit = want_pr || has_flag(rest, "--commit");
let want_push = want_pr || has_flag(rest, "--push");
let mut release_args = vec![
request_id.clone(),
"--root".to_string(),
root.display().to_string(),
"--no-branch".to_string(),
];
for flag in [
"--run-id",
"--to",
"--from",
"--output",
"--value",
"--inline-max-bytes",
"--runner",
] {
if let Some(value) = rho_core::arg_value(rest, flag) {
release_args.push(flag.to_string());
release_args.push(value);
}
}
for flag in ["--allow-missing-request", "--no-stage"] {
if has_flag(rest, flag) {
release_args.push(flag.to_string());
}
}
if want_commit {
release_args.push("--commit".to_string());
}
release(&release_args)?;
if want_push {
let target = github_ssh_url(&format!(
"{}/{}",
pr.head_repository_owner.login, pr.head_repository.name
));
git_push_head_to_url(&root, &target, &pr.head_ref_name)?;
println!(
"pushed: {}:{}",
pr.head_repository_owner.login, pr.head_ref_name
);
}
if want_pr {
println!("pull request: {}", pr.url);
}
Ok(())
}
fn print_create_pr_next_step(root: &Path, title: &str, body: &str) {
let root_arg = root.display().to_string();
println!(
"next: rho repo create-pr --root {} --title {} --body {}",
shell_quote(&root_arg),
shell_quote(title),
shell_quote(body)
);
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn verify(rest: &[String]) -> RhoResult<()> {
let request_id = rest.first().cloned().unwrap_or_else(|| usage());
validate_request_id(&request_id)?;
let root = rho_core::arg_value(rest, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let expected_recipient = rho_core::arg_value(rest, "--to")
.map(|value| normalize_actor_id(&value))
.transpose()?;
let repo_id = read_repo_id(&root)?;
let result_path = locate_result_path(&root, &request_id, expected_recipient.as_deref())?;
let result_signature_path = result_path.with_file_name("result.rhosig.yaml");
refresh_result_file_if_needed(&root, &result_path)?;
refresh_result_file_if_needed(&root, &result_signature_path)?;
let result: ResultVerifyManifest = from_yaml(&fs::read_to_string(&result_path)?)?;
let result = result.result;
if result.request_id != request_id {
return Err(format!(
"result request_id mismatch: expected {request_id}, got {}",
result.request_id
)
.into());
}
if let Some(expected) = expected_recipient.as_deref()
&& result.to != expected
{
return Err(format!(
"result recipient mismatch: expected {expected}, got {}",
result.to
)
.into());
}
verify_file_with_context(SignatureVerify {
signed_path: &result_path,
signature_path: &result_signature_path,
identity: &result.from,
repo_root: &root,
repo_id: &repo_id,
request_id: &request_id,
recipient: &result.to,
purpose: "rho.result",
})?;
let receipt_path = root
.join("rho/run-receipts")
.join(&request_id)
.join(format!("{}.yaml", result.run_id));
let receipt_signature_path = root
.join("rho/run-receipts")
.join(&request_id)
.join(format!("{}.rhosig.yaml", result.run_id));
let receipt: RunReceiptVerifyManifest = from_yaml(&fs::read_to_string(&receipt_path)?)?;
let receipt = receipt.run_receipt;
check_result_receipt_binding(&root, &result_path, &result, &receipt)?;
verify_file_with_context(SignatureVerify {
signed_path: &receipt_path,
signature_path: &receipt_signature_path,
identity: &result.from,
repo_root: &root,
repo_id: &repo_id,
request_id: &request_id,
recipient: &result.to,
purpose: "rho.run_receipt",
})?;
let action_id = action_id_for_run_id(&result.run_id)?;
let grant_path = root
.join("rho/approval-grants")
.join(&request_id)
.join(format!("{action_id}.yaml"));
let grant_signature_path = root
.join("rho/approval-grants")
.join(&request_id)
.join(format!("{action_id}.rhosig.yaml"));
let grant: ApprovalGrantVerifyManifest = from_yaml(&fs::read_to_string(&grant_path)?)?;
let grant = grant.approval_grant;
check_receipt_grant_binding(&result.from, &receipt, &grant, &action_id)?;
verify_file_with_context(SignatureVerify {
signed_path: &grant_path,
signature_path: &grant_signature_path,
identity: &result.from,
repo_root: &root,
repo_id: &repo_id,
request_id: &request_id,
recipient: &result.to,
purpose: "rho.approval_grant",
})?;
println!(
"result: verified {}",
repo_relative_path(&root, &result_path)?
);
println!(" request_id: {}", result.request_id);
println!(" run_id: {}", result.run_id);
println!(" from: {}", result.from);
println!(" to: {}", result.to);
println!(" output_sha256: {}", result.output_sha256);
println!(
"run receipt: verified {}",
repo_relative_path(&root, &receipt_path)?
);
println!(" runner: {}", receipt.runner);
println!(" tier: {}", receipt.tier);
println!(" code_sha256: {}", receipt.code_sha256);
println!(" input_sha256: {}", receipt.input_sha256);
println!(
"approval grant: verified {}",
repo_relative_path(&root, &grant_path)?
);
println!(" action_id: {}", grant.action_id);
println!(" decision: {}", grant.decision);
println!(" granted_by: {}", grant.granted_by);
Ok(())
}
struct ReleaseOutput {
output_sha256: String,
payload: ReleasePayload,
}
enum ReleasePayload {
Inline { value: String },
Attachment { file_name: String, bytes: Vec<u8> },
}
#[derive(Debug, Deserialize)]
struct GhPrMetadata {
#[serde(rename = "headRefName")]
head_ref_name: String,
#[serde(rename = "headRepository")]
head_repository: GhPrRepository,
#[serde(rename = "headRepositoryOwner")]
head_repository_owner: GhPrOwner,
url: String,
}
#[derive(Debug, Deserialize)]
struct GhPrRepository {
name: String,
}
#[derive(Debug, Deserialize)]
struct GhPrOwner {
login: String,
}
#[derive(Debug, Deserialize)]
struct ResultVerifyManifest {
result: ResultVerifyRecord,
}
#[derive(Debug, Deserialize)]
struct ResultVerifyRecord {
request_id: String,
from: String,
to: String,
output_sha256: String,
run_id: String,
}
#[derive(Debug, Deserialize)]
struct RunReceiptVerifyManifest {
run_receipt: RunReceiptVerifyRecord,
}
#[derive(Debug, Deserialize)]
struct RunReceiptVerifyRecord {
request_id: String,
run_id: String,
runner: String,
status: String,
tier: String,
code_path: String,
code_sha256: String,
input_sha256: String,
output_sha256: String,
result_path: String,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantVerifyManifest {
approval_grant: ApprovalGrantVerifyRecord,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantVerifyRecord {
action_id: String,
request_id: String,
decision: String,
granted_by: String,
code: ApprovalGrantCodeVerifyRecord,
private_input: ApprovalGrantPrivateInputVerifyRecord,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantCodeVerifyRecord {
sha256: String,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantPrivateInputVerifyRecord {
tier: String,
sha256: String,
}
fn read_release_output(root: &Path, run_id: &str, rest: &[String]) -> RhoResult<ReleaseOutput> {
if let Some(value) = rho_core::arg_value(rest, "--value") {
let output_sha256 = sha256_hex(value.as_bytes());
if value.len() > inline_result_max_bytes(rest)? {
return Ok(ReleaseOutput {
output_sha256,
payload: ReleasePayload::Attachment {
file_name: "output.txt".to_string(),
bytes: value.into_bytes(),
},
});
}
return Ok(ReleaseOutput {
output_sha256,
payload: ReleasePayload::Inline { value },
});
}
let path = if let Some(path) = rho_core::arg_value(rest, "--output") {
PathBuf::from(path)
} else {
root.join(".rho/runs").join(run_id).join("stdout.txt")
};
let bytes = fs::read(&path).map_err(|_| {
format!(
"missing run output; pass --output or --value: {}",
path.display()
)
})?;
let file_name = attachment_file_name(&path)?;
let output_sha256 = sha256_hex(&bytes);
Ok(ReleaseOutput {
output_sha256,
payload: ReleasePayload::Attachment { file_name, bytes },
})
}
fn inline_result_max_bytes(rest: &[String]) -> RhoResult<usize> {
rho_core::arg_value(rest, "--inline-max-bytes")
.map(|value| {
value
.parse::<usize>()
.map_err(|_| format!("invalid --inline-max-bytes: {value}").into())
})
.unwrap_or(Ok(DEFAULT_INLINE_RESULT_MAX_BYTES))
}
fn locate_result_path(
root: &Path,
request_id: &str,
expected_recipient: Option<&str>,
) -> RhoResult<PathBuf> {
if let Some(recipient) = expected_recipient {
for relative in [
identity_inbox_relative_path(recipient)?,
legacy_identity_inbox_relative_path(recipient)?,
] {
let path = root
.join("rho/messages/inbox")
.join(relative)
.join(request_id)
.join("result.yaml");
if path.is_file() {
return Ok(path);
}
}
return Err(format!("result not found for {request_id} and recipient {recipient}").into());
}
let inbox = root.join("rho/messages/inbox");
let mut matches = Vec::new();
if inbox.is_dir() {
collect_result_paths(&inbox, request_id, &mut matches)?;
}
matches.sort();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Err(format!("result not found for request {request_id}").into()),
_ => Err(format!(
"multiple results found for request {request_id}; pass --to to select recipient"
)
.into()),
}
}
fn collect_result_paths(dir: &Path, request_id: &str, matches: &mut Vec<PathBuf>) -> RhoResult<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if !path.is_dir() {
continue;
}
let result_path = path.join(request_id).join("result.yaml");
if result_path.is_file() {
matches.push(result_path);
}
collect_result_paths(&path, request_id, matches)?;
}
Ok(())
}
fn check_result_receipt_binding(
root: &Path,
result_path: &Path,
result: &ResultVerifyRecord,
receipt: &RunReceiptVerifyRecord,
) -> RhoResult<()> {
if receipt.request_id != result.request_id {
return Err(format!(
"run receipt request_id mismatch: expected {}, got {}",
result.request_id, receipt.request_id
)
.into());
}
if receipt.run_id != result.run_id {
return Err(format!(
"run receipt run_id mismatch: expected {}, got {}",
result.run_id, receipt.run_id
)
.into());
}
if receipt.status != "completed" {
return Err(format!("run receipt status is not completed: {}", receipt.status).into());
}
if receipt.output_sha256 != result.output_sha256 {
return Err(format!(
"run receipt output digest mismatch: expected {}, got {}",
result.output_sha256, receipt.output_sha256
)
.into());
}
let expected_result_path = repo_relative_path(root, result_path)?;
if receipt.result_path != expected_result_path {
return Err(format!(
"run receipt result_path mismatch: expected {expected_result_path}, got {}",
receipt.result_path
)
.into());
}
for (label, value) in [
("runner", receipt.runner.as_str()),
("tier", receipt.tier.as_str()),
("code_path", receipt.code_path.as_str()),
("code_sha256", receipt.code_sha256.as_str()),
("output_sha256", receipt.output_sha256.as_str()),
] {
if value.trim().is_empty() {
return Err(format!("run receipt missing {label}").into());
}
}
if receipt.tier == "real" && receipt.input_sha256.trim().is_empty() {
return Err("real run receipt missing input_sha256".into());
}
Ok(())
}
fn check_receipt_grant_binding(
expected_owner: &str,
receipt: &RunReceiptVerifyRecord,
grant: &ApprovalGrantVerifyRecord,
action_id: &str,
) -> RhoResult<()> {
if grant.action_id != action_id {
return Err(format!(
"approval grant action_id mismatch: expected {action_id}, got {}",
grant.action_id
)
.into());
}
if grant.request_id != receipt.request_id {
return Err(format!(
"approval grant request_id mismatch: expected {}, got {}",
receipt.request_id, grant.request_id
)
.into());
}
if grant.decision != "approved" {
return Err(format!(
"approval grant decision is not approved: {}",
grant.decision
)
.into());
}
if grant.granted_by != expected_owner {
return Err(format!(
"approval grant granted_by mismatch: expected {expected_owner}, got {}",
grant.granted_by
)
.into());
}
if grant.code.sha256 != receipt.code_sha256 {
return Err(format!(
"approval grant code digest mismatch: expected {}, got {}",
receipt.code_sha256, grant.code.sha256
)
.into());
}
if grant.private_input.tier != receipt.tier {
return Err(format!(
"approval grant tier mismatch: expected {}, got {}",
receipt.tier, grant.private_input.tier
)
.into());
}
if grant.private_input.sha256 != receipt.input_sha256 {
return Err(format!(
"approval grant input digest mismatch: expected {}, got {}",
receipt.input_sha256, grant.private_input.sha256
)
.into());
}
Ok(())
}
fn action_id_for_run_id(run_id: &str) -> RhoResult<String> {
run_id
.strip_prefix("run-")
.map(|suffix| format!("act-{suffix}"))
.ok_or_else(|| {
format!("run id has no conventional approval grant mapping: {run_id}").into()
})
}
fn attachment_file_name(path: &Path) -> RhoResult<String> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| format!("output path has no valid filename: {}", path.display()))?;
if file_name.is_empty()
|| file_name == "."
|| file_name == ".."
|| file_name.contains('/')
|| file_name.contains('\\')
|| file_name.chars().any(char::is_control)
{
return Err(format!("unsupported output filename: {file_name}").into());
}
Ok(file_name.to_string())
}
struct RunReceiptWrite<'a> {
root: &'a Path,
repo_id: &'a str,
request_id: &'a str,
run_id: &'a str,
runner: &'a str,
result_path: &'a Path,
receipt_path: &'a Path,
receipt_signature_path: &'a Path,
output_sha256: &'a str,
signer: &'a str,
recipient: &'a str,
}
fn write_run_receipt_if_available(receipt: RunReceiptWrite<'_>) -> RhoResult<bool> {
let run_manifest_path = receipt
.root
.join(".rho/runs")
.join(format!("{}.yaml", receipt.run_id));
if !run_manifest_path.is_file() {
return Ok(false);
}
let text = fs::read_to_string(&run_manifest_path)?;
let manifest: RunManifest = from_yaml(&text)?;
if manifest.run.id != receipt.run_id {
return Err(format!(
"run manifest id mismatch: expected {}, got {}",
receipt.run_id, manifest.run.id
)
.into());
}
if manifest.run.request_id != receipt.request_id {
return Err(format!(
"run manifest request mismatch: expected {}, got {}",
receipt.request_id, manifest.run.request_id
)
.into());
}
let result_repo_path = repo_relative_path(receipt.root, receipt.result_path)?;
let input_sha256 = run_input_commitment(receipt.root, &manifest.run)?;
if receipt.receipt_path.is_file() {
validate_existing_run_receipt(&manifest, &receipt, input_sha256.as_deref())?;
verify_file_with_context(SignatureVerify {
signed_path: receipt.receipt_path,
signature_path: receipt.receipt_signature_path,
identity: receipt.signer,
repo_root: receipt.root,
repo_id: receipt.repo_id,
request_id: receipt.request_id,
recipient: receipt.recipient,
purpose: "rho.run_receipt",
})?;
return Ok(true);
}
ensure_parent(receipt.receipt_path)?;
fs::write(
receipt.receipt_path,
format!(
"version: 1\nrun_receipt:\n request_id: {}\n run_id: {}\n runner: {}\n status: {}\n tier: {}\n code_path: {}\n code_sha256: {}\n input_sha256: {}\n output_sha256: {}\n result_path: {}\n created_at: {}\n",
yaml_quote(receipt.request_id),
yaml_quote(receipt.run_id),
yaml_quote(manifest.run.runner.as_deref().unwrap_or(receipt.runner)),
yaml_quote(&manifest.run.status),
yaml_quote(&manifest.run.tier),
yaml_quote(manifest.run.code_path.as_deref().unwrap_or("")),
yaml_quote(manifest.run.code_sha256.as_deref().unwrap_or("")),
yaml_quote(input_sha256.as_deref().unwrap_or("")),
yaml_quote(receipt.output_sha256),
yaml_quote(&result_repo_path),
yaml_quote(&rho_core::now_rfc3339()),
),
)?;
sign_file_with_context(SignatureWrite {
signed_path: receipt.receipt_path,
signature_path: receipt.receipt_signature_path,
identity: receipt.signer,
repo_id: receipt.repo_id,
request_id: receipt.request_id,
recipient: receipt.recipient,
purpose: "rho.run_receipt",
})?;
Ok(true)
}
fn validate_existing_run_receipt(
manifest: &RunManifest,
receipt: &RunReceiptWrite<'_>,
input_sha256: Option<&str>,
) -> RhoResult<()> {
if !receipt.receipt_signature_path.is_file() {
return Err(format!(
"existing run receipt signature missing: {}",
receipt.receipt_signature_path.display()
)
.into());
}
let existing: RunReceiptVerifyManifest = from_yaml(&fs::read_to_string(receipt.receipt_path)?)?;
let existing = existing.run_receipt;
let result_repo_path = repo_relative_path(receipt.root, receipt.result_path)?;
for (label, expected, actual) in [
(
"request_id",
receipt.request_id,
existing.request_id.as_str(),
),
("run_id", receipt.run_id, existing.run_id.as_str()),
(
"status",
manifest.run.status.as_str(),
existing.status.as_str(),
),
("tier", manifest.run.tier.as_str(), existing.tier.as_str()),
(
"output_sha256",
receipt.output_sha256,
existing.output_sha256.as_str(),
),
(
"result_path",
result_repo_path.as_str(),
existing.result_path.as_str(),
),
] {
if expected != actual {
return Err(format!(
"existing run receipt {label} mismatch: expected {expected}, got {actual}"
)
.into());
}
}
if let Some(runner) = manifest.run.runner.as_deref()
&& existing.runner != runner
{
return Err(format!(
"existing run receipt runner mismatch: expected {runner}, got {}",
existing.runner
)
.into());
}
if let Some(code_sha256) = manifest.run.code_sha256.as_deref()
&& existing.code_sha256 != code_sha256
{
return Err(format!(
"existing run receipt code digest mismatch: expected {code_sha256}, got {}",
existing.code_sha256
)
.into());
}
let expected_input = input_sha256.unwrap_or("");
if existing.input_sha256 != expected_input {
return Err(format!(
"existing run receipt input digest mismatch: expected {expected_input}, got {}",
existing.input_sha256
)
.into());
}
Ok(())
}
fn preflight_run_input_commitment(root: &Path, request_id: &str, run_id: &str) -> RhoResult<()> {
let run_manifest_path = root.join(".rho/runs").join(format!("{run_id}.yaml"));
if !run_manifest_path.is_file() {
return Ok(());
}
let text = fs::read_to_string(&run_manifest_path)?;
let manifest: RunManifest = from_yaml(&text)?;
if manifest.run.id != run_id {
return Err(format!(
"run manifest id mismatch: expected {run_id}, got {}",
manifest.run.id
)
.into());
}
if manifest.run.request_id != request_id {
return Err(format!(
"run manifest request mismatch: expected {request_id}, got {}",
manifest.run.request_id
)
.into());
}
let _ = run_input_commitment(root, &manifest.run)?;
Ok(())
}
fn run_input_commitment(root: &Path, run: &rho_core::RunRecord) -> RhoResult<Option<String>> {
let Some(dataset_csv) = run.dataset_csv.as_deref() else {
if run.tier == "real" {
return Err("real run receipt requires dataset_csv for input commitment".into());
}
return Ok(None);
};
let path = Path::new(dataset_csv);
let path = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let digest = file_digest(&path)
.map_err(|error| format!("failed to digest run input {}: {error}", path.display()))?;
Ok(Some(digest))
}
fn active_identity() -> Option<String> {
env::var("RHO_IDENTITY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.and_then(|value| normalize_actor_id(&value).ok())
}
fn require_committed_request(root: &Path, request_id: &str) -> RhoResult<()> {
let inbox = root.join("rho/messages/inbox");
if request_exists(&inbox, request_id)? {
return Ok(());
}
Err(format!(
"request not found in repo; merge the request first or pass --allow-missing-request: {request_id}"
)
.into())
}
fn request_exists(dir: &Path, request_id: &str) -> RhoResult<bool> {
if !dir.is_dir() {
return Ok(false);
}
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
if path.file_name().and_then(|value| value.to_str()) == Some(request_id)
&& path.join("request.yaml").is_file()
{
return Ok(true);
}
if request_exists(&path, request_id)? {
return Ok(true);
}
}
}
Ok(false)
}
#[derive(Debug, Deserialize)]
struct RepoManifest {
repo: RepoRecord,
}
#[derive(Debug, Deserialize)]
struct RepoRecord {
id: String,
}
fn read_repo_id(root: &Path) -> RhoResult<String> {
let path = root.join("rho/repo.yaml");
let text = fs::read_to_string(&path)
.map_err(|_| format!("repo manifest not found: {}", path.display()))?;
let repo: RepoManifest = from_yaml(&text)?;
normalize_repo_id(&repo.repo.id)
}
struct SignatureWrite<'a> {
signed_path: &'a Path,
signature_path: &'a Path,
identity: &'a str,
repo_id: &'a str,
request_id: &'a str,
recipient: &'a str,
purpose: &'a str,
}
struct SignatureVerify<'a> {
signed_path: &'a Path,
signature_path: &'a Path,
identity: &'a str,
repo_root: &'a Path,
repo_id: &'a str,
request_id: &'a str,
recipient: &'a str,
purpose: &'a str,
}
fn sign_file_with_context(signature: SignatureWrite<'_>) -> RhoResult<()> {
let mut rho_crypto = rho_crypto_command();
let status = rho_crypto
.arg("sign")
.arg(signature.signed_path)
.arg("--identity")
.arg(signature.identity)
.arg("--out")
.arg(signature.signature_path)
.arg("--repo-id")
.arg(signature.repo_id)
.arg("--request-id")
.arg(signature.request_id)
.arg("--recipient")
.arg(signature.recipient)
.arg("--purpose")
.arg(signature.purpose)
.status()?;
if !status.success() {
return Err(format!("failed to sign file: {}", signature.signed_path.display()).into());
}
Ok(())
}
fn verify_file_with_context(signature: SignatureVerify<'_>) -> RhoResult<()> {
if !signature.signature_path.is_file() {
return Err(format!("signature missing: {}", signature.signature_path.display()).into());
}
let output = rho_crypto_command()
.arg("verify")
.arg(signature.signed_path)
.arg("--signature")
.arg(signature.signature_path)
.arg("--identity")
.arg(signature.identity)
.arg("--repo-root")
.arg(signature.repo_root)
.arg("--repo-id")
.arg(signature.repo_id)
.arg("--request-id")
.arg(signature.request_id)
.arg("--recipient")
.arg(signature.recipient)
.arg("--purpose")
.arg(signature.purpose)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"signature verification failed for {}: {}",
signature.signed_path.display(),
stderr.trim()
)
.into());
}
Ok(())
}
fn refresh_result_file_if_needed(root: &Path, path: &Path) -> RhoResult<()> {
let text = fs::read_to_string(path)?;
if !is_rho_encrypted_text(&text) {
return Ok(());
}
let relative_path = repo_relative_path(root, path)?;
let plaintext = smudge_result_file_text(root, &relative_path, &text)?;
if is_rho_encrypted_text(&plaintext) {
return Err(format!(
"cannot decrypt protected result file {}; run with a RHO_HOME/RHO_IDENTITY that can open it",
path.display()
)
.into());
}
fs::write(path, plaintext)?;
println!("refreshed protected file: {relative_path}");
Ok(())
}
fn smudge_result_file_text(root: &Path, relative_path: &str, text: &str) -> RhoResult<String> {
let mut rho_crypto = rho_crypto_command();
let mut child = rho_crypto
.arg("smudge")
.arg("--repo-root")
.arg(root)
.arg("--path")
.arg(relative_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
{
let Some(stdin) = child.stdin.as_mut() else {
return Err("failed to open rho crypto smudge stdin".into());
};
stdin.write_all(text.as_bytes())?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"rho crypto smudge failed for {relative_path}: {}",
stderr.trim()
)
.into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn rho_crypto_command() -> Command {
let rho = env::current_exe()
.ok()
.map(|path| path.with_file_name("rho"))
.unwrap_or_else(|| PathBuf::from("rho"));
let mut command = if rho.is_file() {
Command::new(rho)
} else {
let mut command = Command::new("cargo");
command.args(["run", "--quiet", "--bin", "rho", "--"]);
command
};
command.arg("crypto");
command
}
fn git_add(root: &Path, path: &Path) -> RhoResult<()> {
git_add_with_identity(root, path, None)
}
fn finish_branch_work(
args: &[String],
root: &Path,
request_id: &str,
stage_paths: &[&Path],
) -> RhoResult<()> {
let want_pr = rho_core::has_flag(args, "--pr");
let want_commit = want_pr || rho_core::has_flag(args, "--commit");
let want_push = want_pr || rho_core::has_flag(args, "--push");
if !want_commit && !want_push && !want_pr {
return Ok(());
}
if want_commit {
for path in stage_paths {
git_add(root, path)?;
}
git_commit(root, &format!("Release encrypted result for {request_id}"))?;
println!("committed: Release encrypted result for {request_id}");
}
if want_push {
git_push_current_branch(root)?;
println!("pushed: {}", current_branch(root)?);
}
if want_pr {
create_pr(
root,
&format!("Release encrypted result for {request_id}"),
&format!("Signed encrypted result for {request_id}."),
)?;
}
Ok(())
}
fn git_commit(root: &Path, message: &str) -> RhoResult<()> {
let exe = env::current_exe()?;
let status = Command::new(exe)
.current_dir(root)
.arg("commit")
.arg("-m")
.arg(message)
.status()?;
if !status.success() {
return Err(format!("rho commit failed in {}", root.display()).into());
}
Ok(())
}
fn git_push_current_branch(root: &Path) -> RhoResult<()> {
let branch = current_branch(root)?;
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["push", "-u", "origin", &branch])
.status()?;
if !status.success() {
return Err(format!("git push failed for {branch}").into());
}
Ok(())
}
fn git_push_head_to_url(root: &Path, url: &str, branch: &str) -> RhoResult<()> {
let refspec = format!("HEAD:{branch}");
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["push", url, &refspec])
.status()?;
if !status.success() {
return Err(format!("git push failed for {url} {refspec}").into());
}
Ok(())
}
fn github_ssh_url(repo_slug: &str) -> String {
format!("git@github.com:{repo_slug}.git")
}
fn gh_pr_metadata(root: &Path, pr_number: &str) -> RhoResult<GhPrMetadata> {
let output = Command::new("gh")
.current_dir(root)
.args([
"pr",
"view",
pr_number,
"--json",
"headRefName,headRepository,headRepositoryOwner,url",
])
.output()?;
if !output.status.success() {
return Err(format!("gh pr view failed for {pr_number}").into());
}
from_json(&String::from_utf8(output.stdout)?)
}
fn gh_pr_checkout(root: &Path, pr_number: &str) -> RhoResult<()> {
let status = Command::new("gh")
.current_dir(root)
.args(["pr", "checkout", pr_number])
.status()?;
if !status.success() {
return Err(format!("gh pr checkout failed for {pr_number}").into());
}
Ok(())
}
fn current_branch(root: &Path) -> RhoResult<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["branch", "--show-current"])
.output()?;
if !output.status.success() {
return Err(format!("git branch --show-current failed in {}", root.display()).into());
}
let branch = String::from_utf8(output.stdout)?.trim().to_string();
if branch.is_empty() {
return Err("current Git branch is empty".into());
}
Ok(branch)
}
fn find_single_repo_request_id(root: &Path) -> RhoResult<String> {
let mut ids = Vec::new();
collect_repo_request_ids(&root.join("rho/messages/inbox"), &mut ids)?;
ids.sort();
ids.dedup();
match ids.as_slice() {
[id] => Ok(id.clone()),
[] => Err("no request found in checked-out PR; pass --request-id".into()),
_ => Err(format!(
"multiple requests found in checked-out PR: {}; pass --request-id",
ids.join(", ")
)
.into()),
}
}
fn collect_repo_request_ids(dir: &Path, ids: &mut Vec<String>) -> RhoResult<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if path.join("request.yaml").is_file()
&& let Some(id) = path.file_name().and_then(|value| value.to_str())
{
ids.push(id.to_string());
}
collect_repo_request_ids(&path, ids)?;
}
}
Ok(())
}
fn create_pr(root: &Path, title: &str, body: &str) -> RhoResult<()> {
let exe = env::current_exe()?;
let status = Command::new(exe)
.arg("repo")
.arg("create-pr")
.arg("--root")
.arg(root)
.arg("--title")
.arg(title)
.arg("--body")
.arg(body)
.status()?;
if !status.success() {
return Err(format!("rho repo create-pr failed in {}", root.display()).into());
}
Ok(())
}
fn run_rho_command(args: &[String]) -> RhoResult<()> {
let exe = env::current_exe()?;
let status = Command::new(exe).args(args).status()?;
if !status.success() {
return Err(format!("rho command failed: {}", args.join(" ")).into());
}
Ok(())
}
fn git_add_with_identity(root: &Path, path: &Path, identity: Option<&str>) -> RhoResult<()> {
let mut command = Command::new("git");
command.arg("-C").arg(root).arg("add").arg(path);
if let Some(identity) = identity {
command.env("RHO_IDENTITY", identity);
}
let status = command.status()?;
if !status.success() {
return Err(format!("git add failed: {}", path.display()).into());
}
Ok(())
}
fn verify_staged_encrypted(root: &Path, path: &Path) -> RhoResult<()> {
let relative = path
.strip_prefix(root)
.map_err(|_| format!("path is not under repo root: {}", path.display()))?;
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["show", &format!(":{}", relative.display())])
.output()?;
if !output.status.success() {
return Err(format!("staged blob not found: {}", relative.display()).into());
}
let text = String::from_utf8_lossy(&output.stdout);
if is_rho_encrypted_text(&text) {
return Ok(());
}
Err(format!("staged result is not encrypted: {}", relative.display()).into())
}
fn repo_relative_path(root: &Path, path: &Path) -> RhoResult<String> {
let relative = path
.strip_prefix(root)
.map_err(|_| format!("path is not under repo root: {}", path.display()))?;
Ok(relative
.to_str()
.ok_or_else(|| -> Box<dyn std::error::Error> { "path is not valid UTF-8".into() })?
.to_string())
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn switch_branch(root: &Path, branch: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["switch", "-C", branch])
.status()?;
if !status.success() {
return Err(format!("git switch failed for branch {branch}").into());
}
Ok(())
}
fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
providers::github::handle_from_identity_id(identity_id)
}
fn identity_inbox_relative_path(identity_id: &str) -> RhoResult<PathBuf> {
providers::identity_inbox_relative_path(identity_id)
}
fn legacy_identity_inbox_relative_path(identity_id: &str) -> RhoResult<PathBuf> {
Ok(PathBuf::from(github_handle_from_identity_id(identity_id)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_value_uses_attachment_when_over_inline_limit() {
let small_args = vec![
"--value".to_string(),
"small".to_string(),
"--inline-max-bytes".to_string(),
"8".to_string(),
];
let small = read_release_output(Path::new("."), "run-test", &small_args).unwrap();
assert!(matches!(small.payload, ReleasePayload::Inline { .. }));
let large_args = vec![
"--value".to_string(),
"too large".to_string(),
"--inline-max-bytes".to_string(),
"3".to_string(),
];
let large = read_release_output(Path::new("."), "run-test", &large_args).unwrap();
match large.payload {
ReleasePayload::Attachment { file_name, bytes } => {
assert_eq!(file_name, "output.txt");
assert_eq!(bytes, b"too large");
}
ReleasePayload::Inline { .. } => panic!("large value should become an attachment"),
}
}
#[test]
fn finds_single_request_id_from_repo_inbox() {
let root = env::temp_dir().join(format!(
"rho-result-request-test-{}-{}",
std::process::id(),
rho_core::uuid_like()
));
let request_dir = root.join("rho/messages/inbox/id/github/owner/req-test-001");
fs::create_dir_all(&request_dir).unwrap();
fs::write(request_dir.join("request.yaml"), "version: 1\n").unwrap();
assert_eq!(
find_single_repo_request_id(&root).unwrap(),
"req-test-001".to_string()
);
fs::remove_dir_all(root).unwrap();
}
}