use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use rho_core::{
IdentityBundleManifest, LocalIdentityManifest, RhoResult, SignatureContext, SignatureManifest,
Tool, ToolManifest, arg_value, ensure_parent, file_digest, from_json, from_yaml, has_flag,
is_rho_encrypted_text, normalize_actor_id, normalize_repo_id, path_matches_pattern, providers,
to_json_pretty, to_yaml, validate_actor_id, validate_relative_safe_path, validate_request_id,
yaml_quote, yaml_top_level_kind,
};
use serde::{Deserialize, Serialize};
use crate::commands::id::{sign_identity_self_signature, verify_identity_bundle_self_signature};
fn usage() -> ! {
eprintln!(
"usage:\n rho repo create <owner/repo|repo> [--root <path>] (--public|--private|--internal) [--yes]\n rho repo sync [<owner/repo>] [--root <path>] [--branch <name>]\n rho repo init [--repo-id <rho://repo/...|owner/repo|github-url>] [--root <path>] [--owner <rho-id|github/handle|handle>] [--yes] [--force]\n rho repo join <rho-id|github/handle|handle|owner/repo> [--root <path>] [--no-branch] [--commit] [--push] [--pr]\n rho repo admit <rho-id|github/handle|handle> [--root <path>] [--rho-bin <path>] [--no-branch] [--commit] [--push] [--pr]\n rho repo admit-pr <number> [--root <path>] [--rho-bin <path>] [--new-pr] [--commit] [--push] [--pr]\n rho repo create-pr [--root <path>] --title <text> [--body <text>] [--web]\n rho repo merge-pr <number> [--root <path>] [--merge|--squash|--rebase] [--delete-branch]\n rho repo add-user <rho-id|github/handle|handle> [--root <path>]\n rho repo import-users [--root <path>]\n rho repo sign-governance [--root <path>] [--identity <rho-id>] [--no-stage]\n rho repo protect-path <pattern> [--root <path>] [--recipient <rho-id>...] [--key-file <path>] [--rho-bin <path>]\n rho repo install-filters [--root <path>] [--rho-bin <path>]\n rho repo refresh-protected [--root <path>] [--rho-bin <path>]\n rho repo migrate-inbox-paths [--root <path>] [--dry-run]\n rho repo audit-encryption [--root <path>]\n rho repo status [--root <path>]\n rho repo doctor [--root <path>] [--flow] [--governance|--no-governance]\n rho repo check-pr [--root <path>] [--event <github-event.json>] [--actor <login|rho-id>] [--changed-file <path>...] [--base-ref <ref>] [--format json]"
);
std::process::exit(2);
}
pub fn run(args: &[String]) -> RhoResult<()> {
let Some(command) = args.first().map(String::as_str) else {
usage();
};
match command {
"create" => create(&args[1..]),
"sync" => sync(&args[1..]),
"init" => init(&args[1..]),
"join" => join(&args[1..]),
"admit" => admit(&args[1..]),
"admit-pr" => admit_pr(&args[1..]),
"create-pr" | "open-pr" => create_pr(&args[1..]),
"merge-pr" => merge_pr(&args[1..]),
"add-user" => add_user(&args[1..]),
"import-users" => import_users(&args[1..]),
"sign-governance" => sign_governance(&args[1..]),
"protect-path" => protect_path(&args[1..]),
"install-filters" => install_filters(&args[1..]),
"refresh-protected" => refresh_protected(&args[1..]),
"migrate-inbox-paths" => migrate_inbox_paths(&args[1..]),
"audit-encryption" => audit_encryption(&args[1..]),
"status" => status(&args[1..]),
"doctor" => doctor(&args[1..]),
"check-pr" => check_pr(&args[1..]),
"--help" | "-h" => usage(),
_ => usage(),
}
}
fn import_users(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let participants_dir = root.join("rho/participants");
let home = rho_home();
let mut imported = 0usize;
let mut skipped_local = 0usize;
if !participants_dir.is_dir() {
println!("imported users: 0");
return Ok(());
}
for entry in fs::read_dir(&participants_dir)? {
let path = entry?.path();
if path.extension().and_then(|value| value.to_str()) != Some("yaml")
|| path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.ends_with(".rhosig.yaml"))
{
continue;
}
let bundle: IdentityBundleManifest = from_yaml(&fs::read_to_string(&path)?)?;
validate_actor_id(&bundle.identity.id)?;
let handle = github_handle_from_identity_id(&bundle.identity.id)?;
if bundle.identity.kind != "github" || bundle.identity.handle != handle {
return Err(format!("participant identity mismatch: {}", path.display()).into());
}
let local_path = home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"));
if local_path.is_file() {
skipped_local += 1;
println!("local {}", bundle.identity.id);
continue;
}
let peer_path = home
.join("peers")
.join("github")
.join(format!("{handle}.yaml"));
ensure_parent(&peer_path)?;
fs::write(&peer_path, to_yaml(&bundle)?)?;
imported += 1;
println!("{}", peer_path.display());
}
println!("imported users: {imported}");
if skipped_local > 0 {
println!("already local identities: {skipped_local}");
}
Ok(())
}
fn create(args: &[String]) -> RhoResult<()> {
let Some(repo_arg) = args.first() else {
usage();
};
let owner_identity = normalize_actor_id(
&arg_value(args, "--owner")
.or_else(active_identity)
.ok_or("rho repo create requires --owner or a global --profile/--identity")?,
)?;
let owner_handle = github_handle_from_identity_id(&owner_identity)?;
let repo_slug = github_repo_slug(repo_arg, &owner_handle)?;
let repo_name = repo_slug
.split_once('/')
.map(|(_, repo)| repo)
.ok_or("invalid GitHub repo slug")?;
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| default_project_root(&owner_handle, repo_name));
let visibility = repo_visibility_arg(args)?;
fs::create_dir_all(&root)?;
if !root.join(".git").is_dir() {
git_init_main(&root)?;
}
configure_profile_ssh(&root, &owner_handle)?;
init(&[
"--root".to_string(),
root.display().to_string(),
"--repo-id".to_string(),
repo_slug.clone(),
"--owner".to_string(),
owner_identity.clone(),
"--yes".to_string(),
])?;
add_user(&[
owner_identity.clone(),
"--root".to_string(),
root.display().to_string(),
])?;
protect_path(&[
format!(
"rho/messages/inbox/{}/**",
identity_inbox_relative_path(&owner_identity)?.display()
),
"--root".to_string(),
root.display().to_string(),
"--recipient".to_string(),
owner_identity.clone(),
])?;
sign_governance(&["--root".to_string(), root.display().to_string()])?;
doctor(&["--root".to_string(), root.display().to_string()])?;
git_add_relative(&root, ".gitattributes")?;
git_add_relative(&root, ".gitignore")?;
git_add_relative(&root, "rho")?;
git_add_relative(&root, "rho.yaml")?;
git_commit(&root, &format!("Initialize Rho project {repo_slug}"))?;
gh_repo_create(&root, &repo_slug, visibility)?;
git_push(&root, "origin", "main")?;
println!("repo: {repo_slug}");
println!("root: {}", root.display());
println!("pushed: main");
Ok(())
}
fn sync(args: &[String]) -> RhoResult<()> {
let active = normalize_actor_id(&active_identity().ok_or(
"rho repo sync requires a global --profile/--identity so Rho knows where to write",
)?)?;
let handle = github_handle_from_identity_id(&active)?;
let branch = arg_value(args, "--branch").unwrap_or_else(|| "main".to_string());
let repo_arg = args.iter().find(|arg| !arg.starts_with("--")).cloned();
let root = arg_value(args, "--root")
.map(PathBuf::from)
.or_else(|| {
repo_arg
.as_deref()
.and_then(repo_name_from_slug)
.map(|repo_name| default_project_root(&handle, repo_name))
})
.unwrap_or_else(|| PathBuf::from("."));
let source_slug = match repo_arg {
Some(value) => github_repo_slug(&value, &handle)?,
None => detect_remote_repo(&root, "upstream")
.or_else(|| detect_remote_repo(&root, "origin"))
.ok_or("could not infer source repo; pass owner/repo")?,
};
let (source_owner, repo_name) = source_slug
.split_once('/')
.ok_or("invalid GitHub repo slug")?;
if !root.join(".git").is_dir() {
if source_owner == handle {
gh_repo_clone(&source_slug, &root)?;
} else {
ensure_join_clone(&root, &source_slug, &handle, repo_name)?;
}
}
if source_owner != handle {
let fork_slug = format!("{handle}/{repo_name}");
let sync_status = Command::new("gh")
.args([
"repo",
"sync",
&fork_slug,
"--source",
&source_slug,
"--branch",
&branch,
])
.status()?;
if !sync_status.success() {
return Err(format!("gh repo sync failed for {fork_slug} from {source_slug}").into());
}
ensure_remote(&root, "upstream", &github_ssh_url(&source_slug))?;
}
configure_profile_ssh(&root, &handle)?;
git_fetch(&root, "origin", &branch)?;
switch_branch(&root, &branch)?;
git_merge_ff(&root, &format!("origin/{branch}"))?;
init(&[
"--root".to_string(),
root.display().to_string(),
"--yes".to_string(),
])?;
println!("synced: {source_slug}");
println!("root: {}", root.display());
println!("read remote: upstream ({source_slug})");
println!(
"write remote: origin ({}/{repo_name})",
if source_owner == handle {
source_owner
} else {
&handle
}
);
Ok(())
}
fn sign_governance(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let repo: RepoManifest = from_yaml(&fs::read_to_string(root.join("rho/repo.yaml"))?)?;
let identity = match arg_value(args, "--identity") {
Some(identity) => normalize_actor_id(&identity)?,
None => repo
.repo
.owner
.clone()
.ok_or("repo owner missing; pass --identity explicitly")?,
};
validate_actor_id(&identity)?;
let files = governance_manifest_files(&root)?;
if files.is_empty() {
println!("governance files: none");
return Ok(());
}
for file in files {
let signed_path = root.join(&file);
let signature_path = governance_signature_path(&root, &file)?;
sign_file(&signed_path, &signature_path, &identity)?;
println!("{}", signature_path.display());
if !has_flag(args, "--no-stage") {
git_add_relative(&root, &file)?;
git_add_path(&root, &signature_path)?;
}
}
if !has_flag(args, "--no-stage") {
println!("staged governance signatures");
}
Ok(())
}
fn status(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest = from_yaml(&fs::read_to_string(&permissions_path)?)?;
let files = tracked_and_untracked_files(&root)?;
let mut protected_count = 0usize;
let mut warning_count = 0usize;
println!("rho status");
println!("root: {}", root.display());
for file in files {
let Some(rule) = permissions.permissions.iter().find(|rule| {
!rule.read.public
&& !rule.read.encrypted_to.is_empty()
&& path_matches_pattern(&file, &rule.pattern)
}) else {
continue;
};
protected_count += 1;
println!();
println!("{file}");
println!(" policy: {}", rule.pattern);
println!(" read: encrypted to {}", rule.read.encrypted_to.join(", "));
match index_blob(&root, &file) {
Some(content)
if yaml_top_level_kind(&content).as_deref() == Some("rho_recipient_envelope") =>
{
let missing = missing_recipients(&content, &rule.read.encrypted_to);
if missing.is_empty() {
println!(" staged: encrypted recipient envelope");
} else {
warning_count += 1;
println!(" staged: encrypted, but missing {}", missing.join(", "));
}
}
Some(content)
if yaml_top_level_kind(&content).as_deref() == Some("rho_transparent_file") =>
{
warning_count += 1;
println!(" staged: encrypted with repo key, not recipient envelope");
}
Some(_) => {
warning_count += 1;
println!(" staged: NOT encrypted");
}
None => {
println!(" staged: not staged");
println!(" next add: will use rho-crypt filter from .gitattributes");
}
}
}
if protected_count == 0 {
println!("protected files: none");
} else {
println!();
println!("protected files: {protected_count}");
}
if warning_count > 0 {
return Err(format!("{warning_count} protected file warning(s)").into());
}
Ok(())
}
#[derive(Debug, Serialize)]
struct CheckPrResult {
status: String,
actor: String,
role: String,
reason: String,
close_recommended: bool,
changed_files: Vec<String>,
violations: Vec<CheckPrViolation>,
}
#[derive(Debug, Serialize)]
struct CheckPrViolation {
path: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct RevocationsManifest {
#[serde(default)]
revocations: Vec<RevocationRecord>,
#[serde(default)]
blocks: Vec<RevocationRecord>,
}
#[derive(Debug, Deserialize)]
struct RevocationRecord {
#[serde(default)]
identity: String,
#[serde(default)]
actor: String,
#[serde(default)]
decision: String,
}
fn check_pr(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let changed_files = check_pr_changed_files(args, &root)?;
if !root.join("rho/repo.yaml").is_file() {
let actor = check_pr_actor(args).unwrap_or_else(|_| "unknown".to_string());
let result = CheckPrResult {
status: "pass".to_string(),
actor,
role: "unmanaged".to_string(),
reason: "rho repo not initialized; policy check skipped".to_string(),
close_recommended: false,
changed_files,
violations: Vec::new(),
};
print_check_pr_result(args, &result)?;
return Ok(());
}
let actor = check_pr_actor(args)?;
validate_actor_id(&actor)?;
let role = check_pr_role(&root, &actor)?;
let violations = check_pr_violations(&actor, &role, &changed_files);
let blocked_actor = role == "blocked";
let status = if blocked_actor || !violations.is_empty() {
"blocked"
} else if role == "unknown" {
"pending"
} else {
"pass"
};
let reason = check_pr_reason(status, &role, blocked_actor, &violations);
let result = CheckPrResult {
status: status.to_string(),
actor,
role,
reason,
close_recommended: status == "blocked",
changed_files,
violations,
};
print_check_pr_result(args, &result)?;
Ok(())
}
fn print_check_pr_result(args: &[String], result: &CheckPrResult) -> RhoResult<()> {
if arg_value(args, "--format").as_deref() == Some("json") || has_flag(args, "--json") {
print!("{}", to_json_pretty(&result)?);
} else {
println!("status: {}", result.status);
println!("actor: {}", result.actor);
println!("role: {}", result.role);
println!("reason: {}", result.reason);
for violation in &result.violations {
println!("blocked: {}: {}", violation.path, violation.reason);
}
}
Ok(())
}
fn check_pr_actor(args: &[String]) -> RhoResult<String> {
if let Some(actor) = arg_value(args, "--actor") {
return normalize_actor_id(&actor);
}
if let Some(event_path) = arg_value(args, "--event") {
let text = fs::read_to_string(&event_path)
.map_err(|error| format!("cannot read GitHub event file {event_path}: {error}"))?;
let event: serde_json::Value = from_json(&text)?;
for pointer in [
"/pull_request/user/login",
"/sender/login",
"/issue/user/login",
] {
if let Some(login) = event.pointer(pointer).and_then(|value| value.as_str())
&& !login.trim().is_empty()
{
return normalize_actor_id(login);
}
}
return Err(format!("GitHub event has no usable actor login: {event_path}").into());
}
Err("check-pr requires --actor or --event".into())
}
fn check_pr_role(root: &Path, actor: &str) -> RhoResult<String> {
if is_actor_blocked(root, actor)? {
return Ok("blocked".to_string());
}
let membership_path = root.join("rho/membership.yaml");
let membership: MembershipManifest = from_yaml(&fs::read_to_string(&membership_path)?)?;
for member in membership.members {
if member.identity == actor {
return Ok(match member.role.as_str() {
"owner" | "admin" => "admin".to_string(),
"collaborator" | "member" => "member".to_string(),
other => other.to_string(),
});
}
}
Ok("unknown".to_string())
}
fn is_actor_blocked(root: &Path, actor: &str) -> RhoResult<bool> {
let path = root.join("rho/policy/revocations.yaml");
if !path.is_file() {
return Ok(false);
}
let manifest: RevocationsManifest = from_yaml(&fs::read_to_string(path)?)?;
Ok(manifest
.revocations
.into_iter()
.chain(manifest.blocks)
.any(|record| {
let identity = if record.identity.is_empty() {
record.actor
} else {
record.identity
};
identity == actor
&& (record.decision.is_empty()
|| matches!(
record.decision.as_str(),
"block" | "blocked" | "deny" | "denied" | "revoked"
))
}))
}
fn check_pr_changed_files(args: &[String], root: &Path) -> RhoResult<Vec<String>> {
let mut files = repeated_arg(args, "--changed-file");
if files.is_empty()
&& let Some(event_path) = arg_value(args, "--event")
{
files = changed_files_from_event(&event_path)?;
}
if files.is_empty()
&& let Some(base_ref) = arg_value(args, "--base-ref")
{
files = changed_files_from_git(root, &base_ref)?;
}
files.sort();
files.dedup();
for file in &files {
validate_relative_safe_path(file)?;
}
Ok(files)
}
fn changed_files_from_event(event_path: &str) -> RhoResult<Vec<String>> {
let text = fs::read_to_string(event_path)
.map_err(|error| format!("cannot read GitHub event file {event_path}: {error}"))?;
let event: serde_json::Value = from_json(&text)?;
let Some(files) = event
.get("rho_changed_files")
.and_then(|value| value.as_array())
else {
return Ok(Vec::new());
};
Ok(files
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect())
}
fn changed_files_from_git(root: &Path, base_ref: &str) -> RhoResult<Vec<String>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["diff", "--name-only", "-z", &format!("{base_ref}...HEAD")])
.output()?;
if !output.status.success() {
return Err(format!("git diff failed against base ref {base_ref}").into());
}
Ok(output
.stdout
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
.filter_map(|entry| String::from_utf8(entry.to_vec()).ok())
.collect())
}
fn check_pr_violations(actor: &str, role: &str, changed_files: &[String]) -> Vec<CheckPrViolation> {
let mut violations = Vec::new();
for path in changed_files {
let reason = if role == "blocked" {
Some("actor is blocked".to_string())
} else if role == "admin" || (role == "unknown" && is_unknown_join_path(actor, path)) {
None
} else if is_admin_only_path(path) {
Some("only admins may change Rho policy, membership, participants, tools, actions, or workflows".to_string())
} else if role == "unknown" && !is_unknown_join_path(actor, path) {
Some("unknown actors may only submit their own join request files".to_string())
} else {
None
};
if let Some(reason) = reason {
violations.push(CheckPrViolation {
path: path.clone(),
reason,
});
}
}
violations
}
fn is_admin_only_path(path: &str) -> bool {
path == "rho.yaml"
|| path == ".gitmodules"
|| path.starts_with("rho/policy/")
|| path == "rho/membership.yaml"
|| path.starts_with("rho/participants/")
|| path.starts_with("rho/tools/")
|| path.starts_with(".github/workflows/")
|| path.starts_with("action/")
}
fn is_unknown_join_path(actor: &str, path: &str) -> bool {
let Ok(handle) = github_handle_from_identity_id(actor) else {
return false;
};
path == format!("rho/participants/{handle}.yaml")
|| path.starts_with("rho/requests/join-")
|| path.starts_with(&format!("rho/requests/{handle}/join-"))
}
fn check_pr_reason(
status: &str,
role: &str,
blocked_actor: bool,
violations: &[CheckPrViolation],
) -> String {
if blocked_actor {
return "actor is blocked by Rho policy".to_string();
}
if let Some(first) = violations.first() {
return format!("{}: {}", first.path, first.reason);
}
match (status, role) {
("pending", "unknown") => "unknown actor requires admin review".to_string(),
("pass", "admin") => "admin actor is allowed".to_string(),
("pass", "member") => "member actor is allowed".to_string(),
_ => "Rho PR policy evaluated".to_string(),
}
}
fn audit_encryption(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest = from_yaml(&fs::read_to_string(&permissions_path)?)?;
let files = tracked_files(&root)?;
let mut protected_count = 0usize;
let mut checked_blobs = 0usize;
let mut failures = Vec::new();
println!("rho encryption audit");
println!("root: {}", root.display());
for file in files {
let Some(rule) = permissions.permissions.iter().find(|rule| {
!rule.read.public
&& !rule.read.encrypted_to.is_empty()
&& path_matches_pattern(&file, &rule.pattern)
}) else {
continue;
};
protected_count += 1;
println!();
println!("{file}");
if let Some(content) = index_blob(&root, &file) {
checked_blobs += 1;
audit_encrypted_blob(
"index",
&file,
&content,
&rule.read.encrypted_to,
&mut failures,
);
} else {
println!(" index: not present");
}
if let Some(content) = head_blob(&root, &file) {
checked_blobs += 1;
audit_encrypted_blob(
"HEAD",
&file,
&content,
&rule.read.encrypted_to,
&mut failures,
);
} else {
println!(" HEAD: not present");
}
}
if protected_count == 0 {
println!("protected files: none");
} else {
println!();
println!("protected files: {protected_count}");
println!("checked encrypted blobs: {checked_blobs}");
}
if failures.is_empty() {
println!("rho encryption audit passed");
return Ok(());
}
eprintln!("rho encryption audit failed");
for failure in &failures {
eprintln!("- {failure}");
}
Err(format!("{} encryption audit failure(s)", failures.len()).into())
}
fn audit_encrypted_blob(
label: &str,
relative_path: &str,
content: &str,
recipients: &[String],
failures: &mut Vec<String>,
) {
match yaml_top_level_kind(content).as_deref() {
Some("rho_recipient_envelope") => {
let missing = missing_recipients(content, recipients);
if missing.is_empty() {
println!(" {label}: encrypted recipient envelope");
} else {
println!(" {label}: encrypted, but missing {}", missing.join(", "));
failures.push(format!(
"{label} blob missing recipient(s) for {relative_path}: {}",
missing.join(", ")
));
}
return;
}
Some("rho_transparent_file") => {
println!(" {label}: encrypted with repo key, not recipient envelope");
failures.push(format!(
"{label} blob uses repo-key encryption where recipients are required: {relative_path}"
));
return;
}
_ => {}
}
println!(" {label}: NOT encrypted");
failures.push(format!("{label} blob is not encrypted: {relative_path}"));
}
fn init(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
if root.join("rho/repo.yaml").is_file() && !has_flag(args, "--force") {
return init_existing_repo(args, &root);
}
let repo_id = arg_value(args, "--repo-id")
.or_else(|| detect_github_remote(&root))
.map(|value| normalize_repo_id(&value))
.transpose()?
.ok_or(
"could not infer repo id; pass --repo-id owner/repo or https://github.com/owner/repo",
)?;
validate_repo_id(&repo_id)?;
let owner = arg_value(args, "--owner")
.or_else(active_identity)
.map(|value| normalize_actor_id(&value))
.transpose()?
.unwrap_or_else(|| "repo-owner".to_string());
validate_actor_id(&owner)?;
let force = has_flag(args, "--force");
if !has_flag(args, "--yes") {
println!("Rho repo init");
println!(" root: {}", root.display());
println!(" repo id: {repo_id}");
println!(" owner: {owner}");
print!("Use these values? [Y/n] ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer == "n" || answer == "no" {
return Err("rho repo init cancelled".into());
}
}
fs::create_dir_all(root.join("rho/participants"))?;
fs::create_dir_all(root.join("rho/policy"))?;
fs::create_dir_all(root.join("rho/tools"))?;
fs::create_dir_all(root.join("rho/requests"))?;
fs::create_dir_all(root.join("rho/control/outbox"))?;
fs::create_dir_all(root.join("rho/messages/inbox"))?;
fs::create_dir_all(root.join("workspace"))?;
write_if_missing_or_forced(
&root.join("rho.yaml"),
&format!(
"rho: 1\nproject:\n id: {}\n owner: {}\ntransport:\n kind: git\nmembers:\n file: rho/membership.yaml\nparticipants:\n path: rho/participants/\npermissions:\n file: rho/policy/permissions.yaml\ncrypto:\n file: rho/policy/crypto.yaml\nauth:\n file: rho/policy/auth.yaml\ntools:\n path: rho/tools/\nresources:\n file: rho/resources.yaml\n",
yaml_quote(&repo_id),
yaml_quote(&owner),
),
force,
)?;
write_if_missing_or_forced(
&root.join("rho/repo.yaml"),
&format!(
"version: 1\nrepo:\n id: {}\n owner: {}\n created_at: {}\n",
yaml_quote(&repo_id),
yaml_quote(&owner),
yaml_quote(&rho_core::now_rfc3339()),
),
force,
)?;
write_if_missing_or_forced(
&root.join("rho/membership.yaml"),
&format!(
"version: 1\nmembers:\n - identity: {}\n role: owner\n",
yaml_quote(&owner),
),
force,
)?;
write_if_missing_or_forced(
&root.join("rho/policy/paths.yaml"),
"version: 1\npaths:\n repo_root: rho://repo/\n rho_root: rho://repo/rho/\n workspace: rho://repo/workspace/\n requests: rho://repo/rho/requests/\n control_outbox: rho://repo/rho/control/outbox/\n",
force,
)?;
write_if_missing_or_forced(
&root.join("rho/policy/permissions.yaml"),
"version: 1\npermissions: []\n",
force,
)?;
write_if_missing_or_forced(
&root.join("rho/policy/crypto.yaml"),
"version: 1\ncrypto:\n signatures: ssh\n encryption: age-recipient-envelope\n transparent_git_filters: disabled\n",
force,
)?;
write_if_missing_or_forced(
&root.join("rho/policy/auth.yaml"),
"version: 1\nauth:\n identity_proof: github-profile-fragment-claim\n trust: local\n",
force,
)?;
write_if_missing_or_forced(
&root.join("rho/policy/revocations.yaml"),
"version: 1\nrevocations: []\n",
force,
)?;
write_if_missing_or_forced(
&root.join("rho/resources.yaml"),
"version: 1\nresources: []\n",
force,
)?;
write_if_missing_or_forced(
&root.join(".gitignore"),
"sandbox/\nrho/private/\nrho/local/\n",
false,
)?;
write_if_missing_or_forced(
&root.join(".gitattributes"),
"*.rhoenc binary\n*.rhosig.yaml text\nrho/participants/*.yaml text\nrho/requests/*.yaml text\n",
false,
)?;
write_tool(&root, "run_mock", "run_mock_data", &owner, true, force)?;
write_tool(&root, "run_real", "run_real_data", &owner, true, force)?;
write_tool(
&root,
"publish_results",
"release_results",
&owner,
true,
force,
)?;
println!("{}", root.join("rho.yaml").display());
println!("{}", root.join("rho/repo.yaml").display());
println!("{}", root.join("rho/membership.yaml").display());
println!("{}", root.join("rho/policy/paths.yaml").display());
println!("{}", root.join("rho/policy/permissions.yaml").display());
println!("{}", root.join("rho/policy/crypto.yaml").display());
println!("{}", root.join("rho/policy/auth.yaml").display());
println!("{}", root.join("rho/policy/revocations.yaml").display());
println!("{}", root.join("rho/resources.yaml").display());
println!("{}", root.join("rho/tools/run_mock.yaml").display());
println!("{}", root.join("rho/tools/run_real.yaml").display());
println!("{}", root.join("rho/tools/publish_results.yaml").display());
Ok(())
}
fn init_existing_repo(args: &[String], root: &Path) -> RhoResult<()> {
ensure_repo(root)?;
println!("Rho repo already initialized");
println!(" root: {}", root.display());
println!(" repo: {}", root.join("rho/repo.yaml").display());
if !has_flag(args, "--yes") {
print!("Install filters and import participants? [Y/n] ");
io::stdout().flush()?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?;
let answer = answer.trim().to_ascii_lowercase();
if answer == "n" || answer == "no" {
return Err("rho repo init maintenance cancelled".into());
}
}
let root_arg = root.display().to_string();
install_filters(&["--root".to_string(), root_arg.clone()])?;
import_users(&["--root".to_string(), root_arg])?;
Ok(())
}
fn add_user(args: &[String]) -> RhoResult<()> {
let Some(identity_arg) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_arg)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let rho_home = rho_home();
let local_path = rho_home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"));
let peer_path = rho_home
.join("peers")
.join("github")
.join(format!("{handle}.yaml"));
let bundle = if local_path.is_file() {
let local: LocalIdentityManifest = from_yaml(&fs::read_to_string(&local_path)?)?;
let self_signature = sign_identity_self_signature(&local)?;
IdentityBundleManifest {
version: 1,
identity: local.local_identity.identity,
self_signature: Some(self_signature),
}
} else if peer_path.is_file() {
from_yaml::<IdentityBundleManifest>(&fs::read_to_string(&peer_path)?)?
} else {
return Err(format!(
"identity not found in local RHO_HOME: {identity_id}; run `rho id init --github {handle}` or `rho id import <file>` first"
)
.into());
};
if bundle.identity.id != identity_id {
return Err(format!(
"identity mismatch: requested {identity_id}, found {}",
bundle.identity.id
)
.into());
}
let out = root
.join("rho")
.join("participants")
.join(format!("{handle}.yaml"));
ensure_parent(&out)?;
fs::write(&out, to_yaml(&bundle)?)?;
println!("{}", out.display());
add_member_if_missing(&root, &identity_id)?;
Ok(())
}
fn join(args: &[String]) -> RhoResult<()> {
let Some(identity_arg) = args.first() else {
usage();
};
if is_github_repo_join_arg(identity_arg) {
return join_github_repo(args);
}
let identity_id = normalize_actor_id(identity_arg)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
require_local_or_peer_identity(&identity_id)?;
if !has_flag(args, "--no-branch") {
switch_branch(&root, &format!("{handle}/join-rho"))?;
}
add_user(&[
identity_id.clone(),
"--root".to_string(),
root.display().to_string(),
])?;
println!("join branch: {handle}/join-rho");
println!("stage: rho/participants/{handle}.yaml rho/membership.yaml");
println!("next: rho commit -m \"Request Rho access for {handle}\"");
print_create_pr_next_step(
&root,
&format!("Request Rho access for {handle}"),
&format!("Add {identity_id} as a pending Rho participant."),
);
finish_branch_work(
args,
&root,
&["rho/participants", "rho/membership.yaml"],
&format!("Request Rho access for {handle}"),
&format!("Request Rho access for {handle}"),
&format!("Add {identity_id} as a pending Rho participant."),
)?;
Ok(())
}
fn join_github_repo(args: &[String]) -> RhoResult<()> {
let repo_arg = args.first().ok_or("missing GitHub repository")?;
let identity_id = normalize_actor_id(
&active_identity().ok_or("rho repo join <owner/repo> requires --profile or --identity")?,
)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let repo_slug = github_repo_slug(repo_arg, &handle)?;
let repo_name = repo_slug
.split_once('/')
.map(|(_, repo)| repo)
.ok_or("invalid GitHub repo slug")?;
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| default_project_root(&handle, repo_name));
ensure_join_clone(&root, &repo_slug, &handle, repo_name)?;
init(&[
"--root".to_string(),
root.display().to_string(),
"--yes".to_string(),
])?;
join(&[
identity_id,
"--root".to_string(),
root.display().to_string(),
])?;
finish_branch_work(
args,
&root,
&["rho/participants", "rho/membership.yaml"],
&format!("Request Rho access for {handle}"),
&format!("Request Rho access for {handle}"),
&format!("Add github/{handle} as a Rho participant."),
)?;
Ok(())
}
fn ensure_join_clone(root: &Path, repo_slug: &str, handle: &str, repo_name: &str) -> RhoResult<()> {
let fork_slug = format!("{handle}/{repo_name}");
if root.join(".git").is_dir() {
configure_profile_ssh(root, handle)?;
ensure_remote(root, "origin", &github_ssh_url(&fork_slug))?;
ensure_remote(root, "upstream", &github_ssh_url(repo_slug))?;
return Ok(());
}
let fork_status = Command::new("gh")
.args(["repo", "fork", repo_slug, "--clone=false"])
.status()?;
if !fork_status.success() {
let view_status = Command::new("gh")
.args(["repo", "view", &fork_slug])
.status()?;
if !view_status.success() {
return Err(format!("gh repo fork failed for {repo_slug}").into());
}
}
if let Some(parent) = root.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let clone_status = Command::new("gh")
.args(["repo", "clone", &fork_slug])
.arg(root)
.status()?;
if !clone_status.success() {
return Err(format!("gh repo clone failed for {fork_slug}").into());
}
configure_profile_ssh(root, handle)?;
ensure_remote(root, "origin", &github_ssh_url(&fork_slug))?;
ensure_remote(root, "upstream", &github_ssh_url(repo_slug))?;
Ok(())
}
fn gh_repo_clone(repo_slug: &str, root: &Path) -> RhoResult<()> {
if let Some(parent) = root.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let status = Command::new("gh")
.args(["repo", "clone", repo_slug])
.arg(root)
.status()?;
if !status.success() {
return Err(format!("gh repo clone failed for {repo_slug}").into());
}
ensure_remote(root, "origin", &github_ssh_url(repo_slug))?;
Ok(())
}
fn ensure_remote(root: &Path, name: &str, url: &str) -> RhoResult<()> {
if git_remote_exists(root, name) {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "set-url", name, url])
.status()?;
if !status.success() {
return Err(format!("git remote set-url failed for {name}").into());
}
} else {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "add", name, url])
.status()?;
if !status.success() {
return Err(format!("git remote add failed for {name}").into());
}
}
Ok(())
}
fn git_remote_exists(root: &Path, name: &str) -> bool {
Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "get-url", name])
.output()
.is_ok_and(|output| output.status.success())
}
fn configure_profile_ssh(root: &Path, handle: &str) -> RhoResult<()> {
if default_ssh_authenticates_as(handle)? {
return Ok(());
}
let key = github_ssh_key_for_handle(handle)?.ok_or_else(|| {
format!(
"could not find an SSH key that authenticates to GitHub as {handle}; set {}=/path/to/key or configure GitHub SSH access",
github_ssh_key_env_name(handle)
)
})?;
let command = format!(
"ssh -i {} -o IdentitiesOnly=yes",
shell_quote(&key.display().to_string())
);
git_config(root, "core.sshCommand", &command)?;
println!("git ssh identity: {handle} via {}", key.display());
Ok(())
}
fn default_ssh_authenticates_as(handle: &str) -> RhoResult<bool> {
let output = Command::new("ssh")
.args(["-o", "BatchMode=yes", "-T", "git@github.com"])
.output()?;
Ok(ssh_auth_output_matches(
handle,
&output.stdout,
&output.stderr,
))
}
fn github_ssh_key_for_handle(handle: &str) -> RhoResult<Option<PathBuf>> {
let env_name = github_ssh_key_env_name(handle);
if let Ok(value) = env::var(&env_name)
&& !value.trim().is_empty()
{
let path = PathBuf::from(value);
if ssh_key_authenticates_as(&path, handle)? {
return Ok(Some(path));
}
return Err(format!("{} does not authenticate to GitHub as {handle}", env_name).into());
}
for path in ssh_key_candidates()? {
if ssh_key_authenticates_as(&path, handle)? {
return Ok(Some(path));
}
}
Ok(None)
}
fn ssh_key_candidates() -> RhoResult<Vec<PathBuf>> {
let ssh_dir = env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".ssh");
if !ssh_dir.is_dir() {
return Ok(Vec::new());
}
let mut candidates = Vec::new();
for entry in fs::read_dir(ssh_dir)? {
let path = entry?.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if name.ends_with(".pub")
|| name.starts_with("known_hosts")
|| matches!(name, "config" | "authorized_keys")
{
continue;
}
if path
.with_extension(format!(
"{}pub",
path.extension()
.and_then(|value| value.to_str())
.map(|ext| format!("{ext}."))
.unwrap_or_default()
))
.is_file()
|| PathBuf::from(format!("{}.pub", path.display())).is_file()
|| name.starts_with("id_")
{
candidates.push(path);
}
}
candidates.sort();
Ok(candidates)
}
fn ssh_key_authenticates_as(path: &Path, handle: &str) -> RhoResult<bool> {
let output = Command::new("ssh")
.arg("-i")
.arg(path)
.args([
"-o",
"IdentitiesOnly=yes",
"-o",
"BatchMode=yes",
"-T",
"git@github.com",
])
.output()?;
Ok(ssh_auth_output_matches(
handle,
&output.stdout,
&output.stderr,
))
}
fn ssh_auth_output_matches(handle: &str, stdout: &[u8], stderr: &[u8]) -> bool {
let mut text = String::from_utf8_lossy(stdout).to_string();
text.push_str(&String::from_utf8_lossy(stderr));
text.contains(&format!("Hi {handle}!"))
}
fn github_ssh_key_env_name(handle: &str) -> String {
let suffix = handle
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect::<String>();
format!("RHO_GITHUB_SSH_KEY_{suffix}")
}
fn git_fetch(root: &Path, remote: &str, branch: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["fetch", remote, branch])
.status()?;
if !status.success() {
return Err(format!("git fetch failed for {remote} {branch}").into());
}
Ok(())
}
fn git_merge_ff(root: &Path, ref_name: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["merge", "--ff-only", ref_name])
.status()?;
if !status.success() {
return Err(format!("git merge --ff-only failed for {ref_name}").into());
}
Ok(())
}
fn detect_remote_repo(root: &Path, remote_name: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "get-url", remote_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
providers::github::repo_candidate_from_remote(&remote)
}
fn repo_name_from_slug(value: &str) -> Option<&str> {
value.split_once('/').map(|(_, repo)| repo)
}
fn github_ssh_url(repo_slug: &str) -> String {
format!("git@github.com:{repo_slug}.git")
}
fn default_project_root(profile_handle: &str, repo_name: &str) -> PathBuf {
env::var("RHO_PROJECTS_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("rho")
})
.join(profile_handle)
.join("projects")
.join(repo_name)
}
fn is_github_repo_join_arg(value: &str) -> bool {
value.contains('/') && !value.starts_with("github/") && !value.starts_with("rho://")
}
fn admit(args: &[String]) -> RhoResult<()> {
let Some(identity_arg) = args.first() else {
usage();
};
let identity_id = normalize_actor_id(identity_arg)?;
let handle = github_handle_from_identity_id(&identity_id)?;
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let rho_bin = arg_value(args, "--rho-bin").unwrap_or_else(default_rho_bin);
ensure_repo(&root)?;
require_local_or_peer_identity(&identity_id)?;
let branched = !has_flag(args, "--no-branch");
if branched {
switch_branch(&root, &format!("review/{handle}/admit-rho"))?;
}
add_user(&[
identity_id.clone(),
"--root".to_string(),
root.display().to_string(),
])?;
let inbox_pattern = format!(
"rho/messages/inbox/{}/**",
identity_inbox_relative_path(&identity_id)?.display()
);
protect_path(&[
inbox_pattern.clone(),
"--root".to_string(),
root.display().to_string(),
"--rho-bin".to_string(),
rho_bin,
"--recipient".to_string(),
identity_id.clone(),
])?;
sign_governance(&[
"--root".to_string(),
root.display().to_string(),
"--no-stage".to_string(),
])?;
doctor(&["--root".to_string(), root.display().to_string()])?;
if branched {
println!("admit branch: review/{handle}/admit-rho");
} else {
println!("admit branch: {}", current_branch(&root)?);
}
println!("protected inbox: {inbox_pattern}");
println!("stage: .gitattributes rho");
if !has_flag(args, "--no-next") {
println!("next: rho commit -m \"Admit {handle} to Rho project\"");
print_create_pr_next_step(
&root,
&format!("Admit {handle} to Rho project"),
&format!("Admit {identity_id} and add the encrypted inbox policy."),
);
}
finish_branch_work(
args,
&root,
&[".gitattributes", "rho"],
&format!("Admit {handle} to Rho project"),
&format!("Admit {handle} to Rho project"),
&format!("Admit {identity_id} and add the encrypted inbox policy."),
)?;
Ok(())
}
fn admit_pr(args: &[String]) -> RhoResult<()> {
let Some(pr_number) = args.first() else {
usage();
};
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let pr = gh_pr_metadata(&root, pr_number)?;
gh_pr_checkout(&root, pr_number)?;
import_users(&["--root".to_string(), root.display().to_string()])?;
let author = pr.author.login.clone();
let identity = normalize_actor_id(&author)?;
verify_github_identity_with_rho(&identity)?;
let mut admit_args = vec![
identity.clone(),
"--root".to_string(),
root.display().to_string(),
];
if let Some(rho_bin) = arg_value(args, "--rho-bin") {
admit_args.push("--rho-bin".to_string());
admit_args.push(rho_bin);
}
let new_pr = has_flag(args, "--new-pr");
if !new_pr || has_flag(args, "--no-branch") {
admit_args.push("--no-branch".to_string());
}
admit_args.push("--no-next".to_string());
admit(&admit_args)?;
if new_pr {
finish_branch_work(
args,
&root,
&[".gitattributes", "rho"],
&format!("Admit {author} to Rho project"),
&format!("Admit {author} to Rho project"),
&format!("Admit {identity} and add the encrypted inbox policy."),
)?;
} else {
finish_pr_branch_work(
args,
&root,
&pr,
&[".gitattributes", "rho"],
&format!("Admit {author} to Rho project"),
)?;
}
Ok(())
}
fn verify_github_identity_with_rho(identity: &str) -> RhoResult<()> {
let exe = env::current_exe()?;
let status = Command::new(exe)
.args(["id", "verify-github", "--identity", identity])
.status()?;
if !status.success() {
return Err(format!("github identity verification failed for {identity}").into());
}
Ok(())
}
fn create_pr(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let title = arg_value(args, "--title").ok_or("missing required argument: --title")?;
let body = arg_value(args, "--body").unwrap_or_default();
let open_browser = has_flag(args, "--web") || has_flag(args, "--open");
ensure_repo(&root)?;
let output = providers::github::create_pull_request(&root, &title, &body, open_browser)?;
if output.is_empty() {
println!("pull request: opened in browser");
} else {
println!("pull request: {output}");
}
Ok(())
}
fn merge_pr(args: &[String]) -> RhoResult<()> {
let Some(pr_number) = args.first() else {
usage();
};
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
let strategy = pr_merge_strategy(args)?;
let mut command = Command::new("gh");
command
.current_dir(&root)
.args(["pr", "merge", pr_number, strategy]);
if has_flag(args, "--delete-branch") {
command.arg("--delete-branch");
}
let status = command.status()?;
if !status.success() {
return Err(format!("gh pr merge failed for {pr_number}").into());
}
println!("merged pull request: {pr_number}");
Ok(())
}
fn pr_merge_strategy(args: &[String]) -> RhoResult<&'static str> {
let selected = [
(has_flag(args, "--merge"), "--merge"),
(has_flag(args, "--squash"), "--squash"),
(has_flag(args, "--rebase"), "--rebase"),
]
.iter()
.filter_map(|(present, value)| present.then_some(*value))
.collect::<Vec<_>>();
match selected.as_slice() {
[value] => Ok(value),
[] => Ok("--merge"),
_ => Err("choose only one of --merge, --squash, or --rebase".into()),
}
}
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 finish_branch_work(
args: &[String],
root: &Path,
stage_paths: &[&str],
commit_message: &str,
pr_title: &str,
pr_body: &str,
) -> RhoResult<()> {
let want_pr = has_flag(args, "--pr");
let want_commit = want_pr || has_flag(args, "--commit");
let want_push = want_pr || has_flag(args, "--push");
if !want_commit && !want_push && !want_pr {
return Ok(());
}
if want_commit {
for path in stage_paths {
git_add_relative(root, path)?;
}
if git_has_staged_changes(root)? {
git_commit(root, commit_message)?;
println!("committed: {commit_message}");
} else {
println!("commit: no staged changes");
}
}
if want_push {
git_push_current_branch(root)?;
println!("pushed: {}", current_branch(root)?);
}
if want_pr {
create_pr(&[
"--root".to_string(),
root.display().to_string(),
"--title".to_string(),
pr_title.to_string(),
"--body".to_string(),
pr_body.to_string(),
])?;
}
Ok(())
}
fn finish_pr_branch_work(
args: &[String],
root: &Path,
pr: &GhPrMetadata,
stage_paths: &[&str],
commit_message: &str,
) -> RhoResult<()> {
let want_pr = has_flag(args, "--pr");
let want_commit = want_pr || has_flag(args, "--commit");
let want_push = want_pr || has_flag(args, "--push");
if !want_commit && !want_push && !want_pr {
return Ok(());
}
if want_commit {
for path in stage_paths {
git_add_relative(root, path)?;
}
if git_has_staged_changes(root)? {
git_commit(root, commit_message)?;
println!("committed: {commit_message}");
} else {
println!("commit: no staged changes");
}
}
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 require_local_or_peer_identity(identity_id: &str) -> RhoResult<()> {
let handle = github_handle_from_identity_id(identity_id)?;
let home = rho_home();
let local_path = home
.join("identities")
.join("github")
.join(format!("{handle}.yaml"));
let peer_path = home
.join("peers")
.join("github")
.join(format!("{handle}.yaml"));
if local_path.is_file() || peer_path.is_file() {
return Ok(());
}
Err(format!(
"identity not found in local RHO_HOME: {identity_id}; run `rho id init --github {handle}` or `rho id import <file>` first"
)
.into())
}
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 git_init_main(root: &Path) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.arg("init")
.status()?;
if !status.success() {
return Err(format!("git init failed in {}", root.display()).into());
}
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["switch", "-C", "main"])
.status()?;
if !status.success() {
return Err(format!("git switch main failed in {}", root.display()).into());
}
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_has_staged_changes(root: &Path) -> RhoResult<bool> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["diff", "--cached", "--quiet"])
.status()?;
match status.code() {
Some(0) => Ok(false),
Some(1) => Ok(true),
_ => Err(format!("git diff --cached --quiet failed in {}", root.display()).into()),
}
}
fn git_push_current_branch(root: &Path) -> RhoResult<()> {
let branch = current_branch(root)?;
git_push(root, "origin", &branch)
}
fn git_push(root: &Path, remote: &str, branch: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["push", "-u", remote, branch])
.status()?;
if !status.success() {
return Err(format!("git push failed for {remote} {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 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 gh_repo_create(root: &Path, repo_slug: &str, visibility: &str) -> RhoResult<()> {
let status = Command::new("gh")
.args(["repo", "create", repo_slug, visibility, "--source"])
.arg(root)
.args(["--remote", "origin"])
.status()?;
if !status.success() {
return Err(format!("gh repo create failed for {repo_slug}").into());
}
ensure_remote(root, "origin", &github_ssh_url(repo_slug))?;
Ok(())
}
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 gh_pr_metadata(root: &Path, pr_number: &str) -> RhoResult<GhPrMetadata> {
let output = Command::new("gh")
.current_dir(root)
.args([
"pr",
"view",
pr_number,
"--json",
"author,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 repo_visibility_arg(args: &[String]) -> RhoResult<&'static str> {
let values = [
(has_flag(args, "--public"), "--public"),
(has_flag(args, "--private"), "--private"),
(has_flag(args, "--internal"), "--internal"),
];
let selected = values
.iter()
.filter_map(|(present, value)| present.then_some(*value))
.collect::<Vec<_>>();
match selected.as_slice() {
[value] => Ok(value),
[] => {
if has_flag(args, "--yes") {
Ok("--public")
} else {
Err("choose repository visibility: --public, --private, or --internal".into())
}
}
_ => Err("choose only one of --public, --private, or --internal".into()),
}
}
fn github_repo_slug(value: &str, default_owner: &str) -> RhoResult<String> {
let slug = if value.contains('/') {
value.to_string()
} else {
format!("{default_owner}/{value}")
};
let Some((owner, repo)) = slug.split_once('/') else {
return Err(format!("invalid GitHub repository: {value}").into());
};
validate_github_repo_part(owner, "owner")?;
validate_github_repo_part(repo, "repo")?;
Ok(slug)
}
fn validate_github_repo_part(value: &str, label: &str) -> RhoResult<()> {
if value.is_empty()
|| !value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
{
return Err(format!("invalid GitHub {label}: {value}").into());
}
Ok(())
}
fn add_member_if_missing(root: &Path, identity_id: &str) -> RhoResult<()> {
let membership_path = root.join("rho/membership.yaml");
let text = fs::read_to_string(&membership_path)?;
if let Ok(membership) = from_yaml::<MembershipManifest>(&text)
&& membership
.members
.iter()
.any(|member| member.identity == identity_id)
{
return Ok(());
}
let repo_path = root.join("rho/repo.yaml");
let repo: RepoManifest = from_yaml(&fs::read_to_string(&repo_path)?)?;
let role = if repo.repo.owner.as_deref() == Some(identity_id) {
"owner"
} else {
"collaborator"
};
let mut updated = text;
if !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(&format!(
" - identity: {}\n role: {role}\n",
yaml_quote(identity_id)
));
fs::write(&membership_path, updated)?;
println!("{}", membership_path.display());
Ok(())
}
#[derive(Debug, Deserialize)]
struct RepoManifest {
repo: RepoRecord,
}
#[derive(Debug, Deserialize)]
struct RepoRecord {
id: String,
owner: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RhoRootManifest {
project: Option<RhoRootProject>,
members: Option<RhoFileRef>,
participants: Option<RhoPathRef>,
permissions: Option<RhoFileRef>,
crypto: Option<RhoFileRef>,
auth: Option<RhoFileRef>,
tools: Option<RhoPathRef>,
resources: Option<RhoFileRef>,
}
#[derive(Debug, Deserialize)]
struct RhoRootProject {
id: Option<String>,
owner: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RhoFileRef {
file: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RhoPathRef {
path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct MembershipManifest {
#[serde(default)]
members: Vec<MemberRecord>,
}
#[derive(Debug, Deserialize)]
struct MemberRecord {
identity: String,
role: String,
}
#[derive(Debug, Deserialize)]
struct GhPrMetadata {
author: GhPrAuthor,
#[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 GhPrAuthor {
login: String,
}
#[derive(Debug, Deserialize)]
struct GhPrRepository {
name: String,
}
#[derive(Debug, Deserialize)]
struct GhPrOwner {
login: String,
}
#[derive(Debug, Deserialize)]
struct CryptoPolicyManifest {
crypto: CryptoPolicy,
}
#[derive(Debug, Deserialize)]
struct CryptoPolicy {
signatures: Option<String>,
encryption: Option<String>,
transparent_git_filters: Option<String>,
filter: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct PermissionsManifest {
#[serde(default)]
permissions: Vec<PermissionRule>,
}
#[derive(Debug, Clone, Deserialize)]
struct PermissionRule {
#[serde(default)]
id: String,
pattern: String,
#[serde(default)]
read: ReadPermission,
#[serde(default)]
write: WritePermission,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct ReadPermission {
#[serde(default)]
public: bool,
#[serde(default)]
repo_key: bool,
#[serde(default)]
encrypted_to: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct WritePermission {
#[serde(default)]
allowed: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct DatasetManifest {
version: Option<u32>,
dataset: Option<DatasetRecord>,
uuid: Option<String>,
variants: Option<serde_yaml::Value>,
}
#[derive(Debug, Deserialize)]
struct DatasetRecord {
uuid: Option<String>,
name: Option<String>,
owner: Option<String>,
variants: Option<DatasetVariants>,
}
#[derive(Debug, Deserialize)]
struct DatasetVariants {
public: Option<DatasetVariant>,
mock: Option<DatasetVariant>,
real: Option<DatasetVariant>,
}
#[derive(Debug, Deserialize)]
struct DatasetVariant {
relative_path: Option<String>,
share_relative_path: Option<String>,
sha256: Option<String>,
source: Option<DatasetSource>,
}
#[derive(Debug, Deserialize)]
struct DatasetSource {
kind: Option<String>,
repo: Option<String>,
url: Option<String>,
revision: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RunReceiptManifest {
run_receipt: RunReceiptRecord,
}
#[derive(Debug, Deserialize)]
struct RunReceiptRecord {
request_id: String,
run_id: String,
runner: String,
status: String,
tier: String,
code_path: String,
code_sha256: String,
#[serde(default)]
input_path: Option<String>,
input_sha256: String,
output_sha256: String,
result_path: String,
}
#[derive(Debug, Deserialize)]
struct ResultManifest {
result: ResultRecord,
}
#[derive(Debug, Deserialize)]
struct ResultRecord {
request_id: String,
to: String,
run_id: String,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantManifest {
approval_grant: ApprovalGrantRecord,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantRecord {
action_id: String,
request_id: String,
tool_id: String,
action_type: String,
decision: String,
granted_by: String,
action: ApprovalGrantFile,
local_grant: ApprovalGrantFile,
code: ApprovalGrantInput,
private_input: ApprovalGrantPrivateInput,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantFile {
path: String,
sha256: String,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantInput {
kind: String,
path: String,
sha256: String,
}
#[derive(Debug, Deserialize)]
struct ApprovalGrantPrivateInput {
dataset_uuid: String,
tier: String,
sha256: String,
}
fn doctor(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
ensure_repo(&root)?;
if has_flag(args, "--governance") && has_flag(args, "--no-governance") {
return Err("--governance and --no-governance cannot be used together".into());
}
let check_governance = !has_flag(args, "--no-governance");
let mut failures = Vec::new();
let mut advisories = Vec::new();
let root_manifest_path = root.join("rho.yaml");
let root_manifest: Option<RhoRootManifest> =
read_yaml_check(&root_manifest_path, &mut failures);
let repo_path = root.join("rho/repo.yaml");
let repo: Option<RepoManifest> = read_yaml_check(&repo_path, &mut failures);
if let Some(repo) = &repo {
if let Err(error) = validate_repo_id(&repo.repo.id) {
failures.push(format!(
"invalid repo id in {}: {error}",
repo_path.display()
));
}
match repo.repo.owner.as_deref() {
Some(owner) => {
if let Err(error) = validate_actor_id(owner) {
failures.push(format!(
"invalid repo owner in {}: {error}",
repo_path.display()
));
}
}
None => failures.push(format!("missing repo.owner in {}", repo_path.display())),
}
}
if let Some(root_manifest) = &root_manifest {
check_rho_root_manifest(&root, root_manifest, repo.as_ref(), &mut failures);
}
require_file(&root.join("rho/policy/auth.yaml"), &mut failures);
require_file(&root.join("rho/policy/permissions.yaml"), &mut failures);
require_file(&root.join("rho/policy/crypto.yaml"), &mut failures);
require_file(&root.join("rho/resources.yaml"), &mut failures);
require_file(&root.join("rho/policy/revocations.yaml"), &mut failures);
require_file(&root.join(".gitattributes"), &mut failures);
let membership_path = root.join("rho/membership.yaml");
let membership: Option<MembershipManifest> = read_yaml_check(&membership_path, &mut failures);
let mut member_ids = Vec::new();
if let Some(membership) = &membership {
let mut seen_members = HashSet::new();
let mut owner_count = 0usize;
if membership.members.is_empty() {
failures.push(format!(
"membership has no members: {}",
membership_path.display()
));
}
for member in &membership.members {
if let Err(error) = validate_actor_id(&member.identity) {
failures.push(format!(
"invalid member identity {}: {error}",
member.identity
));
}
if !seen_members.insert(member.identity.clone()) {
failures.push(format!("duplicate member identity: {}", member.identity));
}
if member.role == "owner" {
owner_count += 1;
}
if !matches!(member.role.as_str(), "owner" | "admin" | "collaborator") {
failures.push(format!(
"unsupported role for {}: {}",
member.identity, member.role
));
}
member_ids.push(member.identity.clone());
check_participant(&root, &member.identity, &mut failures);
}
if owner_count == 0 {
failures.push("membership has no owner role".to_string());
}
if let Some(owner) = repo.as_ref().and_then(|repo| repo.repo.owner.as_ref())
&& !membership
.members
.iter()
.any(|member| member.identity == *owner && member.role == "owner")
{
failures.push(format!(
"repo owner is not listed as owner in membership: {owner}"
));
}
check_orphan_participants(&root, &seen_members, &mut failures)?;
}
check_legacy_inbox_paths(&root, &member_ids, &mut advisories)?;
let crypto_path = root.join("rho/policy/crypto.yaml");
let crypto: Option<CryptoPolicyManifest> = read_yaml_check(&crypto_path, &mut failures);
if let Some(crypto) = &crypto {
check_crypto_policy(&crypto.crypto, &root, &mut failures);
}
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: Option<PermissionsManifest> =
read_yaml_check(&permissions_path, &mut failures);
if let Some(permissions) = permissions {
check_permissions_policy(&root, &permissions, &member_ids, &mut failures)?;
}
check_dataset_manifests(&root, &member_ids, &mut failures)?;
let mut flow_report = None;
if has_flag(args, "--flow") {
if let Some(repo) = repo.as_ref() {
if let Some(owner) = repo.repo.owner.as_deref() {
flow_report = Some(check_flow_receipts(
&root,
&repo.repo.id,
owner,
&mut failures,
)?);
} else {
failures.push("flow audit requires repo.owner".to_string());
}
} else {
failures.push("flow audit requires repo manifest".to_string());
}
}
if check_governance {
if let Some(owner) = repo.as_ref().and_then(|repo| repo.repo.owner.as_deref()) {
check_governance_signatures(&root, owner, &mut failures)?;
} else {
failures.push("governance signatures require repo.owner".to_string());
}
}
if failures.is_empty() {
println!("rho repo doctor passed");
println!("{}", root.join("rho/repo.yaml").display());
println!("{}", root.join("rho/membership.yaml").display());
println!("{}", root.join("rho/policy/permissions.yaml").display());
println!("{}", root.join("rho/policy/crypto.yaml").display());
if has_flag(args, "--flow") {
println!("flow: passed");
if let Some(report) = flow_report {
for next_step in report.next_steps {
println!("flow next: {next_step}");
}
}
}
if check_governance {
println!("governance: passed");
}
for advisory in advisories {
println!("migration: {advisory}");
}
return Ok(());
}
eprintln!("rho repo doctor failed");
for failure in &failures {
eprintln!("- {failure}");
}
Err(format!("{} repo health check(s) failed", failures.len()).into())
}
fn read_yaml_check<T: for<'de> Deserialize<'de>>(
path: &Path,
failures: &mut Vec<String>,
) -> Option<T> {
match fs::read_to_string(path) {
Ok(text) => match from_yaml(&text) {
Ok(value) => Some(value),
Err(error) => {
failures.push(format!("invalid YAML in {}: {error}", path.display()));
None
}
},
Err(_) => {
failures.push(format!("missing required file: {}", path.display()));
None
}
}
}
fn require_file(path: &Path, failures: &mut Vec<String>) {
if !path.is_file() {
failures.push(format!("missing required file: {}", path.display()));
}
}
fn check_rho_root_manifest(
root: &Path,
manifest: &RhoRootManifest,
repo: Option<&RepoManifest>,
failures: &mut Vec<String>,
) {
match manifest
.project
.as_ref()
.and_then(|project| project.id.as_deref())
{
Some(id) => {
if let Some(repo) = repo
&& id != repo.repo.id
{
failures.push(format!(
"rho.yaml project.id mismatch: expected {}, got {id}",
repo.repo.id
));
}
}
None => failures.push("rho.yaml missing project.id".to_string()),
}
match manifest
.project
.as_ref()
.and_then(|project| project.owner.as_deref())
{
Some(owner) => {
if let Some(repo_owner) = repo.and_then(|repo| repo.repo.owner.as_deref())
&& owner != repo_owner
{
failures.push(format!(
"rho.yaml project.owner mismatch: expected {repo_owner}, got {owner}"
));
}
}
None => failures.push("rho.yaml missing project.owner".to_string()),
}
check_rho_file_ref(
root,
"members.file",
manifest
.members
.as_ref()
.and_then(|value| value.file.as_deref()),
"rho/membership.yaml",
failures,
);
check_rho_path_ref(
root,
"participants.path",
manifest
.participants
.as_ref()
.and_then(|value| value.path.as_deref()),
"rho/participants/",
failures,
);
check_rho_file_ref(
root,
"permissions.file",
manifest
.permissions
.as_ref()
.and_then(|value| value.file.as_deref()),
"rho/policy/permissions.yaml",
failures,
);
check_rho_file_ref(
root,
"crypto.file",
manifest
.crypto
.as_ref()
.and_then(|value| value.file.as_deref()),
"rho/policy/crypto.yaml",
failures,
);
check_rho_file_ref(
root,
"auth.file",
manifest
.auth
.as_ref()
.and_then(|value| value.file.as_deref()),
"rho/policy/auth.yaml",
failures,
);
check_rho_path_ref(
root,
"tools.path",
manifest
.tools
.as_ref()
.and_then(|value| value.path.as_deref()),
"rho/tools/",
failures,
);
check_rho_file_ref(
root,
"resources.file",
manifest
.resources
.as_ref()
.and_then(|value| value.file.as_deref()),
"rho/resources.yaml",
failures,
);
}
fn check_rho_file_ref(
root: &Path,
label: &str,
actual: Option<&str>,
expected: &str,
failures: &mut Vec<String>,
) {
let Some(actual) = non_empty(actual) else {
failures.push(format!("rho.yaml missing {label}"));
return;
};
if actual != expected {
failures.push(format!("rho.yaml {label} must be {expected}, got {actual}"));
}
let Some(path) = safe_repo_path(actual) else {
failures.push(format!(
"rho.yaml {label} is not a safe repo path: {actual}"
));
return;
};
if !root.join(path).is_file() {
failures.push(format!(
"rho.yaml {label} references missing file: {actual}"
));
}
}
fn check_rho_path_ref(
root: &Path,
label: &str,
actual: Option<&str>,
expected: &str,
failures: &mut Vec<String>,
) {
let Some(actual) = non_empty(actual) else {
failures.push(format!("rho.yaml missing {label}"));
return;
};
if actual != expected {
failures.push(format!("rho.yaml {label} must be {expected}, got {actual}"));
}
let Some(path) = safe_repo_path(actual.trim_end_matches('/')) else {
failures.push(format!(
"rho.yaml {label} is not a safe repo path: {actual}"
));
return;
};
if !root.join(path).is_dir() {
failures.push(format!(
"rho.yaml {label} references missing directory: {actual}"
));
}
}
fn check_participant(root: &Path, identity_id: &str, failures: &mut Vec<String>) {
let Ok(handle) = github_handle_from_identity_id(identity_id) else {
return;
};
let path = root.join("rho/participants").join(format!("{handle}.yaml"));
let Some(bundle): Option<IdentityBundleManifest> = read_yaml_check(&path, failures) else {
return;
};
if bundle.identity.id != identity_id {
failures.push(format!(
"participant id mismatch in {}: expected {}, got {}",
path.display(),
identity_id,
bundle.identity.id
));
}
if !bundle
.identity
.public_keys
.iter()
.any(|key| key.kind == "ssh-signing")
{
failures.push(format!(
"participant has no signing key: {}",
path.display()
));
}
if !bundle
.identity
.public_keys
.iter()
.any(|key| key.kind == "age-encryption" && key.algorithm == "age-x25519")
{
failures.push(format!(
"participant has no age encryption key: {}",
path.display()
));
}
match bundle.self_signature.as_ref() {
Some(_) => {
if let Err(error) = verify_identity_bundle_self_signature(&bundle) {
failures.push(format!(
"participant self-signature invalid for {}: {error}",
path.display()
));
}
}
None => failures.push(format!(
"participant self-signature missing: {}",
path.display()
)),
}
}
fn check_orphan_participants(
root: &Path,
member_ids: &HashSet<String>,
failures: &mut Vec<String>,
) -> RhoResult<()> {
let participants_dir = root.join("rho/participants");
if !participants_dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(participants_dir)? {
let path = entry?.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !file_name.ends_with(".yaml") || file_name.ends_with(".rhosig.yaml") {
continue;
}
let Some(bundle): Option<IdentityBundleManifest> = read_yaml_check(&path, failures) else {
continue;
};
if !member_ids.contains(&bundle.identity.id) {
failures.push(format!(
"participant is not admitted in membership: rho/participants/{file_name} -> {}",
bundle.identity.id
));
}
}
Ok(())
}
fn check_legacy_inbox_paths(
root: &Path,
member_ids: &[String],
advisories: &mut Vec<String>,
) -> RhoResult<()> {
for migration in legacy_inbox_migrations(root, member_ids)? {
advisories.push(format!(
"legacy inbox path {}/ should move to {}/",
migration.legacy_relative, migration.canonical_relative
));
}
advisories.sort();
advisories.dedup();
Ok(())
}
#[derive(Debug)]
struct InboxPathMigration {
handle: String,
legacy_relative: String,
canonical_relative: String,
}
fn legacy_inbox_migrations(
root: &Path,
member_ids: &[String],
) -> RhoResult<Vec<InboxPathMigration>> {
let inbox = root.join("rho/messages/inbox");
if !inbox.is_dir() {
return Ok(Vec::new());
}
let member_handles = member_ids
.iter()
.filter_map(|identity| github_handle_from_identity_id(identity).ok())
.collect::<HashSet<_>>();
let mut migrations = Vec::new();
for entry in fs::read_dir(inbox)? {
let path = entry?.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if name == "id" || !member_handles.contains(name) {
continue;
}
migrations.push(InboxPathMigration {
handle: name.to_string(),
legacy_relative: format!("rho/messages/inbox/{name}"),
canonical_relative: format!("rho/messages/inbox/id/github/{name}"),
});
}
migrations.sort_by(|left, right| left.handle.cmp(&right.handle));
Ok(migrations)
}
fn migrate_inbox_paths(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let dry_run = has_flag(args, "--dry-run");
ensure_repo(&root)?;
let membership_path = root.join("rho/membership.yaml");
let membership: MembershipManifest = from_yaml(&fs::read_to_string(&membership_path)?)?;
let member_ids = membership
.members
.iter()
.map(|member| member.identity.clone())
.collect::<Vec<_>>();
let migrations = legacy_inbox_migrations(&root, &member_ids)?;
if migrations.is_empty() {
println!("legacy inbox paths: none");
return Ok(());
}
for migration in &migrations {
let legacy_path = root.join(&migration.legacy_relative);
let canonical_path = root.join(&migration.canonical_relative);
if canonical_path.exists() {
return Err(format!(
"cannot migrate {}; target already exists: {}",
migration.legacy_relative, migration.canonical_relative
)
.into());
}
if dry_run {
println!(
"dry-run: move {} -> {}",
migration.legacy_relative, migration.canonical_relative
);
continue;
}
ensure_parent(&canonical_path)?;
fs::rename(&legacy_path, &canonical_path)?;
println!(
"moved {} -> {}",
migration.legacy_relative, migration.canonical_relative
);
}
if !dry_run {
let rewritten = rewrite_legacy_inbox_policy_patterns(&root, &migrations)?;
if rewritten > 0 {
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest =
from_yaml(&fs::read_to_string(&permissions_path)?)?;
write_generated_gitattributes_block(&root, &permissions)?;
println!("updated inbox policy patterns: {rewritten}");
}
println!("next: git add rho/messages/inbox rho/policy/permissions.yaml .gitattributes");
}
println!("legacy inbox paths migrated: {}", migrations.len());
Ok(())
}
fn rewrite_legacy_inbox_policy_patterns(
root: &Path,
migrations: &[InboxPathMigration],
) -> RhoResult<usize> {
let path = root.join("rho/policy/permissions.yaml");
let mut text = fs::read_to_string(&path)?;
let original = text.clone();
let mut rewritten = 0usize;
for migration in migrations {
let legacy_pattern = format!("{}/**", migration.legacy_relative);
let canonical_pattern = format!("{}/**", migration.canonical_relative);
if text.contains(&legacy_pattern) {
text = text.replace(&legacy_pattern, &canonical_pattern);
rewritten += 1;
}
}
if text != original {
fs::write(path, text)?;
}
Ok(rewritten)
}
fn member_set(member_ids: &[String]) -> HashSet<&str> {
member_ids.iter().map(String::as_str).collect()
}
fn check_crypto_policy(crypto: &CryptoPolicy, root: &Path, failures: &mut Vec<String>) {
if crypto.signatures.as_deref() != Some("ssh") {
failures.push("crypto.signatures must be ssh".to_string());
}
if crypto.encryption.as_deref() != Some("age-recipient-envelope") {
failures.push("crypto.encryption must be age-recipient-envelope".to_string());
}
let attributes = fs::read_to_string(root.join(".gitattributes")).unwrap_or_default();
let has_rho_filter = attributes
.lines()
.any(|line| line.contains("filter=rho-crypt"));
if has_rho_filter && crypto.transparent_git_filters.as_deref() == Some("disabled") {
failures.push(
"crypto.transparent_git_filters is disabled but .gitattributes uses filter=rho-crypt"
.to_string(),
);
}
if has_rho_filter && crypto.filter.as_deref() != Some("rho-crypt") {
failures
.push("crypto.filter must be rho-crypt when protected filters are used".to_string());
}
}
fn check_permissions_policy(
root: &Path,
permissions: &PermissionsManifest,
member_ids: &[String],
failures: &mut Vec<String>,
) -> RhoResult<()> {
let attributes = fs::read_to_string(root.join(".gitattributes")).unwrap_or_default();
for rule in &permissions.permissions {
if rule.read.public && (rule.read.repo_key || !rule.read.encrypted_to.is_empty()) {
failures.push(format!(
"permission cannot be both public and encrypted: {}",
rule.pattern
));
}
for recipient in &rule.read.encrypted_to {
if !member_ids.is_empty() && !member_ids.iter().any(|member| member == recipient) {
failures.push(format!(
"permission encrypted reader is not a member: {} -> {}",
rule.pattern, recipient
));
}
if let Err(error) = validate_actor_id(recipient) {
failures.push(format!(
"invalid permission encrypted reader {recipient}: {error}"
));
}
}
if rule.read.repo_key || !rule.read.encrypted_to.is_empty() {
let expected_attr = format!("{} filter=rho-crypt", rule.pattern);
if !attributes
.lines()
.any(|line| line.starts_with(&expected_attr))
{
failures.push(format!(
".gitattributes is missing rho-crypt filter for {}",
rule.pattern
));
}
}
}
let files = tracked_and_untracked_files(root)?;
for file in files {
for rule in &permissions.permissions {
if path_matches_pattern(&file, &rule.pattern) {
check_permission_file(root, &file, rule, failures);
}
}
}
Ok(())
}
fn check_dataset_manifests(
root: &Path,
member_ids: &[String],
failures: &mut Vec<String>,
) -> RhoResult<()> {
let member_ids = member_set(member_ids);
for file in tracked_and_untracked_files(root)? {
if file == "dataset.yaml" || file.ends_with("/dataset.yaml") {
check_dataset_manifest(root, &file, &member_ids, failures);
}
}
Ok(())
}
fn check_dataset_manifest(
root: &Path,
relative_path: &str,
member_ids: &HashSet<&str>,
failures: &mut Vec<String>,
) {
let path = root.join(relative_path);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => {
failures.push(format!(
"cannot read dataset manifest {relative_path}: {error}"
));
return;
}
};
let manifest: DatasetManifest = match from_yaml(&text) {
Ok(manifest) => manifest,
Err(error) => {
failures.push(format!("invalid dataset manifest {relative_path}: {error}"));
return;
}
};
if manifest.version != Some(1) {
failures.push(format!(
"dataset manifest version must be 1: {relative_path}"
));
}
if manifest.uuid.is_some() || manifest.variants.is_some() {
failures.push(format!(
"dataset manifest uses deprecated top-level fields; expected dataset.uuid and dataset.variants: {relative_path}"
));
}
let Some(dataset) = manifest.dataset else {
failures.push(format!("missing dataset section in {relative_path}"));
return;
};
let Some(uuid) = non_empty(dataset.uuid.as_deref()) else {
failures.push(format!("missing dataset.uuid in {relative_path}"));
return;
};
let dataset_name = non_empty(dataset.name.as_deref());
if dataset_name.is_none() {
failures.push(format!("missing dataset.name in {relative_path}"));
}
if let Some(owner) = non_empty(dataset.owner.as_deref()) {
if let Err(error) = validate_actor_id(owner) {
failures.push(format!(
"invalid dataset.owner in {relative_path}: {owner}: {error}"
));
}
if !member_ids.is_empty() && !member_ids.contains(owner) {
failures.push(format!(
"dataset owner is not a member in {relative_path}: {owner}"
));
}
} else {
failures.push(format!("missing dataset.owner in {relative_path}"));
}
let Some(variants) = dataset.variants else {
failures.push(format!("missing dataset.variants in {relative_path}"));
return;
};
if variants.public.is_none() && variants.mock.is_none() && variants.real.is_none() {
failures.push(format!(
"dataset.variants must define at least one of public, mock, or real in {relative_path}"
));
}
if let Some(public) = variants.public {
check_dataset_variant(root, relative_path, "public", &public, failures);
}
if let Some(mock) = variants.mock {
check_dataset_variant(root, relative_path, "mock", &mock, failures);
}
if let Some(real) = variants.real
&& let Some(real_path) = non_empty(real.relative_path.as_deref())
{
check_dataset_relative_path(root, relative_path, real_path, failures);
check_variant_sha(relative_path, "real", &real, failures);
}
if !relative_path.contains(uuid)
&& dataset_name.is_none_or(|name| !relative_path.contains(name))
{
failures.push(format!(
"dataset path should include dataset.uuid {uuid} or dataset.name in {relative_path}"
));
}
}
fn check_dataset_variant(
root: &Path,
manifest_path: &str,
variant_name: &str,
variant: &DatasetVariant,
failures: &mut Vec<String>,
) {
if let Some(path) = non_empty(
variant
.relative_path
.as_deref()
.or(variant.share_relative_path.as_deref()),
) {
check_dataset_relative_path(root, manifest_path, path, failures);
check_variant_sha(manifest_path, variant_name, variant, failures);
return;
}
let Some(source) = &variant.source else {
failures.push(format!(
"missing dataset.variants.{variant_name}.relative_path or source in {manifest_path}"
));
return;
};
if non_empty(source.kind.as_deref()).is_none() {
failures.push(format!(
"missing dataset.variants.{variant_name}.source.kind in {manifest_path}"
));
}
if non_empty(source.repo.as_deref()).is_none() && non_empty(source.url.as_deref()).is_none() {
failures.push(format!(
"dataset.variants.{variant_name}.source must include repo or url in {manifest_path}"
));
}
let _ = non_empty(source.revision.as_deref());
}
fn check_variant_sha(
manifest_path: &str,
variant_name: &str,
variant: &DatasetVariant,
failures: &mut Vec<String>,
) {
if non_empty(variant.sha256.as_deref()).is_none() {
failures.push(format!(
"missing dataset.variants.{variant_name}.sha256 in {manifest_path}"
));
}
}
fn check_dataset_relative_path(
root: &Path,
manifest_path: &str,
relative_payload: &str,
failures: &mut Vec<String>,
) {
let payload = Path::new(relative_payload);
if payload.is_absolute()
|| payload.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
failures.push(format!(
"unsafe dataset payload path in {manifest_path}: {relative_payload}"
));
return;
}
let manifest_dir = root
.join(manifest_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| root.to_path_buf());
if !manifest_dir.join(payload).is_file() {
failures.push(format!(
"dataset payload missing for {manifest_path}: {relative_payload}"
));
}
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
#[derive(Default)]
struct FlowReport {
next_steps: Vec<String>,
}
fn check_flow_receipts(
root: &Path,
repo_id: &str,
owner: &str,
failures: &mut Vec<String>,
) -> RhoResult<FlowReport> {
let mut report = FlowReport::default();
let receipt_files: Vec<String> = tracked_and_untracked_files(root)?
.into_iter()
.filter(|file| {
file.starts_with("rho/run-receipts/")
&& file.ends_with(".yaml")
&& !file.ends_with(".rhosig.yaml")
})
.collect();
if receipt_files.is_empty() {
let request_ids = repo_request_ids(root)?;
if request_ids.is_empty() {
report
.next_steps
.push("submit a signed request".to_string());
} else {
for request_id in request_ids {
report.next_steps.push(format!(
"rho run approve-request {request_id} --private-root <private-datasets-root>"
));
}
}
return Ok(report);
}
for receipt_file in receipt_files {
check_flow_receipt(root, repo_id, owner, &receipt_file, failures, &mut report);
}
Ok(report)
}
fn check_flow_receipt(
root: &Path,
repo_id: &str,
owner: &str,
receipt_file: &str,
failures: &mut Vec<String>,
report: &mut FlowReport,
) {
let receipt_path = root.join(receipt_file);
let receipt: RunReceiptManifest = match read_yaml_check(&receipt_path, failures) {
Some(receipt) => receipt,
None => return,
};
let record = receipt.run_receipt;
if let Err(error) = validate_request_id(&record.request_id) {
failures.push(format!(
"invalid run receipt request_id in {receipt_file}: {error}"
));
}
for (label, value) in [
("run_id", record.run_id.as_str()),
("runner", record.runner.as_str()),
("status", record.status.as_str()),
("tier", record.tier.as_str()),
("code_path", record.code_path.as_str()),
("code_sha256", record.code_sha256.as_str()),
("output_sha256", record.output_sha256.as_str()),
("result_path", record.result_path.as_str()),
] {
if value.trim().is_empty() {
failures.push(format!("missing run receipt {label} in {receipt_file}"));
}
}
if record.tier == "real" && record.input_sha256.trim().is_empty() {
failures.push(format!(
"missing real input commitment in run receipt {receipt_file}"
));
}
if let Some(input_path) = non_empty(record.input_path.as_deref())
&& Path::new(input_path).is_absolute()
{
failures.push(format!(
"run receipt leaks absolute input_path in {receipt_file}: {input_path}"
));
}
if !repo_request_exists(root, &record.request_id) {
failures.push(format!(
"run receipt references missing request {}: {receipt_file}",
record.request_id
));
}
let Some(result_path) = safe_repo_path(&record.result_path) else {
failures.push(format!(
"unsafe run receipt result_path in {receipt_file}: {}",
record.result_path
));
return;
};
let result_path = root.join(result_path);
let expected_result_recipient = flow_recipient_from_result_path(&record.result_path);
let result_exists = result_path.is_file();
if !result_exists {
report.next_steps.push(format!(
"rho result release {} --run-id {} --to {}",
record.request_id,
record.run_id,
expected_result_recipient
.as_deref()
.unwrap_or("<result-recipient>")
));
}
let result = if result_path.is_file() {
read_result_for_flow(&result_path, &record, failures)
} else {
None
};
check_flow_approval_grant(
root,
repo_id,
owner,
&record,
result
.as_ref()
.map(|result| result.to.as_str())
.or(expected_result_recipient.as_deref()),
receipt_file,
failures,
);
let result_signature = result_path.with_file_name("result.rhosig.yaml");
if result_exists && !result_signature.is_file() {
failures.push(format!(
"run receipt result signature missing for {}",
record.result_path
));
}
let receipt_signature = receipt_path.with_file_name(format!("{}.rhosig.yaml", record.run_id));
if !receipt_signature.is_file() {
failures.push(format!("run receipt signature missing for {receipt_file}"));
}
if receipt_signature.is_file() {
if let Some(recipient) = result
.as_ref()
.map(|result| result.to.as_str())
.or(expected_result_recipient.as_deref())
{
let context =
flow_signature_context(repo_id, &record.request_id, recipient, "rho.run_receipt");
verify_repo_signature_with_context(
root,
&receipt_path,
&receipt_signature,
Some(&context),
failures,
);
} else {
verify_repo_signature(root, &receipt_path, &receipt_signature, failures);
}
}
if result_path.is_file() && result_signature.is_file() {
if let Some(result) = &result {
let context =
flow_signature_context(repo_id, &record.request_id, &result.to, "rho.result");
verify_repo_signature_with_context(
root,
&result_path,
&result_signature,
Some(&context),
failures,
);
} else {
verify_repo_signature(root, &result_path, &result_signature, failures);
}
}
}
fn read_result_for_flow(
result_path: &Path,
receipt: &RunReceiptRecord,
failures: &mut Vec<String>,
) -> Option<ResultRecord> {
let result: ResultManifest = read_yaml_check(result_path, failures)?;
let result = result.result;
if result.request_id != receipt.request_id {
failures.push(format!(
"result request_id mismatch for {}: expected {}, got {}",
result_path.display(),
receipt.request_id,
result.request_id
));
}
if result.run_id != receipt.run_id {
failures.push(format!(
"result run_id mismatch for {}: expected {}, got {}",
result_path.display(),
receipt.run_id,
result.run_id
));
}
if let Err(error) = validate_actor_id(&result.to) {
failures.push(format!(
"invalid result recipient in {}: {error}",
result_path.display()
));
}
Some(result)
}
fn flow_signature_context(
repo_id: &str,
request_id: &str,
recipient_id: &str,
purpose: &str,
) -> SignatureContext {
SignatureContext {
repo_id: Some(repo_id.to_string()),
request_id: Some(request_id.to_string()),
message_id: None,
recipient_id: Some(recipient_id.to_string()),
purpose: Some(purpose.to_string()),
}
}
fn check_flow_approval_grant(
root: &Path,
repo_id: &str,
owner: &str,
receipt: &RunReceiptRecord,
result_recipient: Option<&str>,
receipt_file: &str,
failures: &mut Vec<String>,
) {
let Some(action_id) = action_id_for_run_id(&receipt.run_id) else {
failures.push(format!(
"run receipt has non-conventional run_id with no approval grant mapping in {receipt_file}: {}",
receipt.run_id
));
return;
};
let relative_grant = format!(
"rho/approval-grants/{}/{}.yaml",
receipt.request_id, action_id
);
let grant_path = root.join(&relative_grant);
if !grant_path.is_file() {
failures.push(format!(
"approval grant missing for run receipt {receipt_file}: {relative_grant}"
));
return;
}
let signature_path = grant_path.with_file_name(format!("{action_id}.rhosig.yaml"));
if !signature_path.is_file() {
failures.push(format!(
"approval grant signature missing for {relative_grant}"
));
} else {
match signature_signer(&signature_path) {
Ok(signer) if signer == owner => {}
Ok(signer) => failures.push(format!(
"approval grant signer mismatch for {relative_grant}: expected {owner}, got {signer}"
)),
Err(error) => failures.push(format!(
"invalid approval grant signature {}: {error}",
signature_path.display()
)),
}
if let Some(result_recipient) = result_recipient {
let context = flow_signature_context(
repo_id,
&receipt.request_id,
result_recipient,
"rho.approval_grant",
);
verify_repo_signature_with_context(
root,
&grant_path,
&signature_path,
Some(&context),
failures,
);
} else {
verify_repo_signature(root, &grant_path, &signature_path, failures);
}
}
let grant: ApprovalGrantManifest = match read_yaml_check(&grant_path, failures) {
Some(grant) => grant,
None => return,
};
let grant = grant.approval_grant;
if grant.action_id != action_id {
failures.push(format!(
"approval grant action_id mismatch for {relative_grant}: expected {action_id}, got {}",
grant.action_id
));
}
if grant.request_id != receipt.request_id {
failures.push(format!(
"approval grant request_id mismatch for {relative_grant}: expected {}, got {}",
receipt.request_id, grant.request_id
));
}
if grant.decision != "approved" {
failures.push(format!(
"approval grant decision is not approved for {relative_grant}: {}",
grant.decision
));
}
if grant.granted_by != owner {
failures.push(format!(
"approval grant granted_by mismatch for {relative_grant}: expected {owner}, got {}",
grant.granted_by
));
}
for (label, value) in [
("tool_id", grant.tool_id.as_str()),
("action_type", grant.action_type.as_str()),
("action.sha256", grant.action.sha256.as_str()),
("local_grant.sha256", grant.local_grant.sha256.as_str()),
("code.path", grant.code.path.as_str()),
(
"private_input.dataset_uuid",
grant.private_input.dataset_uuid.as_str(),
),
] {
if value.trim().is_empty() {
failures.push(format!(
"missing approval grant {label} in {relative_grant}"
));
}
}
let expected_local_grant = format!(".rho/action-grants/{action_id}.yaml");
if grant.local_grant.path != expected_local_grant {
failures.push(format!(
"approval grant local_grant path mismatch for {relative_grant}: expected {expected_local_grant}, got {}",
grant.local_grant.path
));
}
if grant.code.kind != "code" {
failures.push(format!(
"approval grant code input kind mismatch for {relative_grant}: {}",
grant.code.kind
));
}
if grant.code.sha256 != receipt.code_sha256 {
failures.push(format!(
"approval grant code digest mismatch for {relative_grant}: expected {}, got {}",
receipt.code_sha256, grant.code.sha256
));
}
if grant.private_input.tier != receipt.tier {
failures.push(format!(
"approval grant private input tier mismatch for {relative_grant}: expected {}, got {}",
receipt.tier, grant.private_input.tier
));
}
if grant.private_input.sha256 != receipt.input_sha256 {
failures.push(format!(
"approval grant private input digest mismatch for {relative_grant}: expected {}, got {}",
receipt.input_sha256, grant.private_input.sha256
));
}
if let Some(action_path) = safe_repo_path(&grant.action.path)
&& root.join(&action_path).is_file()
&& let Ok(digest) = file_digest(&root.join(action_path))
&& digest != grant.action.sha256
{
failures.push(format!(
"approval grant action digest mismatch for {relative_grant}: expected {}, got {digest}",
grant.action.sha256
));
}
}
fn action_id_for_run_id(run_id: &str) -> Option<String> {
run_id
.strip_prefix("run-")
.map(|suffix| format!("act-{suffix}"))
}
fn verify_repo_signature(
root: &Path,
signed_path: &Path,
signature_path: &Path,
failures: &mut Vec<String>,
) {
verify_repo_signature_with_context(root, signed_path, signature_path, None, failures);
}
fn verify_repo_signature_with_context(
root: &Path,
signed_path: &Path,
signature_path: &Path,
context: Option<&SignatureContext>,
failures: &mut Vec<String>,
) {
let text = match fs::read_to_string(signature_path) {
Ok(text) => text,
Err(error) => {
failures.push(format!(
"cannot read signature {}: {error}",
signature_path.display()
));
return;
}
};
let manifest: SignatureManifest = match from_yaml(&text) {
Ok(manifest) => manifest,
Err(error) => {
failures.push(format!(
"invalid signature manifest {}: {error}",
signature_path.display()
));
return;
}
};
let mut rho_crypto = rho_crypto_command();
rho_crypto
.arg("verify")
.arg(signed_path)
.arg("--signature")
.arg(signature_path)
.arg("--identity")
.arg(&manifest.signature.signer)
.arg("--repo-root")
.arg(root);
if let Some(context) = context {
rho_crypto
.arg("--repo-id")
.arg(context.repo_id.as_deref().unwrap_or(""))
.arg("--request-id")
.arg(context.request_id.as_deref().unwrap_or(""))
.arg("--recipient")
.arg(context.recipient_id.as_deref().unwrap_or(""))
.arg("--purpose")
.arg(context.purpose.as_deref().unwrap_or(""));
}
let output = rho_crypto.output();
match output {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
failures.push(format!(
"signature verification failed for {}: {}",
signed_path.display(),
stderr.trim()
));
}
Err(error) => failures.push(format!(
"failed to run rho crypto verify for {}: {error}",
signed_path.display()
)),
}
}
fn check_governance_signatures(
root: &Path,
owner: &str,
failures: &mut Vec<String>,
) -> RhoResult<()> {
let files = governance_manifest_files(root)?;
if files.is_empty() {
failures.push("governance has no manifest files to verify".to_string());
return Ok(());
}
for file in files {
let signed_path = root.join(&file);
let signature_path = governance_signature_path(root, &file)?;
if !signature_path.is_file() {
failures.push(format!(
"governance signature missing for {file}: {}",
repo_relative_display(root, &signature_path)
));
continue;
}
match signature_signer(&signature_path) {
Ok(signer) if signer == owner => {}
Ok(signer) => failures.push(format!(
"governance signature signer mismatch for {file}: expected {owner}, got {signer}"
)),
Err(error) => failures.push(format!(
"invalid governance signature {}: {error}",
signature_path.display()
)),
}
verify_repo_signature(root, &signed_path, &signature_path, failures);
}
Ok(())
}
fn governance_manifest_files(root: &Path) -> RhoResult<Vec<String>> {
let mut files: Vec<String> = tracked_and_untracked_files(root)?
.into_iter()
.filter(|file| is_governance_manifest(file))
.collect();
files.sort();
files.dedup();
Ok(files)
}
fn is_governance_manifest(file: &str) -> bool {
if !file.ends_with(".yaml") || file.ends_with(".rhosig.yaml") {
return false;
}
matches!(
file,
"rho.yaml" | "rho/repo.yaml" | "rho/membership.yaml" | "rho/resources.yaml"
) || (file.starts_with("rho/policy/") && !file["rho/policy/".len()..].contains('/'))
|| (file.starts_with("rho/participants/")
&& !file["rho/participants/".len()..].contains('/'))
|| (file.starts_with("rho/tools/") && !file["rho/tools/".len()..].contains('/'))
|| file == "dataset.yaml"
|| file.ends_with("/dataset.yaml")
}
fn governance_signature_path(root: &Path, relative_file: &str) -> RhoResult<PathBuf> {
let path = root.join(relative_file);
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return Err(format!("invalid governance path: {relative_file}").into());
};
let signature_name = if let Some(stem) = file_name.strip_suffix(".yaml") {
format!("{stem}.rhosig.yaml")
} else {
format!("{file_name}.rhosig.yaml")
};
Ok(path.with_file_name(signature_name))
}
fn signature_signer(signature_path: &Path) -> RhoResult<String> {
let text = fs::read_to_string(signature_path)?;
let manifest: SignatureManifest = from_yaml(&text)?;
Ok(manifest.signature.signer)
}
fn sign_file(signed_path: &Path, signature_path: &Path, identity: &str) -> RhoResult<()> {
let output = rho_crypto_command()
.arg("sign")
.arg(signed_path)
.arg("--identity")
.arg(identity)
.arg("--out")
.arg(signature_path)
.output()?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!(
"failed to sign {}: {}",
signed_path.display(),
stderr.trim()
)
.into())
}
fn repo_relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|path| path.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}
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 repo_request_exists(root: &Path, request_id: &str) -> bool {
let inbox = root.join("rho/messages/inbox");
request_exists_under(&inbox, request_id)
}
fn repo_request_ids(root: &Path) -> RhoResult<Vec<String>> {
let inbox = root.join("rho/messages/inbox");
let mut ids = Vec::new();
collect_request_ids_under(&inbox, &mut ids)?;
ids.sort();
ids.dedup();
Ok(ids)
}
fn collect_request_ids_under(dir: &Path, ids: &mut Vec<String>) -> RhoResult<()> {
let Ok(entries) = fs::read_dir(dir) else {
return Ok(());
};
for entry in entries {
let path = entry?.path();
if !path.is_dir() {
continue;
}
if path.join("request.yaml").is_file()
&& let Some(request_id) = path.file_name().and_then(|value| value.to_str())
&& validate_request_id(request_id).is_ok()
{
ids.push(request_id.to_string());
}
collect_request_ids_under(&path, ids)?;
}
Ok(())
}
fn request_exists_under(dir: &Path, request_id: &str) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
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 true;
}
if request_exists_under(&path, request_id) {
return true;
}
}
}
false
}
fn flow_recipient_from_result_path(path: &str) -> Option<String> {
let parts = path.split('/').collect::<Vec<_>>();
match parts.as_slice() {
[
"rho",
"messages",
"inbox",
"id",
"github",
handle,
_request_id,
"result.yaml",
] if !handle.is_empty() && !handle.contains('/') => {
Some(format!("rho://id/github/{handle}"))
}
[
"rho",
"messages",
"inbox",
handle,
_request_id,
"result.yaml",
] if !handle.is_empty() && !handle.contains('/') => {
Some(format!("rho://id/github/{handle}"))
}
_ => None,
}
}
fn identity_inbox_relative_path(identity_id: &str) -> RhoResult<PathBuf> {
providers::identity_inbox_relative_path(identity_id)
}
fn safe_repo_path(path: &str) -> Option<PathBuf> {
let path = Path::new(path);
if path.is_absolute() {
return None;
}
let mut has_component = false;
for component in path.components() {
match component {
Component::Normal(_) => has_component = true,
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
has_component.then(|| path.to_path_buf())
}
fn tracked_and_untracked_files(root: &Path) -> RhoResult<Vec<String>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.output()?;
if !output.status.success() {
return Err("git ls-files failed".into());
}
Ok(output
.stdout
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
.filter_map(|entry| String::from_utf8(entry.to_vec()).ok())
.collect())
}
fn check_permission_file(
root: &Path,
relative_path: &str,
rule: &PermissionRule,
failures: &mut Vec<String>,
) {
if rule.read.public {
return;
}
let recipients = &rule.read.encrypted_to;
let content = index_blob(root, relative_path)
.or_else(|| fs::read_to_string(root.join(relative_path)).ok())
.unwrap_or_default();
let kind = yaml_top_level_kind(&content);
if !matches!(
kind.as_deref(),
Some("rho_recipient_envelope" | "rho_transparent_file")
) {
failures.push(format!(
"private-read file is not encrypted: {relative_path}"
));
return;
}
if !recipients.is_empty() && kind.as_deref() == Some("rho_transparent_file") {
failures.push(format!(
"protected file uses repo-key encryption but policy requires recipients: {relative_path}"
));
return;
}
if kind.as_deref() == Some("rho_recipient_envelope") {
for recipient in recipients {
if !content.contains(&format!("identity_id: {recipient}"))
&& !content.contains(&format!("identity_id: \"{recipient}\""))
{
failures.push(format!(
"protected file is not encrypted to expected recipient {recipient}: {relative_path}"
));
}
}
}
}
fn missing_recipients(content: &str, recipients: &[String]) -> Vec<String> {
recipients
.iter()
.filter(|recipient| {
!content.contains(&format!("identity_id: {recipient}"))
&& !content.contains(&format!("identity_id: \"{recipient}\""))
})
.cloned()
.collect()
}
fn index_blob(root: &Path, relative_path: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["show", &format!(":{relative_path}")])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn head_blob(root: &Path, relative_path: &str) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["show", &format!("HEAD:{relative_path}")])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout).ok()
}
fn github_handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
providers::github::handle_from_identity_id(identity_id)
}
fn active_identity() -> Option<String> {
env::var("RHO_IDENTITY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn detect_github_remote(root: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
if remote.is_empty() {
None
} else {
Some(providers::github::repo_candidate_from_remote(&remote).unwrap_or(remote))
}
}
fn protect_path(args: &[String]) -> RhoResult<()> {
let Some(pattern) = args.first().map(String::as_str) else {
usage();
};
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let key_file = arg_value(args, "--key-file")
.map(PathBuf::from)
.unwrap_or_else(|| default_repo_key_file(&root));
let rho_bin = arg_value(args, "--rho-bin").unwrap_or_else(default_rho_bin);
let recipients = repeated_arg(args, "--recipient")
.into_iter()
.map(|recipient| normalize_actor_id(&recipient))
.collect::<RhoResult<Vec<_>>>()?;
ensure_repo(&root)?;
if recipients.is_empty() {
ensure_repo_key(&key_file)?;
} else {
for recipient in &recipients {
validate_actor_id(recipient)?;
}
}
if recipients.is_empty() {
configure_git_filter(&root, &rho_bin, &key_file)?;
write_filter_crypto_policy(&root)?;
write_permissions_policy_rule(&root, pattern, &[], true)?;
sync_gitattributes_from_permissions(&root)?;
} else {
configure_git_filter_for_policy(&root, &rho_bin)?;
write_filter_crypto_policy(&root)?;
write_permissions_policy_rule(&root, pattern, &recipients, false)?;
sync_gitattributes_from_permissions(&root)?;
}
install_pre_commit_hook(&root)?;
if recipients.is_empty() {
println!("{}", key_file.display());
}
println!("{}", root.join(".gitattributes").display());
println!("{}", root.join("rho/policy/permissions.yaml").display());
println!("{}", root.join(".git/hooks/pre-commit").display());
Ok(())
}
fn install_filters(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let rho_bin = arg_value(args, "--rho-bin").unwrap_or_else(default_rho_bin);
ensure_repo(&root)?;
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest = from_yaml(&fs::read_to_string(&permissions_path)?)?;
let installed = protected_permission_rules(&permissions).count();
write_generated_gitattributes_block(&root, &permissions)?;
configure_git_filter_for_policy(&root, &rho_bin)?;
write_filter_crypto_policy(&root)?;
install_pre_commit_hook(&root)?;
println!("{}", root.join(".gitattributes").display());
println!("{}", root.join(".git/hooks/pre-commit").display());
println!("installed encrypted path filters: {installed}");
Ok(())
}
fn refresh_protected(args: &[String]) -> RhoResult<()> {
let root = arg_value(args, "--root")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let rho_bin = arg_value(args, "--rho-bin").unwrap_or_else(default_rho_bin);
ensure_repo(&root)?;
let root_arg = root.display().to_string();
install_filters(&[
"--root".to_string(),
root_arg,
"--rho-bin".to_string(),
rho_bin.clone(),
])?;
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest = from_yaml(&fs::read_to_string(&permissions_path)?)?;
let tracked = tracked_files(&root)?;
let mut refreshed = 0usize;
let mut skipped_dirty = 0usize;
let mut unchanged = 0usize;
for file in tracked {
let Some(_rule) = permissions.permissions.iter().find(|rule| {
!rule.read.public
&& !rule.read.encrypted_to.is_empty()
&& path_matches_pattern(&file, &rule.pattern)
}) else {
continue;
};
let path = root.join(&file);
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if !is_rho_encrypted_text(&text) {
continue;
}
if path_has_worktree_changes(&root, &file)? {
skipped_dirty += 1;
println!("skipped dirty protected file: {file}");
continue;
}
let plaintext = smudge_file_text(&root, &rho_bin, &file, &text)?;
if plaintext == text || is_rho_encrypted_text(&plaintext) {
unchanged += 1;
continue;
}
fs::write(&path, plaintext)?;
refreshed += 1;
println!("refreshed protected file: {file}");
}
println!("refreshed protected files: {refreshed}");
if skipped_dirty > 0 {
println!("skipped dirty protected files: {skipped_dirty}");
}
if unchanged > 0 {
println!("unchanged protected envelopes: {unchanged}");
}
Ok(())
}
fn tracked_files(root: &Path) -> RhoResult<Vec<String>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "-z"])
.output()?;
if !output.status.success() {
return Err("git ls-files failed".into());
}
Ok(output
.stdout
.split(|byte| *byte == 0)
.filter(|entry| !entry.is_empty())
.filter_map(|entry| String::from_utf8(entry.to_vec()).ok())
.collect())
}
fn path_has_worktree_changes(root: &Path, relative_path: &str) -> RhoResult<bool> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["status", "--porcelain", "--"])
.arg(relative_path)
.output()?;
if !output.status.success() {
return Err(format!("git status failed for {relative_path}").into());
}
Ok(!output.stdout.is_empty())
}
fn smudge_file_text(
root: &Path,
rho_bin: &str,
relative_path: &str,
text: &str,
) -> RhoResult<String> {
let mut child = Command::new(rho_bin)
.args(["crypto", "smudge", "--repo-root"])
.arg(root)
.args(["--path", relative_path])
.stdin(Stdio::piped())
.stdout(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() {
return Err(format!("rho crypto smudge failed for {relative_path}").into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn ensure_repo(root: &Path) -> RhoResult<()> {
if !root.join(".git").exists() {
return Err(format!("not a Git working tree root: {}", root.display()).into());
}
if !root.join("rho/repo.yaml").is_file() {
return Err(format!(
"rho repo not initialized: {}",
root.join("rho/repo.yaml").display()
)
.into());
}
Ok(())
}
fn default_repo_key_file(root: &Path) -> PathBuf {
let repo_slug = fs::read_to_string(root.join("rho/repo.yaml"))
.ok()
.and_then(|text| from_yaml::<RepoManifest>(&text).ok())
.map(|manifest| manifest.repo.id)
.unwrap_or_else(|| "rho-repo".to_string())
.trim_matches('"')
.replace("rho://repo/", "")
.replace(['/', ':'], "-");
rho_home()
.join("repo-keys")
.join(format!("{repo_slug}.key"))
}
fn default_rho_bin() -> String {
if let Ok(exe) = env::current_exe()
&& let Some(parent) = exe.parent()
{
let sibling = parent.join("rho");
if sibling.is_file() {
return sibling.display().to_string();
}
}
"rho".to_string()
}
fn rho_home() -> PathBuf {
if let Ok(value) = env::var("RHO_HOME")
&& !value.is_empty()
{
return PathBuf::from(value);
}
env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join(".rho")
}
fn ensure_repo_key(path: &Path) -> RhoResult<()> {
if path.is_file() {
return Ok(());
}
ensure_parent(path)?;
let mut key = [0u8; 32];
getrandom::getrandom(&mut key).map_err(|error| error.to_string())?;
fs::write(path, format!("{}\n", BASE64.encode(key)))?;
Ok(())
}
fn configure_git_filter(root: &Path, rho_bin: &str, key_file: &Path) -> RhoResult<()> {
let clean = format!(
"{} crypto clean --key-file {}",
shell_quote(rho_bin),
shell_quote(&key_file.display().to_string())
);
let smudge = format!(
"{} crypto smudge --key-file {}",
shell_quote(rho_bin),
shell_quote(&key_file.display().to_string())
);
git_config(root, "filter.rho-crypt.clean", &clean)?;
git_config(root, "filter.rho-crypt.smudge", &smudge)?;
git_config(root, "filter.rho-crypt.required", "true")?;
git_config(root, "diff.rho-crypt.textconv", &smudge)?;
Ok(())
}
fn configure_git_filter_for_policy(root: &Path, rho_bin: &str) -> RhoResult<()> {
let env_prefix = env::var("RHO_HOME")
.ok()
.filter(|value| !value.is_empty())
.map(|value| format!("RHO_HOME={} ", shell_quote(&value)))
.unwrap_or_default();
let clean = format!(
"{}{} crypto clean --repo-root {} --path %f",
env_prefix,
shell_quote(rho_bin),
shell_quote(&root.display().to_string())
);
let smudge = format!(
"{}{} crypto smudge --repo-root {} --path %f",
env_prefix,
shell_quote(rho_bin),
shell_quote(&root.display().to_string())
);
git_config(root, "filter.rho-crypt.clean", &clean)?;
git_config(root, "filter.rho-crypt.smudge", &smudge)?;
git_config(root, "filter.rho-crypt.required", "true")?;
git_config(root, "diff.rho-crypt.textconv", &smudge)?;
Ok(())
}
fn write_filter_crypto_policy(root: &Path) -> RhoResult<()> {
write_if_missing_or_forced(
&root.join("rho/policy/crypto.yaml"),
"version: 1\ncrypto:\n signatures: ssh\n encryption: age-recipient-envelope\n encrypted_envelopes: explicit\n transparent_git_filters: enabled\n filter: rho-crypt\n",
true,
)
}
const GENERATED_ATTRIBUTES_START: &str = "# >>> rho generated encrypted path filters >>>";
const GENERATED_ATTRIBUTES_END: &str = "# <<< rho generated encrypted path filters <<<";
fn protected_permission_rules(
permissions: &PermissionsManifest,
) -> impl Iterator<Item = &PermissionRule> {
permissions
.permissions
.iter()
.filter(|rule| rule.read.repo_key || !rule.read.encrypted_to.is_empty())
}
fn generated_gitattributes_block(permissions: &PermissionsManifest) -> String {
let mut rules = protected_permission_rules(permissions).collect::<Vec<_>>();
rules.sort_by(|left, right| left.pattern.cmp(&right.pattern));
rules.dedup_by(|left, right| left.pattern == right.pattern);
if rules.is_empty() {
return String::new();
}
let mut text = String::new();
text.push_str(GENERATED_ATTRIBUTES_START);
text.push('\n');
text.push_str("# Generated from rho/policy/permissions.yaml; run `rho repo install-filters` after policy edits.\n");
for rule in rules {
text.push_str(&format!(
"{} filter=rho-crypt diff=rho-crypt\n",
rule.pattern
));
}
text.push_str(GENERATED_ATTRIBUTES_END);
text.push('\n');
text
}
fn is_legacy_generated_attribute(line: &str, patterns: &[&str]) -> bool {
if !line.contains("filter=rho-crypt") {
return false;
}
let Some(pattern) = line.split_whitespace().next() else {
return false;
};
patterns.contains(&pattern)
}
fn write_generated_gitattributes_block(
root: &Path,
permissions: &PermissionsManifest,
) -> RhoResult<()> {
let attributes_path = root.join(".gitattributes");
let existing = fs::read_to_string(&attributes_path).unwrap_or_default();
let patterns = protected_permission_rules(permissions)
.map(|rule| rule.pattern.as_str())
.collect::<Vec<_>>();
let mut lines = Vec::new();
let mut in_generated_block = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed == GENERATED_ATTRIBUTES_START {
in_generated_block = true;
continue;
}
if in_generated_block {
if trimmed == GENERATED_ATTRIBUTES_END {
in_generated_block = false;
}
continue;
}
if is_legacy_generated_attribute(line, &patterns) {
continue;
}
lines.push(line.to_string());
}
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
let mut updated = lines.join("\n");
if !updated.is_empty() {
updated.push('\n');
}
let block = generated_gitattributes_block(permissions);
if !block.is_empty() {
if !updated.is_empty() {
updated.push('\n');
}
updated.push_str(&block);
}
fs::write(attributes_path, updated)?;
Ok(())
}
fn sync_gitattributes_from_permissions(root: &Path) -> RhoResult<()> {
let permissions_path = root.join("rho/policy/permissions.yaml");
let permissions: PermissionsManifest = from_yaml(&fs::read_to_string(&permissions_path)?)?;
write_generated_gitattributes_block(root, &permissions)
}
fn write_permissions_policy_rule(
root: &Path,
pattern: &str,
recipients: &[String],
repo_key: bool,
) -> RhoResult<()> {
let permissions_path = root.join("rho/policy/permissions.yaml");
let existing: Option<PermissionsManifest> = read_yaml_check(&permissions_path, &mut Vec::new());
let mut rules = existing
.map(|manifest| manifest.permissions)
.unwrap_or_default();
if let Some(base) = pattern.strip_suffix("/**") {
rules.retain(|rule| rule.pattern != base);
}
if let Some(rule) = rules.iter_mut().find(|rule| rule.pattern == pattern) {
rule.read.public = false;
rule.read.repo_key = repo_key;
rule.read.encrypted_to = recipients.to_vec();
if rule.write.allowed.is_empty() {
rule.write.allowed = vec!["members".to_string()];
}
} else {
let id = unique_permission_id(pattern, &rules);
rules.push(PermissionRule {
id,
pattern: pattern.to_string(),
read: ReadPermission {
public: false,
repo_key,
encrypted_to: recipients.to_vec(),
},
write: WritePermission {
allowed: vec!["members".to_string()],
},
});
}
let mut text = String::from("version: 1\npermissions:\n");
for rule in rules {
text.push_str(&format!(" - id: {}\n", yaml_quote(&rule.id)));
text.push_str(&format!(" pattern: {}\n", yaml_quote(&rule.pattern)));
if !rule.write.allowed.is_empty() {
text.push_str(" write:\n allowed:\n");
for allowed in rule.write.allowed {
text.push_str(&format!(" - {}\n", yaml_quote(&allowed)));
}
}
text.push_str(" read:\n");
if rule.read.public {
text.push_str(" public: true\n");
}
if rule.read.repo_key {
text.push_str(" repo_key: true\n");
}
if !rule.read.encrypted_to.is_empty() {
text.push_str(" encrypted_to:\n");
for recipient in rule.read.encrypted_to {
text.push_str(&format!(" - {}\n", yaml_quote(&recipient)));
}
}
}
write_if_missing_or_forced(&permissions_path, &text, true)
}
fn unique_permission_id(pattern: &str, rules: &[PermissionRule]) -> String {
let base = permission_id_from_pattern(pattern);
if !rules.iter().any(|rule| rule.id == base) {
return base;
}
let mut index = 2usize;
loop {
let candidate = format!("{base}_{index}");
if !rules.iter().any(|rule| rule.id == candidate) {
return candidate;
}
index += 1;
}
}
fn permission_id_from_pattern(pattern: &str) -> String {
let mut id = String::from("perm");
for ch in pattern.chars() {
if ch.is_ascii_alphanumeric() {
id.push(ch.to_ascii_lowercase());
} else if !id.ends_with('_') {
id.push('_');
}
}
id.trim_end_matches('_').to_string()
}
fn git_config(root: &Path, key: &str, value: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "--local", key, value])
.status()?;
if !status.success() {
return Err(format!("git config failed for {key}").into());
}
Ok(())
}
fn git_add_relative(root: &Path, relative_path: &str) -> RhoResult<()> {
let status = Command::new("git")
.arg("-C")
.arg(root)
.arg("add")
.arg("--")
.arg(relative_path)
.status()?;
if !status.success() {
return Err(format!("git add failed for {relative_path}").into());
}
Ok(())
}
fn git_add_path(root: &Path, path: &Path) -> RhoResult<()> {
let relative = path
.strip_prefix(root)
.map(|path| path.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
git_add_relative(root, &relative)
}
fn install_pre_commit_hook(root: &Path) -> RhoResult<()> {
let hook = root.join(".git/hooks/pre-commit");
ensure_parent(&hook)?;
let content = r#"#!/usr/bin/env bash
set -euo pipefail
failed=0
while IFS= read -r -d '' path; do
attr="$(git check-attr filter -- "$path" | sed 's/^.*: filter: //')"
if [[ "$attr" == "rho-crypt" ]]; then
if ! git show ":$path" | grep -Eq "kind: rho_(transparent_file|recipient_envelope)"; then
echo "rho: protected path is staged without rho encryption: $path" >&2
failed=1
fi
fi
done < <(git diff --cached --name-only -z)
if [[ "$failed" -ne 0 ]]; then
echo "rho: run git add again after configuring rho repo protect-path, or use rho crypto encrypt for explicit envelopes." >&2
exit 1
fi
"#;
fs::write(&hook, content)?;
let status = Command::new("chmod").arg("+x").arg(&hook).status()?;
if !status.success() {
return Err(format!("failed to chmod hook: {}", hook.display()).into());
}
Ok(())
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn repeated_arg(args: &[String], flag: &str) -> Vec<String> {
args.windows(2)
.filter(|window| window[0] == flag)
.map(|window| window[1].clone())
.collect()
}
fn write_tool(
root: &Path,
id: &str,
action_type: &str,
owner: &str,
approval_required: bool,
force: bool,
) -> RhoResult<()> {
let path = root.join("rho/tools").join(format!("{id}.yaml"));
let manifest = ToolManifest {
version: 1,
tool: Tool {
id: id.to_string(),
action_type: action_type.to_string(),
owner: owner.to_string(),
approval_required,
command_template: default_tool_command_template(action_type),
},
};
write_if_missing_or_forced(&path, &to_yaml(&manifest)?, force)
}
fn default_tool_command_template(action_type: &str) -> Vec<String> {
match action_type {
"run_mock_data" | "run_real_data" => vec![
"python3".to_string(),
"CODE_PATH".to_string(),
"DATASET_CSV".to_string(),
],
_ => Vec::new(),
}
}
fn write_if_missing_or_forced(path: &Path, content: &str, force: bool) -> RhoResult<()> {
if path.exists() && !force {
return Ok(());
}
ensure_parent(path)?;
fs::write(path, content)?;
Ok(())
}
fn validate_repo_id(value: &str) -> RhoResult<()> {
if !value.starts_with("rho://repo/") {
return Err(format!("repo id must start with rho://repo/: {value}").into());
}
if value.contains('\0') || value.contains("..") {
return Err(format!("invalid repo id: {value}").into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_repo_ids() {
assert!(validate_repo_id("rho://repo/local/example").is_ok());
assert!(validate_repo_id("rho://id/github/user").is_err());
assert!(validate_repo_id("rho://repo/../bad").is_err());
}
#[test]
fn shell_quotes_filter_commands() {
assert_eq!(shell_quote("/tmp/rho"), "'/tmp/rho'");
assert_eq!(shell_quote("/tmp/rho's bin"), "'/tmp/rho'\\''s bin'");
}
#[test]
fn generated_gitattributes_patterns_match_policy_matcher() {
let permissions = PermissionsManifest {
permissions: vec![
PermissionRule {
id: "owner-inbox".to_string(),
pattern: "rho/messages/inbox/id/github/*/req-*/request.yaml".to_string(),
read: ReadPermission {
public: false,
repo_key: false,
encrypted_to: vec!["rho://id/github/rho-owner".to_string()],
},
write: WritePermission::default(),
},
PermissionRule {
id: "nested-results".to_string(),
pattern: "rho/messages/**/result.*".to_string(),
read: ReadPermission {
public: false,
repo_key: false,
encrypted_to: vec!["rho://id/github/rho-collab".to_string()],
},
write: WritePermission::default(),
},
PermissionRule {
id: "repo-key-secret".to_string(),
pattern: "secrets/**".to_string(),
read: ReadPermission {
public: false,
repo_key: true,
encrypted_to: Vec::new(),
},
write: WritePermission::default(),
},
PermissionRule {
id: "public-docs".to_string(),
pattern: "docs/**".to_string(),
read: ReadPermission {
public: true,
repo_key: false,
encrypted_to: Vec::new(),
},
write: WritePermission::default(),
},
],
};
let generated = generated_gitattributes_block(&permissions);
let generated_patterns = generated
.lines()
.filter(|line| line.contains("filter=rho-crypt"))
.map(|line| line.split_whitespace().next().unwrap())
.collect::<Vec<_>>();
assert_eq!(
generated_patterns,
vec![
"rho/messages/**/result.*",
"rho/messages/inbox/id/github/*/req-*/request.yaml",
"secrets/**"
]
);
assert!(generated_patterns.iter().any(|pattern| {
path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/request.yaml",
pattern,
)
}));
assert!(generated_patterns.iter().any(|pattern| {
path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/result.yaml",
pattern,
)
}));
assert!(!generated_patterns.iter().any(|pattern| {
path_matches_pattern(
"rho/messages/inbox/id/github/rho-owner/req-123/notes/request.yaml",
pattern,
)
}));
assert!(
!generated_patterns
.iter()
.any(|pattern| path_matches_pattern("docs/private.md", pattern))
);
assert!(
generated_patterns
.iter()
.any(|pattern| path_matches_pattern("secrets/note.txt", pattern))
);
}
}