use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use rho_core::{
RhoResult, ensure_parent, from_yaml, is_rho_encrypted_text, normalize_actor_id,
normalize_repo_id, providers, require_arg, uuid_like, yaml_quote,
};
use serde::Deserialize;
fn usage() -> ! {
eprintln!(
"usage: rho message send (--root <repo>|--shared-root <path>) --from <id> --to <id> --type <type> --text <text> [--related-request-id <id>] [--id <id>] [--no-stage]\n rho message verify <message-id> --root <repo> [--to <id>]"
);
std::process::exit(2);
}
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
let rest = &args[1..];
if command == "verify" {
return verify_repo_message(rest);
}
if command != "send" {
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 message_type = require_arg(rest, "--type").unwrap_or_else(|_| usage());
let text = require_arg(rest, "--text").unwrap_or_else(|_| usage());
let id = rho_core::arg_value(rest, "--id").unwrap_or_else(|| format!("msg-{}", uuid_like()));
validate_message_id(&id)?;
let related = rho_core::arg_value(rest, "--related-request-id");
if let Some(root) = rho_core::arg_value(rest, "--root") {
if rho_core::arg_value(rest, "--shared-root").is_some() {
return Err("--root and --shared-root cannot be used together".into());
}
return send_repo_message(SendMessage {
root: PathBuf::from(root),
from,
to,
message_type,
text,
id,
related,
stage: !rho_core::has_flag(rest, "--no-stage"),
});
}
let shared_root = PathBuf::from(require_arg(rest, "--shared-root").unwrap_or_else(|_| usage()));
send_legacy_message(
&shared_root,
&from,
&to,
&message_type,
&text,
&id,
related.as_deref(),
)
}
fn send_legacy_message(
shared_root: &Path,
from: &str,
to: &str,
message_type: &str,
text: &str,
id: &str,
related: Option<&str>,
) -> RhoResult<()> {
let path = shared_root
.join(".rho/inbox")
.join(to)
.join(format!("{id}.yaml"));
ensure_parent(&path)?;
let related_line = related
.map(|value| format!(" related_request_id: {}\n", yaml_quote(value)))
.unwrap_or_default();
fs::write(
&path,
format!(
"version: 1\nmessage:\n id: {}\n from: {}\n to: {}\n type: {}\n{} created_at: {}\n body:\n text: {}\n",
yaml_quote(id),
yaml_quote(from),
yaml_quote(to),
yaml_quote(message_type),
related_line,
yaml_quote(&rho_core::now_rfc3339()),
yaml_quote(text),
),
)?;
println!("{}", path.display());
Ok(())
}
struct SendMessage {
root: PathBuf,
from: String,
to: String,
message_type: String,
text: String,
id: String,
related: Option<String>,
stage: bool,
}
fn send_repo_message(message: SendMessage) -> RhoResult<()> {
let repo_id = read_repo_id(&message.root)?;
let message_dir = message
.root
.join("rho/messages/inbox")
.join(identity_inbox_relative_path(&message.to)?)
.join(&message.id);
let message_path = message_dir.join("message.yaml");
let signature_path = message_dir.join("message.rhosig.yaml");
ensure_parent(&message_path)?;
let related_line = message
.related
.as_ref()
.map(|value| format!(" related_request_id: {}\n", yaml_quote(value)))
.unwrap_or_default();
fs::write(
&message_path,
format!(
"version: 1\nmessage:\n id: {}\n from: {}\n to: {}\n type: {}\n{} created_at: {}\n body:\n text: {}\n",
yaml_quote(&message.id),
yaml_quote(&message.from),
yaml_quote(&message.to),
yaml_quote(&message.message_type),
related_line,
yaml_quote(&rho_core::now_rfc3339()),
yaml_quote(&message.text),
),
)?;
sign_message_file(
&message.root,
&repo_id,
&message.id,
&message.from,
&message.to,
&message_path,
&signature_path,
)?;
if message.stage {
git_add_with_identity(&message.root, &message_path, &message.from)?;
git_add_with_identity(&message.root, &signature_path, &message.from)?;
println!("staged encrypted message files");
}
println!("message: {}", message.id);
println!(
"path: {}",
repo_relative_or_display(&message.root, &message_path)
);
println!(
"signature: {}",
repo_relative_or_display(&message.root, &signature_path)
);
Ok(())
}
fn verify_repo_message(rest: &[String]) -> RhoResult<()> {
let id = rest.first().cloned().unwrap_or_else(|| usage());
validate_message_id(&id)?;
let root = PathBuf::from(require_arg(rest, "--root").unwrap_or_else(|_| usage()));
let expected_recipient = rho_core::arg_value(rest, "--to")
.map(|value| normalize_actor_id(&value))
.transpose()?;
let repo_id = read_repo_id(&root)?;
let message_path = locate_message_path(&root, &id, expected_recipient.as_deref())?;
let signature_path = message_path.with_file_name("message.rhosig.yaml");
refresh_message_file_if_needed(&root, &message_path)?;
refresh_message_file_if_needed(&root, &signature_path)?;
let manifest: MessageVerifyManifest = from_yaml(&fs::read_to_string(&message_path)?)?;
let message = manifest.message;
if message.id != id {
return Err(format!("message id mismatch: expected {id}, got {}", message.id).into());
}
if let Some(expected) = expected_recipient.as_deref()
&& message.to != expected
{
return Err(format!(
"message recipient mismatch: expected {expected}, got {}",
message.to
)
.into());
}
verify_message_file(VerifyMessage {
root: &root,
repo_id: &repo_id,
message_id: &id,
signer: &message.from,
recipient: &message.to,
message_path: &message_path,
signature_path: &signature_path,
})?;
println!(
"message: verified {}",
repo_relative_or_display(&root, &message_path)
);
println!(" id: {}", message.id);
println!(" from: {}", message.from);
println!(" to: {}", message.to);
println!(" type: {}", message.message_type);
Ok(())
}
#[derive(Debug, Deserialize)]
struct RepoManifest {
repo: RepoRecord,
}
#[derive(Debug, Deserialize)]
struct RepoRecord {
id: String,
}
#[derive(Debug, Deserialize)]
struct MessageVerifyManifest {
message: MessageVerifyRecord,
}
#[derive(Debug, Deserialize)]
struct MessageVerifyRecord {
id: String,
from: String,
to: String,
#[serde(rename = "type")]
message_type: 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)
}
fn sign_message_file(
root: &Path,
repo_id: &str,
message_id: &str,
signer: &str,
recipient: &str,
message_path: &Path,
signature_path: &Path,
) -> RhoResult<()> {
let status = rho_crypto_command()
.arg("sign")
.arg(message_path)
.arg("--identity")
.arg(signer)
.arg("--out")
.arg(signature_path)
.arg("--repo-id")
.arg(repo_id)
.arg("--message-id")
.arg(message_id)
.arg("--recipient")
.arg(recipient)
.arg("--purpose")
.arg("rho.message")
.current_dir(root)
.status()?;
if !status.success() {
return Err(format!("failed to sign message: {}", message_path.display()).into());
}
Ok(())
}
struct VerifyMessage<'a> {
root: &'a Path,
repo_id: &'a str,
message_id: &'a str,
signer: &'a str,
recipient: &'a str,
message_path: &'a Path,
signature_path: &'a Path,
}
fn verify_message_file(message: VerifyMessage<'_>) -> RhoResult<()> {
if !message.signature_path.is_file() {
return Err(format!("signature missing: {}", message.signature_path.display()).into());
}
let output = rho_crypto_command()
.arg("verify")
.arg(message.message_path)
.arg("--signature")
.arg(message.signature_path)
.arg("--identity")
.arg(message.signer)
.arg("--repo-root")
.arg(message.root)
.arg("--repo-id")
.arg(message.repo_id)
.arg("--message-id")
.arg(message.message_id)
.arg("--recipient")
.arg(message.recipient)
.arg("--purpose")
.arg("rho.message")
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"signature verification failed for {}: {}",
message.message_path.display(),
stderr.trim()
)
.into());
}
Ok(())
}
fn git_add_with_identity(root: &Path, path: &Path, identity: &str) -> RhoResult<()> {
let relative = repo_relative_path(root, path)?;
let status = Command::new("git")
.arg("-C")
.arg(root)
.arg("add")
.arg(&relative)
.env("RHO_IDENTITY", identity)
.status()?;
if !status.success() {
return Err(format!("git add failed for {relative}").into());
}
Ok(())
}
fn rho_crypto_command() -> Command {
let rho = std::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 locate_message_path(
root: &Path,
id: &str,
expected_recipient: Option<&str>,
) -> RhoResult<PathBuf> {
if let Some(recipient) = expected_recipient {
let path = root
.join("rho/messages/inbox")
.join(identity_inbox_relative_path(recipient)?)
.join(id)
.join("message.yaml");
if path.is_file() {
return Ok(path);
}
return Err(format!("message not found for {id} and recipient {recipient}").into());
}
let inbox = root.join("rho/messages/inbox");
let mut matches = Vec::new();
if inbox.is_dir() {
collect_message_paths(&inbox, id, &mut matches)?;
}
matches.sort();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Err(format!("message not found: {id}").into()),
_ => Err(format!("multiple messages found for {id}; pass --to to select recipient").into()),
}
}
fn collect_message_paths(dir: &Path, id: &str, matches: &mut Vec<PathBuf>) -> RhoResult<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if !path.is_dir() {
continue;
}
let message_path = path.join(id).join("message.yaml");
if message_path.is_file() {
matches.push(message_path);
}
collect_message_paths(&path, id, matches)?;
}
Ok(())
}
fn refresh_message_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_message_file_text(root, &relative_path, &text)?;
if is_rho_encrypted_text(&plaintext) {
return Err(format!(
"cannot decrypt protected message 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_message_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 identity_inbox_relative_path(identity_id: &str) -> RhoResult<PathBuf> {
providers::identity_inbox_relative_path(identity_id)
}
fn repo_relative_path(root: &Path, path: &Path) -> RhoResult<String> {
Ok(path
.strip_prefix(root)
.map_err(|_| {
format!(
"path must be inside repo root {}: {}",
root.display(),
path.display()
)
})?
.display()
.to_string())
}
fn repo_relative_or_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|relative| relative.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}
fn validate_message_id(value: &str) -> RhoResult<()> {
if value.is_empty() {
return Err("message id must not be empty".into());
}
if value.len() > 128 {
return Err("message id must be 128 characters or fewer".into());
}
if !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
{
return Err(format!(
"message id must contain only ASCII letters, digits, '.', '_', or '-': {value}"
)
.into());
}
Ok(())
}