rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit f194042783c39c7021a36963cd9698930e8c519d
parent b46df3d991f4f7bedd9b4cb1ff0eb1f37d13ae84
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 19:23:11 +0000

cli: freeze one-pass RHI command admission

- validate explicit bootstrap profile, instance, and bounded absolute paths
- close the offline and live Unix-admin command-name inventory
- reject prototype identity generation, path leaves, and rekey surfaces
- expose only redacted invocation diagnostics and stable source-free failures

Diffstat:
MAGENTS.md | 4++--
MREADME | 23+++++++++++++++++++++++
Asrc/cli_v1.rs | 888+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 7+++++++
Atests/services_hardening_cli.rs | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1225 insertions(+), 2 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -223,8 +223,8 @@ client, or the daemon. Config validation/schema and pre-service initialization are offline. State init/restore/verify/migrate and initial identity provisioning require proof that no writer lock exists. -- Detailed status, redacted effective config, online backup, identity - status/rekey/replace, reconciliation/job/source status, bounded trade/report +- Detailed status, redacted effective config, online backup, identity status + and public export, reconciliation/job/source status, bounded trade/report queries, publication/target retry or refresh, metrics snapshot, and presence status/refresh use bounded, versioned HTTP/JSON over a permissioned Unix socket while live. Do not add TCP admin, browser auth, CORS, direct writable diff --git a/README b/README @@ -34,6 +34,29 @@ exact `toml` or `safe_default` origin for every leaf and the governed authority behind each safe default. Parsing performs no filesystem, environment, identity, database, clock, entropy, logging, DNS, or network operation. +## Hardened v1 command admission + +`parse_rhi_cli_v1_from` parses the bootstrap and closed command tree exactly +once. Every invocation explicitly selects `service-host`, `interactive`, or +`repo-local`, a validated instance, and optionally an absolute configuration +path. Repo-local selection additionally requires one absolute base root and +that root is forbidden for the other profiles. Human output is the default; +governed JSON output is selected explicitly. + +The command inventory covers daemon run; config init, validate, show, schema, +and apply; state init, status, backup, restore, verify, and migrate; service +identity init, status, and public export; service status and metrics snapshot; +reconciliation status, jobs, and refresh; source listing; trade projection and +report queries; publication backlog, targets, and retry; desired-presence +inspection, render, and refresh; and doctor. The parser performs no command +execution. Later ordered steps bind exact command inputs and each offline, +daemon, or live Unix-admin authority. + +Ordinary `run` accepts no identity-generation, identity-path, log-path, +database-path, worker, environment-file, or arbitrary path-leaf flag. Identity +rekey and replacement are not commands; rotation is a create-new offline +artifact plus governed configuration apply. + Use the repository-owned Nix lanes for validation: ```text diff --git a/src/cli_v1.rs b/src/cli_v1.rs @@ -0,0 +1,888 @@ +//! One-pass command-line admission for the hardened RHI command contract. + +use std::error::Error; +use std::ffi::OsString; +use std::fmt; +use std::path::{Component, Path, PathBuf}; + +use clap::{Parser, Subcommand, ValueEnum}; + +/// Maximum encoded length of an RHI instance identifier. +pub const RHI_INSTANCE_ID_MAX_BYTES: usize = 128; + +/// The exact bootstrap profile selected by the operator. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiBootstrapProfileV1 { + ServiceHost, + Interactive, + RepoLocal, +} + +/// The only two governed command-result encodings. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RhiCliOutputModeV1 { + #[default] + Human, + Json, +} + +/// The exact governed top-level RHI command inventory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiCommandV1 { + Run, + Config(RhiConfigCommandV1), + State(RhiStateCommandV1), + Identity(RhiIdentityCommandV1), + Status, + Metrics(RhiMetricsCommandV1), + Reconciliation(RhiReconciliationCommandV1), + Sources(RhiSourcesCommandV1), + Trade(RhiTradeCommandV1), + Publication(RhiPublicationCommandV1), + Presence(RhiPresenceCommandV1), + Doctor, +} + +/// Governed configuration commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiConfigCommandV1 { + Init, + Validate, + Show, + Schema, + Apply, +} + +/// Governed state commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiStateCommandV1 { + Init, + Status, + Backup, + Restore, + Verify, + Migrate, +} + +/// Governed service-identity commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiIdentityCommandV1 { + Init, + Status, + ExportPublic, +} + +/// Governed metrics commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiMetricsCommandV1 { + Snapshot, +} + +/// Governed reconciliation commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiReconciliationCommandV1 { + Status, + Jobs, + Refresh, +} + +/// Governed evidence-source commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiSourcesCommandV1 { + List, +} + +/// Governed trade-query commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiTradeCommandV1 { + Projection, + ReportCurrent, + Reports, +} + +/// Governed publication commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPublicationCommandV1 { + Backlog, + Targets, + Retry, +} + +/// Governed desired-presence commands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiPresenceCommandV1 { + Desired, + Render, + Refresh, +} + +/// Stable source-free classification for command-line admission failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiCliV1ErrorKind { + InvalidArguments, + InvalidInstance, + InvalidRepoLocalRoot, + UnexpectedRepoLocalRoot, + InvalidConfigPath, +} + +impl RhiCliV1ErrorKind { + const fn message(self) -> &'static str { + match self { + Self::InvalidArguments => "command-line arguments are invalid", + Self::InvalidInstance => "instance identifier is invalid", + Self::InvalidRepoLocalRoot => "repo-local profile requires a valid absolute root", + Self::UnexpectedRepoLocalRoot => { + "repo-local root is forbidden outside the repo-local profile" + } + Self::InvalidConfigPath => "configuration path must be absolute without traversal", + } + } +} + +/// One safe command-line admission failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiCliV1Error { + kind: RhiCliV1ErrorKind, +} + +impl RhiCliV1Error { + const fn new(kind: RhiCliV1ErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> RhiCliV1ErrorKind { + self.kind + } +} + +impl fmt::Debug for RhiCliV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiCliV1Error") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiCliV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for RhiCliV1Error {} + +/// A validated one-pass RHI bootstrap and command selection. +pub struct RhiCliInvocationV1 { + profile: RhiBootstrapProfileV1, + instance: Box<str>, + repo_local_root: Option<PathBuf>, + config_path: Option<PathBuf>, + output_mode: RhiCliOutputModeV1, + command: RhiCommandV1, +} + +impl RhiCliInvocationV1 { + /// Returns the explicitly selected bootstrap profile. + #[must_use] + pub const fn profile(&self) -> RhiBootstrapProfileV1 { + self.profile + } + + /// Returns the validated instance identifier. + #[must_use] + pub fn instance(&self) -> &str { + &self.instance + } + + /// Returns the explicit repo-local root, when selected. + #[must_use] + pub fn repo_local_root(&self) -> Option<&Path> { + self.repo_local_root.as_deref() + } + + /// Returns the optional explicit configuration path. + #[must_use] + pub fn config_path(&self) -> Option<&Path> { + self.config_path.as_deref() + } + + /// Returns the selected human or governed machine-result encoding. + #[must_use] + pub const fn output_mode(&self) -> RhiCliOutputModeV1 { + self.output_mode + } + + /// Returns the exact governed command selection. + #[must_use] + pub const fn command(&self) -> RhiCommandV1 { + self.command + } +} + +impl fmt::Debug for RhiCliInvocationV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiCliInvocationV1") + .field("profile", &self.profile) + .field("instance", &"[redacted]") + .field( + "repo_local_root", + &self.repo_local_root.as_ref().map(|_| "[redacted]"), + ) + .field( + "config_path", + &self.config_path.as_ref().map(|_| "[redacted]"), + ) + .field("output_mode", &self.output_mode) + .field("command", &self.command) + .finish() + } +} + +/// Parses the exact hardened RHI bootstrap and command tree once. +/// +/// The iterator must include the program name as its first element. Clap's +/// dependency-owned diagnostic is deliberately discarded so caller-controlled +/// argument text cannot escape through this crate's stable error boundary. +pub fn parse_rhi_cli_v1_from<I, T>(arguments: I) -> Result<RhiCliInvocationV1, RhiCliV1Error> +where + I: IntoIterator<Item = T>, + T: Into<OsString> + Clone, +{ + let parsed = RawRhiCliV1::try_parse_from(arguments) + .map_err(|_| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?; + let profile = parsed + .profile + .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))? + .into(); + let instance = parsed + .instance + .ok_or_else(|| RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidArguments))?; + validate_instance(&instance)?; + validate_bootstrap_paths( + profile, + parsed.repo_local_root.as_deref(), + parsed.config.as_deref(), + )?; + + Ok(RhiCliInvocationV1 { + profile, + instance: instance.into_boxed_str(), + repo_local_root: parsed.repo_local_root, + config_path: parsed.config, + output_mode: parsed.output.into(), + command: parsed.command.into(), + }) +} + +fn validate_instance(value: &str) -> Result<(), RhiCliV1Error> { + let bytes = value.as_bytes(); + let is_boundary = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + if bytes.is_empty() + || bytes.len() > RHI_INSTANCE_ID_MAX_BYTES + || !is_boundary(bytes[0]) + || !is_boundary(bytes[bytes.len() - 1]) + || !bytes + .iter() + .all(|byte| is_boundary(*byte) || matches!(*byte, b'-' | b'_')) + { + return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidInstance)); + } + Ok(()) +} + +fn validate_bootstrap_paths( + profile: RhiBootstrapProfileV1, + repo_local_root: Option<&Path>, + config_path: Option<&Path>, +) -> Result<(), RhiCliV1Error> { + match (profile, repo_local_root) { + (RhiBootstrapProfileV1::RepoLocal, Some(root)) if valid_absolute_path(root, true) => {} + (RhiBootstrapProfileV1::RepoLocal, _) => { + return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidRepoLocalRoot)); + } + (_, Some(_)) => { + return Err(RhiCliV1Error::new( + RhiCliV1ErrorKind::UnexpectedRepoLocalRoot, + )); + } + (_, None) => {} + } + + if config_path.is_some_and(|path| !valid_absolute_path(path, true)) { + return Err(RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidConfigPath)); + } + Ok(()) +} + +fn valid_absolute_path(path: &Path, require_non_root: bool) -> bool { + path.is_absolute() + && (!require_non_root || path.parent().is_some()) + && path + .to_str() + .is_some_and(|value| !value.is_empty() && value.len() <= 4_096) + && !path + .components() + .any(|component| matches!(component, Component::ParentDir)) +} + +#[derive(Parser)] +#[command(name = "rhi", disable_help_subcommand = true)] +struct RawRhiCliV1 { + #[arg(long, global = true, value_enum)] + profile: Option<RawProfile>, + #[arg(long, global = true)] + instance: Option<String>, + #[arg(long = "repo-local-root", global = true)] + repo_local_root: Option<PathBuf>, + #[arg(long, global = true)] + config: Option<PathBuf>, + #[arg(long, global = true, value_enum, default_value_t = RawOutputMode::Human)] + output: RawOutputMode, + #[command(subcommand)] + command: RawCommand, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RawProfile { + ServiceHost, + Interactive, + RepoLocal, +} + +impl From<RawProfile> for RhiBootstrapProfileV1 { + fn from(value: RawProfile) -> Self { + match value { + RawProfile::ServiceHost => Self::ServiceHost, + RawProfile::Interactive => Self::Interactive, + RawProfile::RepoLocal => Self::RepoLocal, + } + } +} + +#[derive(Clone, Copy, Default, ValueEnum)] +enum RawOutputMode { + #[default] + Human, + Json, +} + +impl From<RawOutputMode> for RhiCliOutputModeV1 { + fn from(value: RawOutputMode) -> Self { + match value { + RawOutputMode::Human => Self::Human, + RawOutputMode::Json => Self::Json, + } + } +} + +#[derive(Subcommand)] +enum RawCommand { + Run, + Config { + #[command(subcommand)] + command: RawConfigCommand, + }, + State { + #[command(subcommand)] + command: RawStateCommand, + }, + Identity { + #[command(subcommand)] + command: RawIdentityCommand, + }, + Status, + Metrics { + #[command(subcommand)] + command: RawMetricsCommand, + }, + Reconciliation { + #[command(subcommand)] + command: RawReconciliationCommand, + }, + Sources { + #[command(subcommand)] + command: RawSourcesCommand, + }, + Trade { + #[command(subcommand)] + command: RawTradeCommand, + }, + Publication { + #[command(subcommand)] + command: RawPublicationCommand, + }, + Presence { + #[command(subcommand)] + command: RawPresenceCommand, + }, + Doctor, +} + +impl From<RawCommand> for RhiCommandV1 { + fn from(value: RawCommand) -> Self { + match value { + RawCommand::Run => Self::Run, + RawCommand::Config { command } => Self::Config(command.into()), + RawCommand::State { command } => Self::State(command.into()), + RawCommand::Identity { command } => Self::Identity(command.into()), + RawCommand::Status => Self::Status, + RawCommand::Metrics { command } => Self::Metrics(command.into()), + RawCommand::Reconciliation { command } => Self::Reconciliation(command.into()), + RawCommand::Sources { command } => Self::Sources(command.into()), + RawCommand::Trade { command } => Self::Trade(command.into()), + RawCommand::Publication { command } => Self::Publication(command.into()), + RawCommand::Presence { command } => Self::Presence(command.into()), + RawCommand::Doctor => Self::Doctor, + } + } +} + +macro_rules! command_enum { + ($raw:ident, $public:ident, { $($variant:ident),+ $(,)? }) => { + #[derive(Subcommand)] + enum $raw { + $($variant),+ + } + + impl From<$raw> for $public { + fn from(value: $raw) -> Self { + match value { + $($raw::$variant => Self::$variant),+ + } + } + } + }; +} + +command_enum!(RawConfigCommand, RhiConfigCommandV1, { + Init, + Validate, + Show, + Schema, + Apply, +}); +command_enum!(RawStateCommand, RhiStateCommandV1, { + Init, + Status, + Backup, + Restore, + Verify, + Migrate, +}); +command_enum!(RawIdentityCommand, RhiIdentityCommandV1, { + Init, + Status, + ExportPublic, +}); +command_enum!(RawMetricsCommand, RhiMetricsCommandV1, { Snapshot }); +command_enum!(RawReconciliationCommand, RhiReconciliationCommandV1, { + Status, + Jobs, + Refresh, +}); +command_enum!(RawSourcesCommand, RhiSourcesCommandV1, { List }); +command_enum!(RawTradeCommand, RhiTradeCommandV1, { + Projection, + ReportCurrent, + Reports, +}); +command_enum!(RawPublicationCommand, RhiPublicationCommandV1, { + Backlog, + Targets, + Retry, +}); +command_enum!(RawPresenceCommand, RhiPresenceCommandV1, { + Desired, + Render, + Refresh, +}); + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(command: &[&str]) -> Result<RhiCliInvocationV1, RhiCliV1Error> { + let mut arguments = vec!["rhi", "--profile", "service-host", "--instance", "default"]; + arguments.extend_from_slice(command); + parse_rhi_cli_v1_from(arguments) + } + + #[test] + fn exact_command_inventory_parses() { + let vectors = [ + (&["run"][..], RhiCommandV1::Run), + ( + &["config", "init"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Init), + ), + ( + &["config", "validate"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Validate), + ), + ( + &["config", "show"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Show), + ), + ( + &["config", "schema"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Schema), + ), + ( + &["config", "apply"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Apply), + ), + ( + &["state", "init"][..], + RhiCommandV1::State(RhiStateCommandV1::Init), + ), + ( + &["state", "status"][..], + RhiCommandV1::State(RhiStateCommandV1::Status), + ), + ( + &["state", "backup"][..], + RhiCommandV1::State(RhiStateCommandV1::Backup), + ), + ( + &["state", "restore"][..], + RhiCommandV1::State(RhiStateCommandV1::Restore), + ), + ( + &["state", "verify"][..], + RhiCommandV1::State(RhiStateCommandV1::Verify), + ), + ( + &["state", "migrate"][..], + RhiCommandV1::State(RhiStateCommandV1::Migrate), + ), + ( + &["identity", "init"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::Init), + ), + ( + &["identity", "status"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::Status), + ), + ( + &["identity", "export-public"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic), + ), + (&["status"][..], RhiCommandV1::Status), + ( + &["metrics", "snapshot"][..], + RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot), + ), + ( + &["reconciliation", "status"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status), + ), + ( + &["reconciliation", "jobs"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs), + ), + ( + &["reconciliation", "refresh"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh), + ), + ( + &["sources", "list"][..], + RhiCommandV1::Sources(RhiSourcesCommandV1::List), + ), + ( + &["trade", "projection"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::Projection), + ), + ( + &["trade", "report-current"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent), + ), + ( + &["trade", "reports"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::Reports), + ), + ( + &["publication", "backlog"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog), + ), + ( + &["publication", "targets"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Targets), + ), + ( + &["publication", "retry"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Retry), + ), + ( + &["presence", "desired"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Desired), + ), + ( + &["presence", "render"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Render), + ), + ( + &["presence", "refresh"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh), + ), + (&["doctor"][..], RhiCommandV1::Doctor), + ]; + for (arguments, expected) in vectors { + assert_eq!(parse(arguments).expect("command").command(), expected); + } + } + + #[test] + fn profiles_paths_and_output_are_cross_bound() { + for profile in ["service-host", "interactive"] { + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + profile, + "--instance", + "north-01", + "--config", + "/etc/radroots/rhi.toml", + "--output", + "json", + "run", + ]) + .expect("host profile"); + assert_eq!(invocation.instance(), "north-01"); + assert_eq!( + invocation.config_path(), + Some(Path::new("/etc/radroots/rhi.toml")) + ); + assert_eq!(invocation.output_mode(), RhiCliOutputModeV1::Json); + assert!(invocation.repo_local_root().is_none()); + } + + let repo_local = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "dev", + "--repo-local-root", + "/repo/radroots", + "config", + "validate", + ]) + .expect("repo local"); + assert_eq!(repo_local.profile(), RhiBootstrapProfileV1::RepoLocal); + assert_eq!( + repo_local.repo_local_root(), + Some(Path::new("/repo/radroots")) + ); + assert_eq!(repo_local.output_mode(), RhiCliOutputModeV1::Human); + } + + #[test] + fn invalid_bootstrap_values_fail_with_stable_kinds() { + for value in ["Upper", "north-", "north.west"] { + let error = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "service-host", + "--instance", + value, + "run", + ]) + .expect_err("invalid instance"); + assert_eq!(error.kind(), RhiCliV1ErrorKind::InvalidInstance); + } + + let exact = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES); + assert!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "service-host", + "--instance", + exact.as_str(), + "run", + ]) + .is_ok() + ); + let overlong = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES + 1); + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "service-host", + "--instance", + overlong.as_str(), + "run", + ]) + .expect_err("overlong instance") + .kind(), + RhiCliV1ErrorKind::InvalidInstance + ); + + assert_eq!( + parse_rhi_cli_v1_from(["rhi", "--profile", "repo-local", "--instance", "dev", "run"]) + .expect_err("missing root") + .kind(), + RhiCliV1ErrorKind::InvalidRepoLocalRoot + ); + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + "dev", + "--repo-local-root", + "/repo/radroots", + "run", + ]) + .expect_err("unexpected root") + .kind(), + RhiCliV1ErrorKind::UnexpectedRepoLocalRoot + ); + for invalid in ["relative", "/", "/repo/../escape"] { + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "dev", + "--repo-local-root", + invalid, + "run", + ]) + .expect_err("invalid root") + .kind(), + RhiCliV1ErrorKind::InvalidRepoLocalRoot + ); + } + for invalid in ["relative.toml", "/", "/etc/../secret.toml"] { + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--config", + invalid, + "run", + ]) + .expect_err("invalid config") + .kind(), + RhiCliV1ErrorKind::InvalidConfigPath + ); + } + } + + #[test] + fn removed_and_unknown_inputs_fail_without_sources() { + for arguments in [ + vec!["rhi", "run"], + vec!["rhi", "--profile", "service-host", "run"], + vec!["rhi", "--profile", "service-host", "--instance", "default"], + vec![ + "rhi", + "--profile", + "production", + "--instance", + "default", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--identity", + "/secret", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--allow-generate-identity", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--logs-dir", + "/tmp/logs", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--worker", + "legacy", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "identity", + "rekey", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "identity", + "replace", + ], + ] { + let failure = parse_rhi_cli_v1_from(arguments).expect_err("arguments must fail"); + assert_eq!(failure.kind(), RhiCliV1ErrorKind::InvalidArguments); + assert!(Error::source(&failure).is_none()); + } + } + + #[test] + fn debug_and_errors_do_not_render_caller_values() { + let instance = "sensitive-instance"; + let config = "/sensitive/config.toml"; + let invocation = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "service-host", + "--instance", + instance, + "--config", + config, + "doctor", + ]) + .expect("invocation"); + let debug = format!("{invocation:?}"); + assert!(!debug.contains(instance)); + assert!(!debug.contains(config)); + + let secret = "secret-cli-value"; + let failure = + parse_rhi_cli_v1_from(["rhi", "--profile", secret, "--instance", "default", "run"]) + .expect_err("invalid profile"); + let rendered = format!("{failure} {failure:?}"); + assert!(!rendered.contains(secret)); + assert!(Error::source(&failure).is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -2,6 +2,7 @@ pub mod adapters; pub mod cli; +mod cli_v1; pub mod config; mod config_v1; pub mod features; @@ -14,6 +15,12 @@ pub mod paths; pub mod rhi; pub use cli::Args as cli_args; +pub use cli_v1::{ + RHI_INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliInvocationV1, RhiCliOutputModeV1, + RhiCliV1Error, RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, + RhiMetricsCommandV1, RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, + RhiSourcesCommandV1, RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, +}; pub use config_v1::{ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA, RHI_CONFIG_SCHEMA_VERSION, RhiConfigDefaultAuthority, RhiConfigDocumentV1, RhiConfigProfile, diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs @@ -0,0 +1,305 @@ +#![forbid(unsafe_code)] + +use std::error::Error; +use std::path::Path; + +use rhi::{ + RHI_INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliOutputModeV1, RhiCliV1ErrorKind, + RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, + RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, + RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, +}; + +fn parse(command: &[&str]) -> rhi::RhiCliInvocationV1 { + let mut arguments = vec!["rhi", "--profile", "service-host", "--instance", "default"]; + arguments.extend_from_slice(command); + parse_rhi_cli_v1_from(arguments).expect("governed command") +} + +#[test] +fn root_api_exposes_the_complete_closed_command_inventory() { + let vectors = [ + (&["run"][..], RhiCommandV1::Run), + ( + &["config", "init"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Init), + ), + ( + &["config", "validate"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Validate), + ), + ( + &["config", "show"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Show), + ), + ( + &["config", "schema"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Schema), + ), + ( + &["config", "apply"][..], + RhiCommandV1::Config(RhiConfigCommandV1::Apply), + ), + ( + &["state", "init"][..], + RhiCommandV1::State(RhiStateCommandV1::Init), + ), + ( + &["state", "status"][..], + RhiCommandV1::State(RhiStateCommandV1::Status), + ), + ( + &["state", "backup"][..], + RhiCommandV1::State(RhiStateCommandV1::Backup), + ), + ( + &["state", "restore"][..], + RhiCommandV1::State(RhiStateCommandV1::Restore), + ), + ( + &["state", "verify"][..], + RhiCommandV1::State(RhiStateCommandV1::Verify), + ), + ( + &["state", "migrate"][..], + RhiCommandV1::State(RhiStateCommandV1::Migrate), + ), + ( + &["identity", "init"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::Init), + ), + ( + &["identity", "status"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::Status), + ), + ( + &["identity", "export-public"][..], + RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic), + ), + (&["status"][..], RhiCommandV1::Status), + ( + &["metrics", "snapshot"][..], + RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot), + ), + ( + &["reconciliation", "status"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status), + ), + ( + &["reconciliation", "jobs"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs), + ), + ( + &["reconciliation", "refresh"][..], + RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh), + ), + ( + &["sources", "list"][..], + RhiCommandV1::Sources(RhiSourcesCommandV1::List), + ), + ( + &["trade", "projection"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::Projection), + ), + ( + &["trade", "report-current"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent), + ), + ( + &["trade", "reports"][..], + RhiCommandV1::Trade(RhiTradeCommandV1::Reports), + ), + ( + &["publication", "backlog"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog), + ), + ( + &["publication", "targets"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Targets), + ), + ( + &["publication", "retry"][..], + RhiCommandV1::Publication(RhiPublicationCommandV1::Retry), + ), + ( + &["presence", "desired"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Desired), + ), + ( + &["presence", "render"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Render), + ), + ( + &["presence", "refresh"][..], + RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh), + ), + (&["doctor"][..], RhiCommandV1::Doctor), + ]; + for (arguments, expected) in vectors { + assert_eq!(parse(arguments).command(), expected); + } +} + +#[test] +fn bootstrap_values_are_explicit_bounded_and_cross_bound() { + let repo = parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "repo-local", + "--instance", + "review_01", + "--repo-local-root", + "/repo/radroots", + "--config", + "/repo/config/rhi.toml", + "--output", + "json", + "doctor", + ]) + .expect("repo-local invocation"); + assert_eq!(repo.profile(), RhiBootstrapProfileV1::RepoLocal); + assert_eq!(repo.instance(), "review_01"); + assert_eq!(repo.repo_local_root(), Some(Path::new("/repo/radroots"))); + assert_eq!(repo.config_path(), Some(Path::new("/repo/config/rhi.toml"))); + assert_eq!(repo.output_mode(), RhiCliOutputModeV1::Json); + + let exact = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES); + assert!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + exact.as_str(), + "run", + ]) + .is_ok() + ); + let over = "a".repeat(RHI_INSTANCE_ID_MAX_BYTES + 1); + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + over.as_str(), + "run", + ]) + .expect_err("overlong instance") + .kind(), + RhiCliV1ErrorKind::InvalidInstance + ); + + let exact_path = format!("/{}", "a".repeat(4_095)); + assert_eq!(exact_path.len(), 4_096); + assert!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + "default", + "--config", + exact_path.as_str(), + "run", + ]) + .is_ok() + ); + let overlong_path = format!("{exact_path}a"); + assert_eq!( + parse_rhi_cli_v1_from([ + "rhi", + "--profile", + "interactive", + "--instance", + "default", + "--config", + overlong_path.as_str(), + "run", + ]) + .expect_err("overlong path") + .kind(), + RhiCliV1ErrorKind::InvalidConfigPath + ); +} + +#[test] +fn prototype_and_unsafe_commands_are_absent_and_errors_are_safe() { + for arguments in [ + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--allow-generate-identity", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--identity", + "/secret/identity", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--state-path", + "/tmp/state", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "--worker", + "rhi", + "run", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "identity", + "rekey", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "identity", + "replace", + ], + vec![ + "rhi", + "--profile", + "service-host", + "--instance", + "default", + "attestation-smoke", + ], + ] { + let failure = parse_rhi_cli_v1_from(arguments).expect_err("removed surface"); + assert_eq!(failure.kind(), RhiCliV1ErrorKind::InvalidArguments); + assert!(Error::source(&failure).is_none()); + } + + let secret = "never-render-cli-secret"; + let failure = + parse_rhi_cli_v1_from(["rhi", "--profile", secret, "--instance", "default", "run"]) + .expect_err("secret profile"); + assert!(!format!("{failure} {failure:?}").contains(secret)); +}